Source: mediaHandler.js

/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.*/

/*
 * Base class for all Canopy Media Handlers
 *
 * This is little more than a interface class
 */
class mediaHandler{ 
    /**
     * Instantiates a new Media Handler object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player  - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     * @param {String} type - Media Handler Source Type
     */
    constructor(client, player, media, type){
        /**
         * Parent Client Management Object
         */
        this.client = client;

        /**
         * Parent Canopy Player Object
         */
        this.player = player;

        /**
         * Media Handler Source Type
         */
        this.type = type

        /*
         * Denotes wether a seek call was made by the syncing function
         */
        this.selfAct = false;

        /*
         * Contains the last received time stamp
         */
        this.lastTimestamp = 0;

        //Ingest media object from server
        this.startMedia(media);
    }

    /**
     * Ingests media nd starts playback
     * @param {Object} media - Media object from server
     */
    startMedia(media){
        //If we properly ingested the media
        if(this.ingestMedia(media)){
            //Build the video player
            this.buildPlayer();

            //Call the start function
            this.start();
        }
    }

    /**
     * Builds video player element
     */
    buildPlayer(){
        //Reset player lock
        this.lock = false;
    }

    /**
     * Destroys video player element
     */
    destroyPlayer(){
        //Null out video property
        this.video = null;
    }

    /**
     * Ingests media object from server
     * @param {Object} media - Media object from the server
     * @returns {Boolean} True upon success
     */
    ingestMedia(media){
        //Set now playing
        this.nowPlaying = media;
        
        //return true to signify success
        return true;
    }

    /**
     * Starts video playback
     */
    start(){
        this.setVideoTitle(this.nowPlaying.title);
    }

    /**
     * Syncronizes timestamp based on timestamp received from server
     * @param {Number} timestamp - Current video timestamp in seconds
     */
    sync(timestamp = this.lastTimestamp){
        //Skip sync calls that won't seek so we don't pointlessly throw selfAct
        if(timestamp != this.video.currentTime){
            //Set self act flag
            this.selfAct = true;
        }
    }

    /**
     * Reloads media player
     */
    reload(){
        //Get current timestamp
        const timestamp = this.video.currentTime;

        //Throw self act flag to make sure we don't un-sync the player
        this.selfAct = true;
    }

    /**
     * Handles media end
     */
    end(){
        //Null out current media
        this.nowPlaying = null;

        //Throw self act to prevent unlock on video end
        this.selfAct = true;

        //Destroy the player
        this.destroyPlayer();
    }

    /**
     * Plays video
     */
    play(){
    }

    /**
     * Pauses video
     */
    pause(){
    }

    /**
     * Toggles player control lockout
     * @param {Boolean} lock - Whether or not to lock-out user control of video
     */
    setPlayerLock(lock){
        //set lock property
        this.lock = lock;
    }

    /**
     * Calculates Aspect Ratio of media
     * @returns {Number} Media Aspect Ratio as Floating Point number
     */
    getRatio(){
        return 4/3;
    } 

    /**
     * Gets current timestamp from video
     * @returns {Number} Media Timestamp in seconds
     */
    getTimestamp(){
        return 0;
    }

    /**
     * Sets player title
     * @param {String} title - Title to set
     */
    setVideoTitle(title){
        this.player.title.textContent = `Currently Playing: ${title}`;
    }

    /**
     * Called once all video metadata has properly been fetched
     * @param {Event} event - Event passed down by event handler
     */
    onMetadataLoad(event){
        //Resize aspect (if locked), since the video doesn't properly report it's resolution until it's been loaded
        this.client.chatBox.resizeAspect();
    }

    /**
     * Called on media pause
     * @param {Event} event - Event passed down by event handler
     */
    onPause(event){
        //If the video was paused out-side of code
        if(!this.selfAct){
            this.player.unlockSync();
        }

        this.selfAct = false;
    }

    /**
     * Called on media volume change
     * @param {Event} event - Event passed down by event handler
     */
    onVolumeChange(event){
    }

    /**
     * Called on media seek
     * @param {Event} event - Event passed down by event handler
     */
    onSeek(event){
        //If the video was seeked out-side of code
        if(!this.selfAct){
            this.player.unlockSync();
        }

        //reset self act flag
        this.selfAct = false;
    }

