/*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 .*/ /** * 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 = localStorage.getItem('syncTolerance'); /** * 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 = localStorage.getItem('liveSyncTolerance'); /** * Forced time to wait between sync checks, heavily decreases chance of seek-banging without reducing syncornization accuracy */ this.syncDelta = localStorage.getItem('syncDelta'); /** * 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'){ //If we're running a source from IA if(data.media.type == 'ia'){ //If we have an IA source and a custom IA CDN Server set if(data.media.type == 'ia' && localStorage.getItem("IACDN") != ""){ for(const linkIndex in data.media.rawLink.combo){ //Pull link to sprinkle on dat syntatic sugar (tasty) let link = data.media.rawLink.combo[linkIndex][1] //Replace specified CDN with generic URL, in-case of hard reload link = link.replace(/^https(.*)archive\.org(.*)items/g, "https://archive.org/download"); //Generate and set new link data.media.rawLink.combo[linkIndex][1] = link.replace("https://archive.org/download", `https://${localStorage.getItem("IACDN")}.archive.org/0/items`); } } } //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(); } } /** * Destroys and Re-Creates media handler */ hardReload(){ if(this.mediaHandler != null){ //Re-create data we'd get from server const data = { media: this.mediaHandler.nowPlaying, timestamp: this.mediaHandler.getTimestamp() } //End current media handler this.end(); console.log(data); //Restart from last media handlers this.start(data); } } /** * 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()){ localStorage.setItem("cinemaMode", cinema); if(cinema){ this.navBar.style.display = "none"; }else{ this.navBar.style.display = "flex"; } //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(); } } }