Source: player.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/>.*/

/**
 * Class which represents Canopy Player UX
 */
class player{
    /**
     * Instantiates a new Canopy Player object
     * @param {channel} client - Parent client Management Object
     */
    constructor (client){
        /**
         * Parent CLient Management Object
         */
        this.client = client;

        /**
         * Whether or not the mouse cursor is floating over player UX
         */
        this.onUI = false;

        /**
         * Whether or not player scrub is locked to sync signal from the server
         */
        this.syncLock = true;
        
        /**
         * Player UX Stow-Away timer
         */
        this.uiTimer = setTimeout(this.toggleUI.bind(this), 1500, false);

        //elements
        /**
         * Top-Level Player Container Div
         */
        this.playerDiv = document.querySelector("#media-panel-div");

        /**
         * Player Element Container Div
         */
        this.videoContainer = document.querySelector("#media-panel-video-container")

        /**
         * Page Nav-Par
         */
        this.navBar = document.querySelector("#navbar");

        /**
         * Auto-Hiding Player UI
         */
        this.uiBar = document.querySelector("#media-panel-head-div");

        /**
         * Player Title Label
         */
        this.title = document.querySelector("#media-panel-title-paragraph");

        /**
         * Player Show Video Icon
         */
        this.showVideoIcon = document.querySelector("#chat-panel-show-video-icon");

        /**
         * Player Hide Video Icon
         */
        this.hideVideoIcon = document.querySelector("#media-panel-div-toggle-icon");

        /**
         * Player Syncronization Icon
         */
        this.syncIcon = document.querySelector("#media-panel-sync-icon");

        /**
         * Player Cinema-Mode Icon
         */
        this.cinemaModeIcon = document.querySelector("#media-panel-cinema-mode-icon");

        /**
         * Player Filp Video Y Icon
         */
        this.flipYIcon = document.querySelector("#media-panel-flip-vertical-icon")

        /**
         * Player Flip Video X Icon
         */
        this.flipXIcon = document.querySelector("#media-panel-flip-horizontal-icon")

        /**
         * Player Media Reload Icon
         */
        this.reloadIcon = document.querySelector("#media-panel-reload-icon");

        /**
         * Tolerance between timestamp from server and actual media before corrective seek for pre-recorded media
         */
        this.syncTolerance = 0.4;

        /**
         * Tolerance in livestream delay before corrective seek to live.
         * 
         * Might seem weird to keep this here instead of the HLS handler, but remember we may want to support other livestream services in the future...
         */
        this.streamSyncTolerance = 2;

        /**
         * Forced time to wait between sync checks, heavily decreases chance of seek-banging without reducing syncornization accuracy
         */
        this.syncDelta = 6;

        /**
         * Current Player Volume
         */
        this.volume = 1;

        //run setup functions
        this.setupInput();
        this.defineListeners();
    }

    /**
     * Defines Input-Related Event Listeners for the player
     */
    setupInput(){
        //UIBar Movement Detection
        this.playerDiv.addEventListener("mousemove", this.popUI.bind(this));
        this.uiBar.addEventListener("mouseenter", ()=>{this.setOnUI(true)});
        this.uiBar.addEventListener("mouseleave", ()=>{this.setOnUI(false)});

        //UIBar/header icons
        //Don't bind these, they want an argument that isn't an event :P
        this.showVideoIcon.addEventListener("click", ()=>{this.toggleVideo()});
        this.hideVideoIcon.addEventListener("click", ()=>{this.toggleVideo()});
        this.syncIcon.addEventListener("click", this.lockSync.bind(this));
        this.cinemaModeIcon.addEventListener("click", ()=>{this.toggleCinemaMode()});
        this.flipYIcon.addEventListener('click', this.flipY.bind(this));
        this.flipXIcon.addEventListener('click', this.flipX.bind(this));
        this.reloadIcon.addEventListener("click", this.reload.bind(this));
    }

    /**
     * Define Network-Related Event Listeners for the player
     */
    defineListeners(){
        this.client.socket.on("start", this.start.bind(this));
        this.client.socket.on("sync", this.sync.bind(this));
        this.client.socket.on("end", this.end.bind(this));
        this.client.socket.on("updateCurrentRawFile", this.updateCurrentRawFile.bind(this));
    }

