794 lines
21 KiB
JavaScript
794 lines
21 KiB
JavaScript
/*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 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();
|
|
}
|
|
}
|
|
}
|
|
} |