    /**
     * Called on media buffer
     * @param {Event} event - Event passed down by event handler
     */
    onBuffer(){
        this.selfAct = true;
    }
}

/**
 * Class containing basic building blocks for anything that touches a <video> tag
 * @extends mediaHandler
 */
class rawFileBase extends mediaHandler{
    /**
     * Instantiates a new rawFileBase object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player  - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     * @param {String} type - Media Handler Source Type
     */
    constructor(client, player, media, type){
        super(client, player, media, type);
    }

    /**
     * Defines input-related event listeners
     */
    defineListeners(){
        //Resize to aspect on metadata load
        this.video.addEventListener('loadedmetadata', this.onMetadataLoad.bind(this));
        this.video.addEventListener('volumechange', this.onVolumeChange.bind(this));
    }

    buildPlayer(){
        //Create player
        this.video = document.createElement('video');

        //Enable controls
        this.video.controls = true;

        //Append it to page
        this.player.videoContainer.appendChild(this.video);

        //Run derived method
        super.buildPlayer();
    }

    destroyPlayer(){
        //Stops playback
        this.video.pause();
        //Remove player from page
        this.video.remove();
        //Run derived method
        super.destroyPlayer();
    }

    reload(){
        //Call derived method
        super.reload();

        //Load video from source
        this.video.load();

        //Set it back to the proper time
        this.video.currentTime = this.lastTimestamp;

        //Play the video
        this.video.play();
    }

    setPlayerLock(lock){
            //toggle controls
            this.video.controls = !lock;
            //Only toggle mute if we're locking, or if we're unlocking after being locked
            //If this is ran twice without locking we don't want to surprise unmute on the user
            if(lock || this.lock){
                //toggle mute
                this.video.muted = lock;
            }
            //toggle looping
            this.video.loop = lock;

            //Run derived method
            super.setPlayerLock(lock);
    }

    getRatio(){
        return this.video.videoWidth / this.video.videoHeight;
    } 

    onVolumeChange(event){
        //Pull volume from video
        this.player.volume = this.video.volume;
    }
}

/** 
 * Off air static 'player'
 * @extends rawFileBase
 */
class nullHandler extends rawFileBase{
    /**
     * Instantiates a new Null Handler object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player  - Parent Canopy Player Object
     */
    constructor(client, player){
        //Call derived constructor
        super(client, player, {}, null);

        this.defineListeners();
    }

    defineListeners(){
        //Run derived method
        super.defineListeners();

        //Disable right clicking
        this.video.addEventListener('contextmenu', (e)=>{e.preventDefault()});
    }

    start(){
        //call derived start function
        super.start();        

        //Lock the player
        this.setPlayerLock(true);

        //Set the static placeholder
        this.video.src = '/video/static.webm';

        //play the placeholder video
        this.video.play();
    }

    setVideoTitle(title){
        this.player.title.textContent = `Channel Off Air`;
    }
}

/**
 * Basic building blocks needed for proper time-synchronized raw-file playback
 * @extends rawFileBase
 */
class rawFileHandler extends rawFileBase{
    /**
     * Instantiates a new Null Handler object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player  - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     */
    constructor(client, player, media){
        //Call derived constructor
        super(client, player, media, 'raw');

        //Define listeners
        this.defineListeners();
    }

    defineListeners(){
        //Run derived method
        super.defineListeners();

        this.video.addEventListener('pause', this.onPause.bind(this));
        this.video.addEventListener('seeked', this.onSeek.bind(this));
        this.video.addEventListener('waiting', this.onBuffer.bind(this));
    }

    start(){
        //Call derived start
        super.start();

        //Set video
        this.video.src = this.nowPlaying.rawLink;

        //Set video volume
        this.video.volume = this.player.volume;

        //Unlock player
        this.setPlayerLock(false);

        //play video
        this.video.play();
    }

    play(){
        this.video.play();
    }

    pause(){
        this.video.pause();
    }

    sync(timestamp = this.lastTimestamp){
        //Call derived sync
        super.sync(timestamp);

        //Skip sync calls that won't seek so we don't pointlessly throw selfAct
        if(timestamp != this.video.currentTime){
            //Set current video time based on timestamp received from server
            this.video.currentTime = timestamp;
        }
    }