    /**
     * Handles command from server to start media
     * @param {Object} data - Media Metadata from server
     */
    start(data){
        //If we have an active media handler
        if(this.mediaHandler != null){
            //End the media handler
            this.mediaHandler.end();
        }

        //Ignore null media
        if(data.media == null){
            //Set null handler
            this.mediaHandler = new nullHandler(client, this);
        //Otherwise
        }else{
            //If we have a youtube video and the official embedded iframe player is selected
            if(data.media.type == 'yt' && localStorage.getItem("ytPlayerType") == 'embed'){
                //Create a new yt handler for it
                this.mediaHandler = new youtubeEmbedHandler(this.client, this, data.media);
                //Sync to time stamp
                this.mediaHandler.sync(data.timestamp);
            //If we have an HLS Livestream
            }else if(data.media.type == "livehls"){
                //Create a new HLS Livestream Handler for it
                this.mediaHandler = new hlsLiveStreamHandler(this.client, this, data.media);
            }else if(data.media.type == 'dm'){
                this.mediaHandler = new hlsDailymotionHandler(this.client, this, data.media);
            //Otherwise, if we have a raw-file compatible source
            }else if(data.media.type == 'ia' || data.media.type == 'raw' || data.media.type == 'yt' || data.media.type == 'dm'){
                //Create a new raw file handler for it
                this.mediaHandler = new rawFileHandler(client, this, data.media);
                //Sync to time stamp
                this.mediaHandler.sync(data.timestamp);   
            }else{
                this.mediaHandler = new nullHandler(client, this);
            }
        }

        //Lock synchronization since everyone starts at 0, and update the UI
        this.lockSync();

        //Re-size to aspect since video may now be a different size
        this.client.chatBox.resizeAspect();

        //Sync off of starter time stamp
        this.mediaHandler.sync(data.timestamp);
    }

    /**
     * Handles synchronization command from server
     * @param {Object} data - Syncrhonization Data from Server
     */
    sync(data){
        if(this.mediaHandler != null){
            //Get timestamp
            const timestamp = data.timestamp;
            //Get difference between server and local timestamp
            const difference = Math.abs(timestamp - this.mediaHandler.getTimestamp());

            //Check if timestamp evenly devides into sync delta, effectively only checking for sync every X seconds
            //Check if the difference between timestamps is larger than the sync tolerance
            //Lastly, check to make sure we have sync lock
            if(timestamp % this.syncDelta == 0 && difference > this.syncTolerance && this.syncLock){
                //If we need to sync, then sync the video!
                this.mediaHandler.sync(timestamp);
            }

            //Collect last timestamp
            this.mediaHandler.lastTimestamp = timestamp;
        }
    }

    /**
     * Reloads the media player
     */
    reload(){
        if(this.mediaHandler != null){
            this.mediaHandler.reload();
        }
    }

    /**
     * Handles End-Media Commands from the Server
     */
    end(){
        //Call the media handler finisher
        this.mediaHandler.end();

        //Replace it with a null handler
        this.mediaHandler = new nullHandler(client, this);

        //Re-lock sync since we're probably gonna start new media soon anywho, and we need to update the UI anywho
        this.lockSync();
    }

    /**
     * Handles Raw-File Metadata Updates from the Server
     * @param {Object} data - Updadated Raw-File link from Server
     */
    updateCurrentRawFile(data){
        //typecheck the media handler to see if we really need to do any of this shit, if not...
        if(this.mediaHandler.type == 'ytEmbed'){
            //Ignore it
            return;
        }

        //Grab current item from media handler
        const currentItem = this.mediaHandler.nowPlaying;

        //Update raw link
        currentItem.rawLink = data.file;

        //Re-start the item
        this.start({media: currentItem});
    }

    /**
     * Locks player seek to synced timestamp from the server
     */
    lockSync(){
        //Enable syncing
        this.syncLock = true;

        if(this.mediaHandler != null && this.mediaHandler.type != null){
            //Light up the sync icon to show that we're actively synchronized
            this.syncIcon.classList.add('positive');

            //Sync to last timestamp
            this.mediaHandler.sync();

            //Play
            this.mediaHandler.play();
        }else{
            //Unlight the sync icon since there is nothing to sync
            this.syncIcon.classList.remove('positive');
        }
    }

