/*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 playlistManager{ constructor(client, panelDocument, queuePanel){ //Set client this.client = client; //Set panel document this.panelDocument = panelDocument; //Set parent queue panel this.queuePanel = queuePanel; //Create openMap this.openMap = { Channel: new Map(), User: new Map() }; //Define Listeners this.defineListeners(); } defineListeners(){ this.client.socket.on("chanPlaylists", this.renderChannelPlaylists.bind(this)); this.client.socket.on("userPlaylists", this.renderUserPlaylists.bind(this)); } docSwitch(){ //Grab menus this.channelPlaylistDiv = this.panelDocument.querySelector("#queue-channel-playlist-div"); this.userPlaylistDiv = this.panelDocument.querySelector("#queue-user-playlist-div"); //Grab controls this.createPlaylistSpan = this.panelDocument.querySelector('#queue-add-playlist-span'); this.channelPlaylistLabel = this.panelDocument.querySelector('#queue-channel-playlist-span'); this.channelPlaylistCaret = this.panelDocument.querySelector('#queue-channel-playlist-toggle'); this.userPlaylistLabel = this.panelDocument.querySelector('#queue-user-playlist-span'); this.userPlaylistCaret = this.panelDocument.querySelector('#queue-user-playlist-toggle'); //Force playlist re-render to fix controls this.client.socket.emit('getChannelPlaylists'); this.client.socket.emit('getUserPlaylists'); //Setup Input this.setupInput(); } setupInput(){ this.createPlaylistSpan.addEventListener('click', (event)=>{new newPlaylistPopup(event, this.client, this.queuePanel.ownerDoc)}) this.channelPlaylistLabel.addEventListener('click', this.toggleChannelPlaylists.bind(this)); this.userPlaylistLabel.addEventListener('click', this.toggleUserPlaylists.bind(this)); } /* queue control button functions */ toggleChannelPlaylists(event){ //If the div is hidden if(this.channelPlaylistDiv.style.display == 'none'){ //Light up the button this.channelPlaylistLabel.classList.add('positive'); //Flip the caret this.channelPlaylistCaret.classList.replace('bi-caret-right-fill', 'bi-caret-down-fill'); //Show the div this.channelPlaylistDiv.style.display = ''; }else{ //Unlight the button this.channelPlaylistLabel.classList.remove('positive'); //Flip the caret this.channelPlaylistCaret.classList.replace('bi-caret-down-fill', 'bi-caret-right-fill'); //Hide the div this.channelPlaylistDiv.style.display = 'none'; } } /* queue control button functions */ toggleUserPlaylists(event){ //If the div is hidden if(this.userPlaylistDiv.style.display == 'none'){ //Light up the button this.userPlaylistLabel.classList.add('positive'); //Flip the caret this.userPlaylistCaret.classList.replace('bi-caret-right-fill', 'bi-caret-down-fill'); //Show the div this.userPlaylistDiv.style.display = ''; }else{ //Unlight the button this.userPlaylistLabel.classList.remove('positive'); //Flip the caret this.userPlaylistCaret.classList.replace('bi-caret-down-fill', 'bi-caret-right-fill'); //Hide the div this.userPlaylistDiv.style.display = 'none'; } } checkOpenPlaylists(location){ //If open map is a string, indicating we just renamed a playlist with it's media open if(typeof this.openMap[location] == 'string'){ //Create new map to hold status with the new name of the renamed playlist already added this.openMap[location] = new Map([[this.openMap[location], true]]); }else{ //Create new map to hold status this.openMap[location] = new Map(); } let mediaContainerDivs = []; if(location == 'Channel'){ mediaContainerDivs = this.channelPlaylistDiv.querySelectorAll('.queue-playlist-media-container-div') }else{ mediaContainerDivs = this.userPlaylistDiv.querySelectorAll('.queue-playlist-media-container-div') } //For each container Div rendered for(let containerDiv of mediaContainerDivs){ //Set whether or not it's visible in the map this.openMap[location].set(containerDiv.dataset['playlist'], (containerDiv.style.display != 'none')); } } //Main playlist rendering functions renderChannelPlaylists(data){ //Check for open playlists this.checkOpenPlaylists('Channel'); console.log(this.openMap); //Clear channel playlist div this.channelPlaylistDiv.innerHTML = ''; //Append rendered playlists this.channelPlaylistDiv.append(...this.renderPlaylists(data, 'Channel')); } renderUserPlaylists(data){ //Check for open playlists this.checkOpenPlaylists('User'); //Clear channel playlist div this.userPlaylistDiv.innerHTML = ''; //Append rendered playlists this.userPlaylistDiv.append(...this.renderPlaylists(data, 'User')); } renderPlaylists(data, location){ const playlists = []; //For every playlist sent down from the server for(let playlistIndex in data){ //Get playlist from data const playlist = data[playlistIndex]; //Create a new playlist div const playlistDiv = document.createElement('div'); //Set it's class playlistDiv.classList.add('queue-playlist-div'); //Create span to hold playlist entry line contents const playlistSpan = document.createElement('span'); //Set classes playlistSpan.classList.add('queue-playlist-span'); //If this isn't our first rodeo if(playlistIndex != 0){ //make note playlistSpan.classList.add('not-first'); } //assemble playlist entry line playlistSpan.append( this.renderLabels(playlist, location), this.renderControls(playlist, location) ); //assemble playlist div playlistDiv.append( playlistSpan, this.renderMedia(playlist, location), ); //add playlist div to playlists array playlists.push(playlistDiv); } return playlists; } //aux rendering functions renderLabels(playlist, location){ //Create playlist label span const playlistLabels = document.createElement('span'); //Set it's class playlistLabels.classList.add('queue-playlist-labels-span'); //create playlist title span const playlistTitleSpan = document.createElement('span'); //Set class playlistTitleSpan.classList.add('queue-playlist-title-span', 'interactive'); //Create playlist title caret const playlistTitleCaret = document.createElement('i'); //If this is supposed to be open if(this.openMap[location].get(playlist.name)){ //Set class accordingly playlistTitleSpan.classList.add('positive'); playlistTitleCaret.classList.add('bi-caret-down-fill'); //otherwise }else{ //Set class accordingly playlistTitleCaret.classList.add('bi-caret-right-fill'); } //Create playlist title label const playlistTitle = document.createElement('p'); //Set it's class playlistTitle.classList.add('queue-playlist-title'); //Unescape Sanatized Enteties and safely inject as plaintext playlistTitle.innerText = utils.unescapeEntities(playlist.name); //Construct playlist title span playlistTitleSpan.appendChild(playlistTitleCaret); playlistTitleSpan.appendChild(playlistTitle); //Create playlist count label const playlistCount = document.createElement('p'); //Set it's class playlistCount.classList.add('queue-playlist-count'); //List video count playlistCount.innerText = `Count: ${playlist.media.length}`; //Append items to playlist labels span playlistLabels.appendChild(playlistTitleSpan); playlistLabels.appendChild(playlistCount); //Define input listeners playlistTitleSpan.addEventListener('click', this.toggleMedia.bind(this)); return playlistLabels; } renderControls(playlist, location){ //Create playlist control span const playlistControls = document.createElement('span'); //Set it's class playlistControls.classList.add('queue-playlist-control-span'); //Set dataset playlistControls.dataset['playlist'] = playlist.name; playlistControls.dataset['location'] = location; //Create queue all button const playlistQueueRandomButton = document.createElement('button'); //Set it's classes playlistQueueRandomButton.classList.add('queue-playlist-queue-random-button', 'queue-playlist-control'); //Inject text content playlistQueueRandomButton.textContent = 'Random'; //Set title playlistQueueRandomButton.title = 'Queue Random Item from Playlist'; //Create queue all button const playlistQueueAllButton = document.createElement('button'); //Set it's classes playlistQueueAllButton.classList.add('queue-playlist-queue-all-button', 'queue-playlist-control', 'not-first'); //Inject text content playlistQueueAllButton.textContent = 'All'; //Set title playlistQueueAllButton.title = 'Queue Entire Playlist'; //Create add from URL button const playlistAddURLButton = document.createElement('button'); //Set it's classes playlistAddURLButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'positive-button', 'not-first'); //Set Tile playlistAddURLButton.title = 'Add To Playlist From URL' //Create playlist icons (we're using two so we're putting them inside the button :P) const playlistAddIcon = document.createElement('i'); const playlistLinkIcon = document.createElement('i'); //set classes playlistAddIcon.classList.add('bi-plus-lg'); playlistLinkIcon.classList.add('bi-link-45deg'); //Append icons to URL button playlistAddURLButton.appendChild(playlistAddIcon); playlistAddURLButton.appendChild(playlistLinkIcon); //Create default titles button const playlistDefaultTitlesButton = document.createElement('button'); //Set classes playlistDefaultTitlesButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'bi-tags-fill', 'positive-button', 'not-first'); //Set title playlistDefaultTitlesButton.title = 'Change Default Titles' //Set dataset playlistDefaultTitlesButton.dataset['titles'] = JSON.stringify(playlist.defaultTitles); //Create rename button const playlistRenameButton = document.createElement('button'); //Set it's classes playlistRenameButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'bi-input-cursor-text', 'positive-button', 'not-first'); //Set title playlistRenameButton.title = 'Rename Playlist' //Create delete button const playlistDeleteButton = document.createElement('button'); //Set it's classes playlistDeleteButton.classList.add('queue-playlist-delete-button', 'queue-playlist-control', 'danger-button', 'bi-trash-fill', 'not-first'); //Set title playlistDeleteButton.title = 'Delete Playlist' //Append items to playlist control span playlistControls.append( playlistQueueRandomButton, playlistQueueAllButton, playlistAddURLButton, playlistDefaultTitlesButton, playlistRenameButton, playlistDeleteButton ); //Define input event listeners playlistAddURLButton.addEventListener('click', this.addURL.bind(this)); playlistDefaultTitlesButton.addEventListener('click', this.editDefaultTitles.bind(this)); playlistRenameButton.addEventListener('click', this.renamePlaylist.bind(this)); playlistQueueRandomButton.addEventListener('click', this.queueRandom.bind(this)); playlistQueueAllButton.addEventListener('click', this.queueAll.bind(this)); playlistDeleteButton.addEventListener('click', this.deletePlaylist.bind(this)); return playlistControls; } renderMedia(playlist, location){ //Create media container div const mediaContainer = document.createElement('div'); //Set classes mediaContainer.classList.add('queue-playlist-media-container-div'); //If the playlist wasn't set to open in the open map if(!this.openMap[location].get(playlist.name)){ //Auto-hide media container mediaContainer.style.display = 'none'; } //Set dataset mediaContainer.dataset['playlist'] = playlist.name; for(let mediaIndex in playlist.media){ //Grab media object from playlist const media = playlist.media[mediaIndex]; //Sanatize title text const title = utils.unescapeEntities(media.title); //Create media div const mediaDiv = document.createElement('div'); //Set class mediaDiv.classList.add('queue-playlist-media-div'); //Inject title mediaDiv.title = title; //If this isn't our first rodeo if(mediaIndex != 0){ mediaDiv.classList.add('not-first'); } //Create media title const mediaTitle = document.createElement('p'); //Set class mediaTitle.classList.add('queue-playlist-media-title'); //Inject text content mediaTitle.innerText = title; //Append items to media div mediaDiv.append( mediaTitle, this.renderMediaControls(media, playlist, location) ); //Append media div to media container mediaContainer.appendChild(mediaDiv); } //return media container return mediaContainer; } renderMediaControls(media, playlist, location){ //Create media control span const mediaControlSpan = document.createElement('span'); //Set it's class mediaControlSpan.classList.add('queue-playlist-media-control-span'); //Set dataset mediaControlSpan.dataset['playlist'] = playlist.name; mediaControlSpan.dataset['uuid'] = media.uuid; mediaControlSpan.dataset['location'] = location; //Create Queue Media icon const queueMediaIcon = document.createElement('i'); //set class queueMediaIcon.classList.add('queue-playlist-control', 'queue-playlist-media-queue-icon', 'bi-play-circle'); //Set title queueMediaIcon.title = (`Queue '${media.title}'`); //Create delete media icon const deleteMediaIcon = document.createElement('i'); //set class deleteMediaIcon.classList.add('queue-playlist-control', 'queue-playlist-media-delete-icon', 'danger-text', 'bi-trash-fill'); //Set title deleteMediaIcon.title = `Delete '${media.title}' from playlist '${playlist.name}'`; //Append items to media control span mediaControlSpan.appendChild(queueMediaIcon); mediaControlSpan.appendChild(deleteMediaIcon); //Handle input event listeners queueMediaIcon.addEventListener('click', this.queueMedia.bind(this)); deleteMediaIcon.addEventListener('click', this.deleteMedia.bind(this)); //Return media control span return mediaControlSpan; } toggleMedia(event){ //Grab playlist title caret const playlistTitleCaret = event.target.querySelector('i'); //I hope my mother doesn't see this next line, god I hate dot crawling... const mediaContainer = event.target.parentNode.parentNode.nextElementSibling; //If the div is hidden if(mediaContainer.style.display == 'none'){ //Light up the button event.target.classList.add('positive'); //Flip the caret playlistTitleCaret.classList.replace('bi-caret-right-fill', 'bi-caret-down-fill'); //Show the div mediaContainer.style.display = ''; }else{ //Unlight the button event.target.classList.remove('positive'); //Flip the caret playlistTitleCaret.classList.replace('bi-caret-down-fill', 'bi-caret-right-fill'); //Hide the div mediaContainer.style.display = 'none'; } } addURL(event){ console.log(event.target.parentNode.dataset['location']); new addURLPopup( event, event.target.parentNode.dataset['playlist'], event.target.parentNode.dataset['location'], this.client, this.queuePanel.ownerDoc ); } //playlist control functions editDefaultTitles(event){ new defaultTitlesPopup( event, event.target.parentNode.dataset['playlist'], JSON.parse(event.target.dataset['titles']), event.target.parentNode.dataset['location'], this.client, this.queuePanel.ownerDoc ); } renamePlaylist(event){ new renamePopup( event, event.target.parentNode.dataset['playlist'], this.client, this.queuePanel.ownerDoc, handleOpenedMedia.bind(this) ); function handleOpenedMedia(newName){ //do an ugly dot crawl to get the media container div const mediaContainer = event.target.parentNode.parentNode.nextElementSibling; //If the media container is visible if(mediaContainer.style.display != 'none'){ //Set openMap to new name indicating the new playlist has it's media opened this.openMap[event.target.parentNode.dataset['location']] = newName; } } } queueAll(event){ this.client.socket.emit(`queue${event.target.parentNode.dataset['location']}Playlist`, {playlist: event.target.parentNode.dataset['playlist']}); } queueMedia(event){ this.client.socket.emit(`queueFrom${event.target.parentNode.dataset['location']}Playlist`,{playlist: event.target.parentNode.dataset['playlist'], uuid: event.target.parentNode.dataset['uuid']}); } queueRandom(event){ this.client.socket.emit(`queueRandomFrom${event.target.parentNode.dataset['location']}Playlist`,{playlist: event.target.parentNode.dataset['playlist']}); } deletePlaylist(event){ this.client.socket.emit(`delete${event.target.parentNode.dataset['location']}Playlist`, {playlist: event.target.parentNode.dataset['playlist']}); } deleteMedia(event ){ this.client.socket.emit(`delete${event.target.parentNode.dataset['location']}PlaylistMedia`, {playlist: event.target.parentNode.dataset['playlist'], uuid: event.target.parentNode.dataset['uuid']}); } } class newPlaylistPopup{ constructor(event, client, doc){ //Set Client this.client = client; //Create media popup and call async constructor when done //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( this.popup = new canopyUXUtils.popup('/newPlaylist', true, this.asyncConstructor.bind(this), doc, false); } asyncConstructor(){ this.name = this.popup.contentDiv.querySelector('#queue-create-playlist-popup-name'); this.defaultTitles = this.popup.contentDiv.querySelector('#queue-create-playlist-popup-default-titles'); this.location = this.popup.contentDiv.querySelector('#queue-create-playlist-popup-location'); this.saveButton = this.popup.contentDiv.querySelector('#queue-create-playlist-popup-save'); this.setupInput(); } setupInput(){ //Setup input this.saveButton.addEventListener('click', this.createPlaylist.bind(this)); this.popup.popupDiv.addEventListener('keydown', this.createPlaylist.bind(this)); } createPlaylist(event){ //If we clicked or hit enter if(event.key == null || (event.key == "Enter" && this.defaultTitles !== this.popup.doc.activeElement)){ //Tell the server to create a new playlist this.client.socket.emit(`create${this.location.value}Playlist`, { playlist: this.name.value, defaultTitles: this.defaultTitles.value.split('\n') }); //Close the popup this.popup.closePopup(); } } } class addURLPopup{ constructor(event, playlist, location, client, doc){ //Set Client this.client = client; //Set playlist this.playlist = playlist //Set location this.location = location; //Create media popup and call async constructor when done //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( this.popup = new canopyUXUtils.popup('/addToPlaylist', true, this.asyncConstructor.bind(this), doc); } asyncConstructor(){ this.urlPrompt = this.popup.contentDiv.querySelector('#playlist-add-media-popup-prompt'); this.addButton = this.popup.contentDiv.querySelector('#playlist-add-media-popup-button'); this.setupInput(); } setupInput(){ //Setup input this.addButton.addEventListener('click', this.addToPlaylist.bind(this)); this.popup.popupDiv.addEventListener('keydown', this.addToPlaylist.bind(this)); } addToPlaylist(event){ //If we clicked or hit enter if(event.key == null || event.key == "Enter"){ //Tell the server to add url to the playlist this.client.socket.emit(`addTo${this.location}Playlist`, { //this.client.socket.emit(`addToChannelPlaylist`, { playlist: this.playlist, url: this.urlPrompt.value }); //Close the popup this.popup.closePopup(); } } } class defaultTitlesPopup{ constructor(event, playlist, titles, location, client, doc){ //Set Client this.client = client; //Set playlist this.playlist = playlist //Set location this.location = location; //Set title string this.titles = titles.join('\n'); //Create media popup and call async constructor when done //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( this.popup = new canopyUXUtils.popup('/playlistDefaultTitles', true, this.asyncConstructor.bind(this), doc, false); } asyncConstructor(){ this.titlePrompt = this.popup.contentDiv.querySelector('#playlist-default-titles-popup-prompt'); this.titleButton = this.popup.contentDiv.querySelector('#playlist-default-media-popup-button'); this.titlePrompt.textContent = utils.unescapeEntities(this.titles); this.setupInput(); } setupInput(){ //Setup input this.titleButton.addEventListener('click', this.changeDefaultTitles.bind(this)); this.popup.popupDiv.addEventListener('keydown', this.changeDefaultTitles.bind(this)); } changeDefaultTitles(event){ //If we clicked or hit enter while the prompt wasn't active if(event.key == null || (event.key == "Enter" && this.titlePrompt !== this.popup.doc.activeElement)){ //Tell the server to change the titles this.client.socket.emit(`changeDefaultTitles${this.location}Playlist`, { playlist: this.playlist, defaultTitles: this.titlePrompt.value.split('\n') }); //Close the popup this.popup.closePopup(); } } } class renamePopup{ constructor(event, playlist, client, doc, cb){ //Set Client this.client = client; //Set playlist this.playlist = playlist //Set callback this.cb = cb; //Create media popup and call async constructor when done //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( this.popup = new canopyUXUtils.popup('/renamePlaylist', true, this.asyncConstructor.bind(this), doc); } asyncConstructor(){ this.renamePrompt = this.popup.contentDiv.querySelector('#playlist-rename-popup-prompt'); this.renameButton = this.popup.contentDiv.querySelector('#playlist-rename-popup-button'); this.setupInput(); } setupInput(){ //Setup input this.renameButton.addEventListener('click', this.changeDefaultTitles.bind(this)); this.popup.popupDiv.addEventListener('keydown', this.changeDefaultTitles.bind(this)); } changeDefaultTitles(event){ //If we clicked or hit enter while the prompt wasn't active if(event.key == null || event.key == "Enter"){ //Tell the server to change the titles this.client.socket.emit('renameChannelPlaylist', { playlist: this.playlist, name: this.renamePrompt.value }); //if CB is a function if(typeof this.cb == 'function'){ //Hand it back the new name this.cb(this.renamePrompt.value); } //Close the popup this.popup.closePopup(); } } }