    getTimestamp(){
        //Return current timestamp
        return this.video.currentTime;
    }
}

/**
 * Handles Youtube playback via the official YT embed (gross)
 * @extends mediaHandler
 */
class youtubeEmbedHandler extends mediaHandler{
    /**
     * Instantiates a new  Youtube Embed Handler object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player  - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     */
    constructor(client, player, media){
        //Call derived constructor
        super(client, player, media, 'ytEmbed');

        //Set flag to notify functions when the player is actually ready
        this.ready = false;

        //Create property to hold video iframe for easy access
        this.iframe = null;
    }

    //custom start media function since we want the youtube player to call the start function once it's ready
    startMedia(media){
        //If we properly ingested the media
        if(this.ingestMedia(media)){
            //Build the video player
            this.buildPlayer();
        }
    }

    buildPlayer(){
        //If the embed API hasn't loaded
        if(!this.client.ytEmbedAPILoaded){
            //Complain and stop
            return console.warn("youtubeEmbedHandler.buildPlayer() Called before YT Iframe API Loaded, waiting on refresh to rebuild...");
        }

        //Create temp div for yt api to replace
        const tempDiv = document.createElement('div');
        //Name the div
        tempDiv.id = "youtube-embed-player"
        //Append it to the video container
        this.player.videoContainer.appendChild(tempDiv);

        //Create a new youtube player using the official YT iframe-embed api
        this.video = new YT.Player('youtube-embed-player', {
            //Inject video id
            videoId: this.nowPlaying.id,
            events: {
                'onReady': this.start.bind(this),
                'onStateChange': this.onStateChange.bind(this)
            }
        }); 

        //Call derived function
        super.buildPlayer();
    }

    start(){
        //Call derived start function
        super.start();

        //Set volume based on player volume
        this.video.setVolume(this.player.volume * 100);

        //Kick the video off
        this.video.playVideo();

        //Pull iframe
        this.iframe = this.video.getIframe()

        //Throw the ready flag
        this.ready = true;
    }

    destroyPlayer(){
        //If we've had enough time to create a player frame
        if(this.ready){
            //Pull volume from player before destroying since google didn't give us a volume change event like a bunch of dicks
            this.player.volume = (this.video.getVolume() / 100);

            //Use the embed api's built in destroy function
            this.video.destroy();
        }

        //Check the f̶r̶i̶d̶g̶e video container for leftovers
        const leftovers = this.player.videoContainer.querySelector("#youtube-embed-player");

        //If we have any leftovers
        if(leftovers != null){
            //Nukem like last nights chicken
            leftovers.remove();
        }

        //Call derived destroy function
        super.destroyPlayer();
    }

    sync(timestamp = this.lastTimestamp){
        //If we're not ready
        if(!this.ready){
            //Kick off a timer to wait it out and try again l8r
            setTimeout(this.sync.bind(this), 100);

            //If it failed, tell randy to fuck off
            return;
        }

        //Seek to timestamp, allow buffering
        this.video.seekTo(timestamp, true);
    }

    reload(){
        //if we're ready
        if(this.ready){
            //re-load the video by id
            this.video.loadVideoById(this.nowPlaying.id);
        }
    }

    play(){
        //If we're ready
        if(this.ready){
            //play the video
            this.video.playVideo();
        }
    }

    pause(){
        //If we're ready
        if(this.ready){
            //pause the video
            this.video.pauseVideo();
        }
    }

    getRatio(){
        //TODO: Implement a type-specific metadata property object in the media class to hold type-sepecifc meta-data
        //Alternatively we could fill in resolution information from the raw link 
        //However keeping embedded functionality dependant on raw-links seems like bad practice
    }

    getTimestamp(){
        //If we're ready
        if(this.ready){
            //Return the timestamp
            return this.video.getCurrentTime();
        }
        
        //If we fall through, simply report that the video hasn't gone anywhere yet
        return 0;
    }

    setVideoTitle(){
        //Clear out the player title so that youtube's baked in title can do it's thing.
        //This will be replaced once we complete the full player control and remove the defualt youtube UI
        this.player.title.textContent = "";
    }