    /**
     * Un-locks player seek to synced timestamp from the server
     */
    unlockSync(){
        //Unlight the sync icon since we're no longer actively synced
        this.syncIcon.classList.remove('positive');

        //Disable syncing
        this.syncLock = false;
    }

    /**
     * Flips the video horizontally
     */
    flipX(){
        //I'm lazy
        const transform = this.videoContainer.style.transform;

        //If we we're specifically set to un-mirrored
        if(transform.match("scaleX(1)")){
            //mirror it
            this.videoContainer.style.transfrom = transform.replace('scaleX(1)', 'scaleX(-1)');
        //If we're currently mirrored
        }else if(transform.match("scaleX(-1)")){
            //Un-mirror
            this.videoContainer.style.transfrom = transform.replace('scaleX(-1)', 'scaleX(1)');
        //Otherwise, if it's untouched
        }else{
            //Mirror it
            this.videoContainer.style.transform += 'scaleX(-1)';
        }
    }

    /**
     * Flips the video vertically
     */
    flipY(){
        //I'm lazy
        const transform = this.videoContainer.style.transform;

        //If we we're specifically set to un-mirrored
        if(transform.match("scaleY(1)")){
            //mirror it
            this.videoContainer.style.transfrom = transform.replace('scaleY(1)', 'scaleY(-1)');
        //If we're currently mirrored
        }else if(transform.match("scaleY(-1)")){
            //Un-mirror
            this.videoContainer.style.transfrom = transform.replace('scaleY(-1)', 'scaleY(1)');
        //Otherwise, if it's untouched
        }else{
            //Mirror it
            this.videoContainer.style.transform += 'scaleY(-1)';
        }
    }

    /**
     * Displays UI after player-related input
     * @param {Event} event - Event passed through by event handler
     */
    popUI(event){
        this.toggleUI(true);
        clearTimeout(this.uiTimer);
        if(!this.onUI){
            this.uiTimer = setTimeout(this.toggleUI.bind(this), 1500, false);
        }
    }

    /**
     * Toggles UI-Bar on or off
     * @param {Boolean} show - Whether or not to show the UI-Bar. Defaults to toggle if left unspecified.
     */
    toggleUI(show = this.uiBar.style.display == "none"){
        this.uiBar.style.display = show ? "flex" : "none";
    }

    /**
     * Toggles video on or off
     * @param {Boolean} show - Whether or not to show the video player. Defaults to toggle if left unspecified
     */
    toggleVideo(show = !this.playerDiv.checkVisibility()){
        if(show){
            this.playerDiv.style.display = "flex";
            this.showVideoIcon.style.display = "none";
        }else{
            this.playerDiv.style.display = "none";
            this.showVideoIcon.style.display = "flex";
        }

        //Tell chatbox to handle this shit
        this.client.chatBox.handleVideoToggle(show);
    }

    /**
     * Toggles Cinema Mode on or off
     * @param {Boolean} cinema - Whether or not to enter Cinema Mode. Defaults to toggle if left unspecified
     */
    toggleCinemaMode(cinema = !this.navBar.checkVisibility()){
        if(cinema){
            this.navBar.style.display = "flex";
        }else{
            this.navBar.style.display = "none";
        }

        //Resize the video if we're aspect locked
        this.client.chatBox.resizeAspect();
    }

    /**
     * Informs the class when the user's mouse curosr enters and leaves the UI area
     * @param {Boolean} onUI - Whether or not onUI should be toggled true
     */
    setOnUI(onUI){
        this.onUI = onUI;
        this.popUI();
    }

    /**
     * Calculates ratio of current media object
     * @returns {Number} Current media aspect ratio as a single floating point number
     */
    getRatio(){
        //If we have no media handler
        if(this.mediaHandler == null){
            //Return a 4/3 aspect to get a decent chat size
            return 4/3;
        }else{
            return this.mediaHandler.getRatio();
        }
    }
}