canopy/www/js/channel/mediaHandler.js

636 lines
17 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/>.*/
//This is little more than a interface class
class mediaHandler{
constructor(client, player, media, type){
//Get parents
this.client = client;
this.player = player;
//Set handler type
this.type = type
//Denotes wether a seek call was made by the syncing function
this.selfAct = false;
//Set last received timestamp to 0
this.lastTimestamp = 0;
//Ingest media object from server
this.startMedia(media);
}
startMedia(media){
//If we properly ingested the media
if(this.ingestMedia(media)){
//Build the video player
this.buildPlayer();
//Call the start function
this.start();
}
}
buildPlayer(){
//Reset player lock
this.lock = false;
}
destroyPlayer(){
//Null out video property
this.video = null;
}
ingestMedia(media){
//Set now playing
this.nowPlaying = media;
//return true to signify success
return true;
}
start(){
this.setVideoTitle(this.nowPlaying.title);
}
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;
}
}
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;
}
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();
}
play(){
}
pause(){
}
setPlayerLock(lock){
//set lock property
this.lock = lock;
}
getRatio(){
}
getTimestamp(){
}
setVideoTitle(title){
this.player.title.textContent = `Currently Playing: ${title}`;
}
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();
}
onPause(event){
//If the video was paused out-side of code
if(!this.selfAct){
this.player.unlockSync();
}
this.selfAct = false;
}
onVolumeChange(event){
}
onSeek(event){
//If the video was seeked out-side of code
if(!this.selfAct){
this.player.unlockSync();
}
//reset self act flag
this.selfAct = false;
}
onBuffer(){
this.selfAct = true;
}
}
//Basic building blocks for anything that touches a <video> tag
class rawFileBase extends mediaHandler{
constructor(client, player, media, type){
super(client, player, media, type);
}
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'
class nullHandler extends rawFileBase{
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
class rawFileHandler extends rawFileBase{
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;
}
}
class youtubeEmbedHandler extends mediaHandler{
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" : "");
}
}
}
class hlsBase extends rawFileBase{
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();
}
}
class hlsLiveStreamHandler extends hlsBase{
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();
}
}
}
}