    /**
     * Generic handler for state changes since google is a dick
     */
    onStateChange(event){
        switch(event.data){
            //video unstarted
            case -1:
                return;
            //video ended
            case 0:
                return;
            //video playing
            case 1:
                return;
            //video paused
            case 2:
                super.onPause(event);
                return;
            //video buffering
            case 3:
                //There is no good way to tell slow connections apart from user seeking
                //This will be easier to implement once we get custom player controls up
                //super.onSeek(event);
                return;
            //video queued
            case 5:
                return;
            //bad status code
            default:
                return;
        }
    }

    setPlayerLock(lock){
        super.setPlayerLock(lock);

        if(this.ready){
            this.iframe.style.pointerEvents = (lock ? "none" : "");
        }
    }
}

/**
 * Base HLS Media handler for handling all HLS related media
 * @extends rawFileBase
 */
class hlsBase extends rawFileBase{
    /**
     * Instantiates a new HLS Base object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     * @param {String} type - Media Handler Source Type
     */
    constructor(client, player, media, type){
        //Call derived constructor
        super(client, player, media, type);
    }

    buildPlayer(){
        //Call derived buildPlayer function
        super.buildPlayer();

        //Instantiate HLS object
        this.hls = new Hls();

        //Load HLS Stream
        this.hls.loadSource(this.nowPlaying.url);

        //Attatch hls object to video element
        this.hls.attachMedia(this.video);

        //Bind onMetadataLoad to MANIFEST_PARSED
        this.hls.on(Hls.Events.MANIFEST_PARSED, this.onMetadataLoad.bind(this));
    }

    end(){
        //Stop hls.js from loading any more of the stream
        this.hls.stopLoad();

        //Call derived method
        super.end();
    }

    onMetadataLoad(){
        //Call derived method
        super.onMetadataLoad();
    }

    start(){
        //Call derived method
        super.start();

        //Start the video
        this.video.play();
    }
}

/**
 * HLS Livestream Handler
 * @extends hlsBase
 */
class hlsLiveStreamHandler extends hlsBase{
    /**
     * Instantiates a new HLS Live Stream Handler object
     * @param {channel} client - Parent Client Management Object
     * @param {player} player - Parent Canopy Player Object
     * @param {Object} media - De-hydrated media object from server
     */
    constructor(client, player, media){
        //Call derived constructor
        super(client, player, media, "livehls");

        //Create variable to determine if we need to resync after next seek
        this.reSync = false;

        this.video.addEventListener('pause', this.onPause.bind(this));
        this.video.addEventListener('seeked', this.onSeek.bind(this));
        this.video.addEventListener('waiting', this.onBuffer.bind(this));
    }

    sync(){
        //Kick the video back on if it was paused
        this.video.play();

        //Pull video duration
        const duration = this.video.duration;

        //Ignore bad timestamps
        if(duration > 0){
            //Seek to the end to sync up w/ the livestream
            this.video.currentTime = duration;
        }
    }

    setVideoTitle(title){
        //Add title as text content for security :P
        this.player.title.textContent = `: ${title}`;

        //Create glow span
        const glowSpan = document.createElement('span');
        //Fill glow span content
        glowSpan.textContent = "🔴LIVE";
        //Set glowspan class
        glowSpan.classList.add('critical-danger-text');

        //Inject glowspan into title in a way that allows it to be easily replaced
        this.player.title.prepend(glowSpan);
    }

    onBuffer(event){
        //Call derived function
        super.onBuffer(event);


        //If we're supposed to be synced by the end of buffering
        if(this.player.syncLock){
            //Throw flag to manually sync since this works entirely differently from literally every other fucking media source
            this.reSync = true;
        }
    }

    onSeek(event){
        //Call derived method
        super.onSeek(event);

        //If we stopped playing the video
        if(this.video == null){
            //Don't worry about it
            return;
        }

        //Calculate distance to end of stream
        const difference = this.video.duration - this.video.currentTime;

        //If we where buffering under sync lock
        if(this.reSync){
            //Set reSync to false
            this.reSync = false;

            //If the difference is bigger than streamSyncTolerance
            if(difference > this.player.streamSyncTolerance){
                //Sync manually since we have no timestamp, and therefore the player won't do it for us
                this.sync();
            }
        }
    }
}