canopy/www/js/channel/panels/queuePanel/playlistManager.js

658 lines
26 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/>.*/
class playlistManager{
constructor(client, panelDocument, queuePanel){
//Set client
this.client = client;
//Set panel document
this.panelDocument = panelDocument;
//Set parent queue panel
this.queuePanel = queuePanel;
//Define Listeners
this.defineListeners();
}
defineListeners(){
this.client.socket.on("chanPlaylists", this.renderChannelPlaylists.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');
//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(){
//If open map is a string, indicating we just renamed a playlist with it's media open
if(typeof this.openMap == 'string'){
//Create new map to hold status with the new name of the renamed playlist already added
this.openMap = new Map([[this.openMap, true]]);
}else{
//Create new map to hold status
this.openMap = new Map();
}
//For each container Div rendered
for(let containerDiv of this.channelPlaylistDiv.querySelectorAll('.queue-playlist-media-container-div')){
//Set whether or not it's visible in the map
this.openMap.set(containerDiv.dataset['playlist'], (containerDiv.style.display != 'none'));
}
}
renderChannelPlaylists(data){
//Check for open playlists
this.checkOpenPlaylists();
//Clear channel playlist div
this.channelPlaylistDiv.innerHTML = '';
//For every playlist sent down from the server
for(let playlistIndex in data){
//Get playlist from data
this.playlist = data[playlistIndex];
//Create a new playlist div
this.playlistDiv = document.createElement('div');
//Set it's class
this.playlistDiv.classList.add('queue-playlist-div');
//Create span to hold playlist entry line contents
this.playlistSpan = document.createElement('span');
//Set classes
this.playlistSpan.classList.add('queue-playlist-span');
//If this isn't our first rodeo
if(this.playlistIndex != 0){
//make note
this.playlistSpan.classList.add('not-first');
}
//Render out playlist entry contents
this.renderLabels();
this.renderControls();
this.renderMedia();
//Append entry items to playlist entry line
this.playlistSpan.appendChild(this.playlistLabels);
this.playlistSpan.appendChild(this.playlistControls);
//Append entry line and contents to playlist div
this.playlistDiv.appendChild(this.playlistSpan);
this.playlistDiv.appendChild(this.mediaContainer);
//Append current playlist div to the channel playlists div
this.channelPlaylistDiv.appendChild(this.playlistDiv);
}
}
//aux rendering functions
renderLabels(){
//Create playlist label span
this.playlistLabels = document.createElement('span');
//Set it's class
this.playlistLabels.classList.add('queue-playlist-labels-span');
//create playlist title span
this.playlistTitleSpan = document.createElement('span');
//Set class
this.playlistTitleSpan.classList.add('queue-playlist-title-span', 'interactive');
//Create playlist title caret
this.playlistTitleCaret = document.createElement('i');
//If this is supposed to be open
if(this.openMap.get(this.playlist.name)){
//Set class accordingly
this.playlistTitleSpan.classList.add('positive');
this.playlistTitleCaret.classList.add('bi-caret-down-fill');
//otherwise
}else{
//Set class accordingly
this.playlistTitleCaret.classList.add('bi-caret-right-fill');
}
//Create playlist title label
this.playlistTitle = document.createElement('p');
//Set it's class
this.playlistTitle.classList.add('queue-playlist-title');
//Unescape Sanatized Enteties and safely inject as plaintext
this.playlistTitle.innerText = utils.unescapeEntities(this.playlist.name);
//Construct playlist title span
this.playlistTitleSpan.appendChild(this.playlistTitleCaret);
this.playlistTitleSpan.appendChild(this.playlistTitle);
//Create playlist count label
this.playlistCount = document.createElement('p');
//Set it's class
this.playlistCount.classList.add('queue-playlist-count');
//List video count
this.playlistCount.innerText = `Count: ${this.playlist.media.length}`;
//Append items to playlist labels span
this.playlistLabels.appendChild(this.playlistTitleSpan);
this.playlistLabels.appendChild(this.playlistCount);
//Define input listeners
this.playlistTitleSpan.addEventListener('click', this.toggleMedia.bind(this));
}
renderControls(){
//Create playlist control span
this.playlistControls = document.createElement('span');
//Set it's class
this.playlistControls.classList.add('queue-playlist-control-span');
//Set dataset
this.playlistControls.dataset['playlist'] = this.playlist.name;
//Create queue all button
this.playlistQueueRandomButton = document.createElement('button');
//Set it's classes
this.playlistQueueRandomButton.classList.add('queue-playlist-queue-random-button', 'queue-playlist-control');
//Inject text content
this.playlistQueueRandomButton.textContent = 'Random';
//Set title
this.playlistQueueRandomButton.title = 'Queue Random Item from Playlist';
//Create queue all button
this.playlistQueueAllButton = document.createElement('button');
//Set it's classes
this.playlistQueueAllButton.classList.add('queue-playlist-queue-all-button', 'queue-playlist-control', 'not-first');
//Inject text content
this.playlistQueueAllButton.textContent = 'All';
//Set title
this.playlistQueueAllButton.title = 'Queue Entire Playlist';
//Create add from URL button
this.playlistAddURLButton = document.createElement('button');
//Set it's classes
this.playlistAddURLButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'positive-button', 'not-first');
//Set Tile
this.playlistAddURLButton.title = 'Add To Playlist From URL'
//Create playlist icons (we're using two so we're putting them inside the button :P)
this.playlistAddIcon = document.createElement('i');
this.playlistLinkIcon = document.createElement('i');
//set classes
this.playlistAddIcon.classList.add('bi-plus-lg');
this.playlistLinkIcon.classList.add('bi-link-45deg');
//Append icons to URL button
this.playlistAddURLButton.appendChild(this.playlistAddIcon);
this.playlistAddURLButton.appendChild(this.playlistLinkIcon);
//Create default titles button
this.playlistDefaultTitlesButton = document.createElement('button');
//Set classes
this.playlistDefaultTitlesButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'bi-tags-fill', 'positive-button', 'not-first');
//Set title
this.playlistDefaultTitlesButton.title = 'Change Default Titles'
//Set dataset
this.playlistDefaultTitlesButton.dataset['titles'] = JSON.stringify(this.playlist.defaultTitles);
//Create rename button
this.playlistRenameButton = document.createElement('button');
//Set it's classes
this.playlistRenameButton.classList.add('queue-playlist-add-url-button', 'queue-playlist-control', 'bi-input-cursor-text', 'positive-button', 'not-first');
//Set title
this.playlistRenameButton.title = 'Rename Playlist'
//Create delete button
this.playlistDeleteButton = document.createElement('button');
//Set it's classes
this.playlistDeleteButton.classList.add('queue-playlist-delete-button', 'queue-playlist-control', 'danger-button', 'bi-trash-fill', 'not-first');
//Set title
this.playlistDeleteButton.title = 'Delete Playlist'
//Append items to playlist control span
this.playlistControls.appendChild(this.playlistQueueRandomButton);
this.playlistControls.appendChild(this.playlistQueueAllButton);
this.playlistControls.appendChild(this.playlistAddURLButton);
this.playlistControls.appendChild(this.playlistDefaultTitlesButton);
this.playlistControls.appendChild(this.playlistRenameButton);
this.playlistControls.appendChild(this.playlistDeleteButton);
//Define input event listeners
this.playlistAddURLButton.addEventListener('click', this.addURL.bind(this));
this.playlistDefaultTitlesButton.addEventListener('click', this.editDefaultTitles.bind(this));
this.playlistRenameButton.addEventListener('click', this.renamePlaylist.bind(this));
this.playlistQueueRandomButton.addEventListener('click', this.queueRandom.bind(this));
this.playlistQueueAllButton.addEventListener('click', this.queueAll.bind(this));
this.playlistDeleteButton.addEventListener('click', this.deletePlaylist.bind(this));
}
renderMedia(){
//Create media container div
this.mediaContainer = document.createElement('div');
//Set classes
this.mediaContainer.classList.add('queue-playlist-media-container-div');
//If the playlist wasn't set to open in the open map
if(!this.openMap.get(this.playlist.name)){
//Auto-hide media container
this.mediaContainer.style.display = 'none';
}
//Set dataset
this.mediaContainer.dataset['playlist'] = this.playlist.name;
for(let mediaIndex in this.playlist.media){
//Grab media object from playlist
this.media = this.playlist.media[mediaIndex];
//Sanatize title text
const title = utils.unescapeEntities(this.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;
//Render out media controls
this.renderMediaControls();
//Append items to media div
mediaDiv.appendChild(mediaTitle);
mediaDiv.appendChild(this.mediaControlSpan);
//Append media div to media container
this.mediaContainer.appendChild(mediaDiv);
}
//return media container
this.mediaContainer;
}
renderMediaControls(){
//Create media control span
this.mediaControlSpan = document.createElement('span');
//Set it's class
this.mediaControlSpan.classList.add('queue-playlist-media-control-span');
//Set dataset
this.mediaControlSpan.dataset['playlist'] = this.playlist.name;
this.mediaControlSpan.dataset['uuid'] = this.media.uuid;
//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 '${this.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 '${this.media.title}' from playlist '${this.playlist.name}'`;
//Append items to media control span
this.mediaControlSpan.appendChild(queueMediaIcon);
this.mediaControlSpan.appendChild(deleteMediaIcon);
//Handle input event listeners
queueMediaIcon.addEventListener('click', this.queueMedia.bind(this));
deleteMediaIcon.addEventListener('click', this.deleteMedia.bind(this));
}
//I'd rather make this a class function but it's probably cleaner to not have to parent crawl
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){
new addURLPopup(
event,
event.target.parentNode.dataset['playlist'],
this.client,
this.queuePanel.ownerDoc
);
}
//playlist control functions
editDefaultTitles(event){
new defaultTitlesPopup(
event,
event.target.parentNode.dataset['playlist'],
JSON.parse(event.target.dataset['titles']),
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 = newName;
}
}
}
queueAll(event){
this.client.socket.emit('queueChannelPlaylist', {playlist: event.target.parentNode.dataset['playlist']});
}
queueMedia(event){
this.client.socket.emit('queueFromChannelPlaylist',{playlist: event.target.parentNode.dataset['playlist'], uuid: event.target.parentNode.dataset['uuid']});
}
queueRandom(event){
this.client.socket.emit('queueRandomFromChannelPlaylist',{playlist: event.target.parentNode.dataset['playlist']});
}
deletePlaylist(event){
this.client.socket.emit('deleteChannelPlaylist', {playlist: event.target.parentNode.dataset['playlist']});
}
deleteMedia(event){
this.client.socket.emit('deleteChannelPlaylistMedia', {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)){
//If we're saving to the channel
if(this.location.value == 'channel'){
//Tell the server to create a new channel playlist
this.client.socket.emit('createChannelPlaylist', {
playlist: this.name.value,
defaultTitles: this.defaultTitles.value.split('\n')
})
}
//Close the popup
this.popup.closePopup();
}
}
}
class addURLPopup{
constructor(event, playlist, client, doc){
//Set Client
this.client = client;
//Set playlist
this.playlist = playlist
//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('addToChannelPlaylist', {
playlist: this.playlist,
url: this.urlPrompt.value
});
//Close the popup
this.popup.closePopup();
}
}
}
class defaultTitlesPopup{
constructor(event, playlist, titles, client, doc){
//Set Client
this.client = client;
//Set playlist
this.playlist = playlist
//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('changeDefaultTitlesChannelPlaylist', {
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
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();
}
}
}