/*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 containing base code for the Canopy channel client.
*/
class channel{
/**
* Instantiates a new channel object
*/
constructor(){
//Establish connetion to the server via socket.io
this.connect();
//Define socket listeners
this.defineListeners();
/**
* Returns true once the ytEmbed API has loaded in from google (eww)
*/
this.ytEmbedAPILoaded = false;
/**
* Current connected channels name
*/
this.channelName = window.location.pathname.split('/c/')[1].split('/')[0];
/**
* Child Video Player object
*/
this.player = new player(this);
/**
* Child Chat Box Object
*/
this.chatBox = new chatBox(this);
/**
* Child User List Object
*/
this.userList = new userList(this);
/**
* Child PM Handler
*/
this.pmHandler = new pmHandler(this);
/**
* Child Canopy Panel Object
*/
this.cPanel = new cPanel(this);
//Set defaults for any unset settings and run any required process steps for the current config
this.setDefaults(false, true);
//Freak out any weirdos who take a peek in the dev console for shits n gigs
console.log("👁️👄👁️ ℬℴ𝓊𝓃𝒿ℴ𝓊𝓇.");
//Preach the good word about software which is Free as in Freedom
console.log(`Did you know Canopy, the software that runs '${utils.ux.getInstanceName()}' is software that is free as in freedom AND free weed?`);
console.log("This means you can read/modify/redistribute the code, run your own server, and even contribute back!");
console.log("https://git.ourfore.st/rainbownapkin/canopy");
}
/**
* Handles initial client connection
*/
connect(){
const clientOptions = {
extraHeaders: {
//Include CSRF token
'x-csrf-token': utils.ajax.getCSRFToken()
}
};
this.socket = io(clientOptions);
this.queueBroadcastSocket = io("/queue-broadcast", clientOptions);
this.pmSocket = io("/pm", clientOptions);
}
/**
* Defines network-related listeners
*/
defineListeners(){
this.socket.on("connect", () => {
document.title = `${this.channelName} - Connected`
});
this.socket.on("kick", async (data) => {
if(data.reason == "Invalid CSRF Token!"){
//Warn the user
new canopyUXUtils.popup('Invalid CSRF Token detected, reloading client...');
//Just reload the fucker
setTimeout(()=>{location.reload();}, 1000);
}else{
new canopyUXUtils.popup(`You have been ${data.type} from the channel for the following reason:
${data.reason}`);
}
});
this.socket.on("clientMetadata", this.handleClientInfo.bind(this));
this.socket.on("error", utils.ux.displayResponseError);
this.queueBroadcastSocket.on("queue", (data) => {
console.log(data);
this.queue = new Map(data.queue);
});
this.socket.on("lock", (data) => {
this.queueLock = data.locked;
});
}
/**
* Handles initial client-metadata ingestion from server upon connection
* @param {Object} data - Data glob from server
*/
handleClientInfo(data){
//Ingest user data
this.user = data.user;
//Re-hydrate permission maps
this.user.permMap.site = new Map(data.user.permMap.site);
this.user.permMap.chan = new Map(data.user.permMap.chan);
//Tell the chatbox to handle client info
//should it have its own event listener instead? Guess it's a stylistic choice :P
this.chatBox.handleClientInfo(data);
//Store queue lock status
this.queueLock = data.queueLock;
}
/**
* Processes and applies default config on any unset settings
* @param {Boolean} force - Whether or not to forcefully reset already set settings
* @param {Boolean} processConfig - Whether or not to run the Process Config function once complete
*/
setDefaults(force = false, processConfig = false){
//Iterate through default config
for(let [key, value] of channel.defaultConfig){
//If the setting is unset or function was called forcefully
if(force || localStorage.getItem(key) == null){
//Set item from default map
localStorage.setItem(key, value);
}
//If we're running process steps for the config
if(processConfig){
//Process the current config value
this.processConfig(key, localStorage.getItem(key));
}
}
}
/**
* Run once every config change to ensure settings are properly set
* @param {String} key - Setting to change
* @param {*} value - Value to set setting to
*/
processConfig(key, value){
//Unfortunately we can't scope constants to switch-cases so this is the best we got if we wanna re-use the name
let nowPlaying;
//Switch/case by config key
switch(key){
case 'ytPlayerType':
const embedScript = document.querySelector(".yt-embed-api");
//If the user is running the embedded player and we don't have en embed script loaded
if(value == 'embed' && embedScript == null){
//Find our footer
const footer = document.querySelector('footer');
//Create new script tag
const ytEmbedAPI = document.createElement('script');
//Link the iframe api from youtube
ytEmbedAPI.src = "https://www.youtube.com/iframe_api";
//set the iframe api script id
ytEmbedAPI.classList.add('yt-embed-api');
//Append the script tag to the top of the footer to give everything else access
footer.prepend(ytEmbedAPI);
//If we're not using the embed player but the script is loaded
}else if(embedScript != null){
//Pull all scripts since the main one might have pulled others
const scripts = document.querySelectorAll('script');
//Iterate through all script tags on the page
for(let script of scripts){
//If the script came from youtube
if(script.src.match(/youtube\.com|youtu\.be/)){
//Rip it out
script.remove();
}
}
}
//If the player or mediaHandler isn't loaded
if(this.player == null || this.player.mediaHandler == null){
//We're fuggin done here
return;
}
//Get current video
nowPlaying = this.player.mediaHandler.nowPlaying;
//If we're playing a youtube video
if(nowPlaying != null && nowPlaying.type == 'yt'){
//Restart the video
this.player.hardReload();
}
//Stop while we're ahead
return;
case 'IACDN':
//If the player or mediaHandler isn't loaded
if(this.player == null || this.player.mediaHandler == null){
//We're fuggin done here
return;
}
//Get current video
nowPlaying = this.player.mediaHandler.nowPlaying;
//If we're playing a video from Internet Archive
if(nowPlaying != null && nowPlaying.type == 'ia'){
//Hard reload the media, forcing media handler re-creation
this.player.hardReload();
}
return;
case 'syncTolerance':
//If the player isn't loaded
if(this.player == null){
//We're fuckin' done here
return;
}
//Set syncronization tolerance
this.player.syncTolerance = value;
return;
case 'liveSyncTolerance':
//If the player isn't loaded
if(this.player == null){
//We're fuckin' done here
return;
}
//Set syncronization tolerance
this.player.streamSyncTolerance = value;
return;
case 'syncDelta':
//If the player isn't loaded
if(this.player == null){
//We're fuckin' done here
return;
}
//Set syncronization delta
this.player.syncDelta = value;
return;
case 'chatWidthMin':
//If the chat isn't loaded
if(this.chatBox == null){
//We're fuckin' done here
return;
}
//Set Chat Box Width minimum while Locked to Aspect-Ratio
this.chatBox.chatWidthMinimum = value / 100;
return;
case 'userlistHidden':
//If the userlist class isn't loaded in yet
if(this.userList == null){
//We're fuckin' done here
return;
}
//Pass value down to UI toggle, making sure to allow for string conversion
this.userList.toggleUI(!(value == true || value == "true"));
return;
case 'cinemaMode':
//If the userlist class isn't loaded in yet
if(this.player == null){
//We're fuckin' done here
return;
}
//Pass value down to UI toggle, making sure to allow for string conversion
this.player.toggleCinemaMode(value == true || value == "true");
return;
}
}
/**
* Default channel config
*/
static defaultConfig = new Map([
["ytPlayerType","raw"],
["IACDN",""],
["syncTolerance",0.4],
["liveSyncTolerance", 2],
["syncDelta", 6],
["chatWidthMin", 20],
["userlistHidden", false],
["cinemaMode", false],
["rxPMSound", 'unread'],
["txPMSound", false],
["newSeshSound", true],
["endSeshSound", true]
]);
}
/**
* Youtube iframe-embed API entry point
*/
function onYouTubeIframeAPIReady(){
//Set embed api to true
client.ytEmbedAPILoaded = true;
//Get currently playing item
const nowPlaying = client.player.mediaHandler.nowPlaying;
//If we're playing a youtube video and the official embeds are enabled
if(nowPlaying.type == 'yt' && localStorage.getItem('ytPlayerType') == "embed"){
//Restart the video now that the embed api has loaded
client.player.start({media: nowPlaying});
}
}
const client = new channel();