/*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 representing Queue Panel UX
+ */
+class queuePanel extends panelObj{
+ /**
+ * Instantiates a new Queue Panel object
+ * @param {channel} client - Parent Client Management Object
+ * @param {Document} panelDocument - Panel Document
+ */
+ constructor(client, panelDocument){
+ //Call derived constructor
+ super(client, "Media Schedule", "/panel/queue", panelDocument);
+
+ /**
+ * Current day, zero'd out to midnight
+ */
+ this.day = new Date();
+ //Zero out day to midnight
+ this.day.setHours(0,0,0,0);
+
+ /**
+ * Schedule time scale in seconds, defaults to 30 minutes
+ */
+ this.scale = 30 * 60;
+
+ /**
+ * Re-scale timer, counts down after re-sizing to clear re-size UI and show schedule again
+ */
+ this.rescaleTimer = null;
+
+ /**
+ * Enables auto-scroll on schedule UX
+ */
+ this.autoscroll = true;
+
+ /**
+ * Child Playlist Manager Object
+ */
+ this.playlistManager = new playlistManager(client, panelDocument, this);
+
+ //Define non-input event listeners
+ this.defineListeners();
+ }
+
+ docSwitch(){
+ //Clear timetips
+ this.killTimetips();
+
+ //Call derived doc switch function
+ super.docSwitch();
+
+ //Get queue div
+ this.queue = this.panelDocument.querySelector('#queue');
+ //Get queue container
+ this.queueContainer = this.queue.querySelector("#queue-container");
+ //Get queue marker contianer
+ this.queueMarkerContainer = this.queue.querySelector('#queue-marker-container');
+ //Get queue layout controller
+ this.queueLayoutController = this.panelDocument.querySelector('#queue-panel-layout-controller');
+ //Get queue control offset
+ this.queueControlOffset = this.panelDocument.querySelector('#queue-control-offset');
+ //Re-acquire time marker
+ this.timeMarker = this.panelDocument.querySelector('#time-marker');
+ //Dragscroll timer
+ this.dragScrollTimer = null;
+
+ //Get main control buttons
+ this.addMediaButton = this.panelDocument.querySelector('#queue-add-media');
+ this.goLiveButton = this.panelDocument.querySelector('#queue-go-live');
+ this.scrollLockButton = this.panelDocument.querySelector('#queue-scroll-lock');
+ this.queueDateButton = this.panelDocument.querySelector('#queue-date');
+ this.playlistMenuButton = this.panelDocument.querySelector("#queue-playlists");
+ this.clearMediaButton = this.panelDocument.querySelector('#queue-clear');
+ this.queueLockButton = this.panelDocument.querySelector('#queue-lock');
+
+ //Get control divs
+ this.addMediaDiv = this.panelDocument.querySelector('#queue-media-prompts');
+ this.goLiveDiv = this.panelDocument.querySelector('#queue-live-prompts');
+ this.queueDateDiv = this.panelDocument.querySelector('#queue-control-date');
+ this.playlistDiv = this.panelDocument.querySelector('#queue-playlist-prompts');
+
+ //Get control div elements
+ //Add Media
+ this.addMediaLinkPrompt = this.panelDocument.querySelector('#media-link-input');
+ this.addMediaNamePrompt = this.panelDocument.querySelector('#media-name-input');
+ this.queueLastButton = this.panelDocument.querySelector('#queue-last-button');
+ this.queueAtButton = this.panelDocument.querySelector('#queue-at-button');
+ //Go Live
+ this.goLiveNamePrompt = this.panelDocument.querySelector("#queue-live-prompts-name-input");
+ this.goLiveOverwriteButton = this.panelDocument.querySelector("#queue-live-prompts-overwrite-button");
+ this.goLivePushbackButton = this.panelDocument.querySelector("#queue-live-prompts-pushback-button");
+ //Date Queue date
+ this.queueDateDecrement = this.panelDocument.querySelector('#queue-control-date-decrement');
+ this.queueDateIncrement = this.panelDocument.querySelector('#queue-control-date-increment');
+ this.queueDatePrompt = this.panelDocument.querySelector('#queue-control-date-prompt');
+
+ //Display lock status
+ this.handleScheduleLock();
+
+ //Render out the queue
+ this.fullRender();
+
+ //Setup panel input
+ this.setupInput();
+
+ //Pass the new panelDoc and docSwitch() call down to child classes
+ this.playlistManager.panelDocument = this.panelDocument;
+ this.playlistManager.docSwitch();
+ }
+
+ closer(){
+ //Clear any remaining timers
+ clearTimeout(this.renderIntervalTimer);
+ //Clear timetips
+ this.killTimetips();
+ }
+
+ /**
+ * Handles Network-Related Event Listeners
+ */
+ defineListeners(){
+ //Render queue when we receive a new copy of the queue data from the server
+ //Render queue should be called within an arrow function so that it's called with default parameters, and not handed an event as a date
+ this.client.socket.on("clientMetadata", () => {this.renderQueue();});
+ this.client.socket.on("queue", () => {this.renderQueue();});
+ this.client.socket.on("start", this.handleStart.bind(this));
+ this.client.socket.on("end", this.handleEnd.bind(this));
+ this.client.socket.on("lock", this.handleScheduleLock.bind(this));
+ this.client.socket.on("error", this.handleQueueError.bind(this));
+ }
+
+ /**
+ * Handles Input-Related Event Listeners
+ */
+ setupInput(){
+ //Re-render queue and time-marker on window resize as time-relative absolute positioning will be absolutely thrown
+ this.ownerDoc.defaultView.addEventListener('resize', this.resizeRender.bind(this));
+
+ //queue
+ this.queue.addEventListener('wheel', this.scaleScroll.bind(this));
+
+ //control bar controls
+ this.addMediaButton.addEventListener('click', this.toggleAddMedia.bind(this));
+ this.goLiveButton.addEventListener('click', this.toggleGoLive.bind(this));
+ this.scrollLockButton.addEventListener('click', this.lockScroll.bind(this));
+ this.queueDateButton.addEventListener('click', this.toggleDateControl.bind(this));
+ this.playlistMenuButton.addEventListener('click', this.togglePlaylistDiv.bind(this));
+ this.clearMediaButton.addEventListener('click', this.clearMedia.bind(this));
+ this.queueLockButton.addEventListener('click', this.lockSchedule.bind(this));
+
+ //control bar divs
+ //Add Media
+ this.queueLastButton.addEventListener('click', this.queueLast.bind(this))
+ this.queueAtButton.addEventListener('click', this.queueAt.bind(this))
+ //Go Live
+ this.goLiveOverwriteButton.addEventListener('click', this.goLive.bind(this));
+ this.goLivePushbackButton.addEventListener('click', this.goLive.bind(this));
+ //Queue Date
+ this.queueDateDecrement.addEventListener('click', this.decrementDate.bind(this));
+ this.queueDateIncrement.addEventListener('click', this.incrementDate.bind(this));
+ this.queueDatePrompt.addEventListener('change', this.setQueueDate.bind(this));
+ }
+
+ /* socket.io listeners */
+ /**
+ * Handles call from server to start media
+ * @param {Object} data - Data from server
+ */
+ handleStart(data){
+ //If we're starting an HLS Livestream
+ if(data.media != null && data.media.type == 'livehls'){
+ //Hide the 'goLive' controls
+ this.goLiveDiv.style.display = 'none';
+ }
+
+ this.renderQueue();
+ }
+
+ /**
+ * Handles call from server to end media
+ */
+ handleEnd(){
+ //Reset Go Live button
+ this.goLiveButton.classList.replace('critical-danger-button', 'danger-button');
+ this.goLiveButton.classList.replace('bi-record', 'bi-record2');
+ this.goLiveButton.title = "Go Live";
+
+ this.renderQueue();
+ }
+
+ /**
+ * Handles call from server to lock schedule
+ */
+ handleScheduleLock(){
+ //Get queue lock button icon
+ const icon = this.queueLockButton.querySelector('i');
+
+ if(this.client.queueLock){
+ this.queueLockButton.classList.remove('positive-button', 'bi-unlock-fill');
+ this.queueLockButton.classList.add('danger-button', 'bi-lock-fill');
+ }else{
+ this.queueLockButton.classList.remove('danger-button', 'bi-lock-fill');
+ this.queueLockButton.classList.add('positive-button', 'bi-unlock-fill');
+ }
+ }
+
+ /**
+ * Handles queue-related error from the server
+ * @param {Object} data - Data from server
+ */
+ handleQueueError(data){
+ //Create a flag to reload the queue
+ let reloadQueue = false;
+
+ //Check errors
+ for(let error of data.errors){
+ //If we have a queue error
+ if(error.type == "queue"){
+ //Throw the reload flag
+ reloadQueue = true;
+ }
+ }
+
+ //If we need to reload the queue
+ if(reloadQueue){
+ //Do so
+ this.renderQueue();
+ }
+ }
+
+ /* queue control button functions */
+
+ /**
+ * Toggles add media UX
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ toggleAddMedia(event){
+ //If the div is hidden
+ if(this.addMediaDiv.style.display == 'none'){
+ //Light up the button
+ this.addMediaButton.classList.add('positive-button');
+ //Show the div
+ this.addMediaDiv.style.display = '';
+ }else{
+ //Unlight the button
+ this.addMediaButton.classList.remove('positive-button');
+ //Hide the div
+ this.addMediaDiv.style.display = 'none';
+ }
+ }
+
+ /**
+ * Toggles Go-Live UX
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ toggleGoLive(event){
+ //If we're not livestreaming
+ if(client.player.mediaHandler.type != "livehls"){
+ //If the div is hidden
+ if(this.goLiveDiv.style.display == 'none'){
+ //Show the div
+ this.goLiveDiv.style.display = '';
+ }else{
+ //Hide the div
+ this.goLiveDiv.style.display = 'none';
+ }
+ //Otherwise
+ }else{
+ //Hide the div, if it isn't already
+ this.goLiveDiv.style.display = 'none';
+
+ //Stop the livestream
+ client.socket.emit('stop');
+ }
+
+ }
+
+ /**
+ * Handles sending request to server to start a live stream
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ goLive(event){
+ //Start a livestream
+ client.socket.emit('goLive',{title: this.goLiveNamePrompt.value, mode: event.target.dataset['mode']});
+ }
+
+ /**
+ * Locks schedule scroll to current time marker
+ * @param {Event} event - Event passed down from Event Listener
+ * @param {Boolean} jumpToDay - whether or not to jump schedule to the current day
+ */
+ lockScroll(event, jumpToDay = true){
+ //If we're supposed to jump to the current day
+ if(jumpToDay){
+ //Set schedule to current day
+ this.setDay();
+ }
+
+ //Enable scroll lock
+ this.autoscroll = true;
+
+ //If we have a time marker
+ if(this.timeMarker != null){
+ //Light the indicator
+ this.scrollLockButton.classList.add('positive-button');
+ //Render the marker early to insta-jump
+ this.renderTimeMarker();
+ }else{
+ //Unlight the indicator
+ this.scrollLockButton.classList.remove('positive-button');
+ }
+ }
+
+ /**
+ * Toggles schedule date control
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ toggleDateControl(event){
+ //If the div is hidden
+ if(this.queueDateDiv.style.display == 'none'){
+ //Light up the button
+ this.queueDateButton.classList.add('positive-button');
+
+ //Set date text
+ this.queueDatePrompt.valueAsDate = utils.ux.localizeDate(this.day);
+
+ //Show the div
+ this.queueDateDiv.style.display = '';
+ }else{
+ //Unlight the button
+ this.queueDateButton.classList.remove('positive-button');
+ //Hide the div
+ this.queueDateDiv.style.display = 'none';
+ }
+ }
+
+ /**
+ * Toggles playlist management UX
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ togglePlaylistDiv(event){
+ //If the div is hidden
+ if(this.playlistDiv.style.display == 'none'){
+ //Query playlists
+ client.socket.emit('getChannelPlaylists');
+
+ //Light up the button
+ this.playlistMenuButton.classList.add('positive-button');
+ //Show the div
+ this.playlistDiv.style.display = '';
+ }else{
+ //Unlight the button
+ this.playlistMenuButton.classList.remove('positive-button');
+ //Hide the div
+ this.playlistDiv.style.display = 'none';
+ }
+ }
+
+ /**
+ * Sends request to server to clear media between a range of two dates
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ clearMedia(event){
+ //Call up the popup
+ new clearPopup(event, this.client, null, this.ownerDoc);
+ }
+
+ /**
+ * Sends request to lock the schedule
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ lockSchedule(event){
+ client.socket.emit('lock');
+ }
+
+ /* add queue controls */
+ /**
+ * Sends request to queue current URL after the last item
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ queueLast(event){
+ //Send off the request
+ this.client.socket.emit("queue",{url:this.addMediaLinkPrompt.value, title:this.addMediaNamePrompt.value});
+
+ //Clear out prompts
+ this.addMediaLinkPrompt.value = '';
+ this.addMediaNamePrompt.value = '';
+ }
+
+ /**
+ * Sends request to queue current URL at the current time
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ queueAt(event){
+ //Call up the popup
+ new schedulePopup(event, this.client, this.addMediaLinkPrompt.value, this.addMediaNamePrompt.value, null, this.ownerDoc);
+
+ //Clear out prompts
+ this.addMediaLinkPrompt.value = '';
+ this.addMediaNamePrompt.value = '';
+ }
+
+ /* set date controls */
+ /**
+ * Increments displayed schedule date
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ incrementDate(event){
+ //Increment day
+ this.day.setDate(this.day.getDate() + 1);
+ //Set day
+ this.setDay(this.day);
+ }
+
+ /**
+ * Decrements displayed schedule date
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ decrementDate(event){
+ //Decrement day
+ this.day.setDate(this.day.getDate() - 1);
+
+ //Set day
+ this.setDay(this.day);
+ }
+
+ /**
+ * Validates and sets display schedule date from user input
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ setQueueDate(event){
+ //If we have a valid date
+ if(this.queueDatePrompt.valueAsDate != null){
+ //Set the day
+ this.setDay(utils.ux.normalizeDate(this.queueDatePrompt.valueAsDate));
+
+ }
+ }
+
+ /**
+ * Directly sets schedule display date
+ * @param {Date} date - Date to set schedule to
+ */
+ setDay(date = new Date()){
+ //Set day
+ this.day = date;
+ //Zero out to midnight
+ this.day.setHours(0,0,0,0);
+
+ //Set prompt to current day
+ this.queueDatePrompt.valueAsDate = utils.ux.localizeDate(this.day);
+
+ //Re-render the queue
+ this.renderQueue();
+ //Re-render/hide the time marker
+ this.renderTimeMarker();
+
+ //If autoscroll is enabled
+ if(this.autoscroll){
+ //Simulate a button click to un/re-light the button and trigger a scroll when the date is set to today
+ this.lockScroll(null, false);
+ }
+ }
+
+ /**
+ * Handles time-scale changes on crtl+scroll
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ scaleScroll(event){
+ if(event.ctrlKey){
+ //I could sit here and work out why timetips break everything on scalescroll
+ //Then again you wouldn't want phantom timetips surviving the re-scale so you might as well get two birds stoned at once :P
+ this.killTimetips();
+
+ //Capture inverse scroll wheel direction
+ const scrollDirection = event.wheelDeltaY / Math.abs(event.wheelDeltaY) * -1;
+
+ //Default scale factor to 5 seconds
+ let scaleFactor = 5;
+
+ //Tried to do this with math but couldnt because im bad at math so heres the if statement of shame :(
+ if(this.scale >= 7200){
+ scaleFactor = 3600
+ }else if(this.scale >= 3600){
+ scaleFactor = scrollDirection > 0 ? 3600 : 1800;
+ }else if(this.scale >= 1800){
+ scaleFactor = scrollDirection > 0 ? 1800 : 900;
+ }else if(this.scale == 900){
+ scaleFactor = scrollDirection > 0 ? 900 : 300;
+ }else if(this.scale > 300){
+ //If we're above five minutes use five minutes
+ scaleFactor = 300;
+ }else if(this.scale == 300){
+ //If we're at five minutes scroll up by five minutes or scroll down to one minute
+ scaleFactor = scrollDirection > 0 ? 300 : 240;
+ }else if(this.scale == 60){
+ //If we're at one minutes scroll up by four minutes or scroll down by 10 seconds
+ scaleFactor = scrollDirection > 0 ? 240 : 10;
+ }else if(this.scale > 10){
+ scaleFactor = 10;
+ }else if(this.scale == 10){
+ scaleFactor = scrollDirection > 0 ? 10 : 5;
+ }
+
+ //Prevent page-wide zoom in/out
+ event.preventDefault();
+
+ //Clear out the queue UI
+ this.clearQueue();
+
+ //Calculate new scale
+ const newScale = this.scale + (scaleFactor * scrollDirection);
+
+ //Clamp scale between 10 seconds and half a day
+ this.scale = Math.max(5, Math.min(43200, newScale));
+
+ //If we have no scale label
+ if(this.scaleLabel == null){
+ //Make it
+ this.scaleLabel = document.createElement('p');
+ this.scaleLabel.id = 'queue-marker-scale-label';
+ this.queue.appendChild(this.scaleLabel);
+ }
+
+ //Set scale label text to humie readable time scale
+ this.scaleLabel.innerHTML = `Time Scale:<br>${utils.ux.humieFriendlyDuration(this.scale)}`
+
+ //Clear any previous rescale timer
+ clearTimeout(this.rescaleTimer);
+ //Set timeout to re-render after input stops
+ this.rescaleTimer = setTimeout(this.fullRender.bind(this), 500);
+ //Otherwise, if we're just scrolling
+ }else{
+ //If we're looking at today
+ if(utils.isSameDate(new Date(), this.day)){
+ //Unlock auto scroll
+ this.unlockScroll();
+ }
+ }
+ }
+
+ /**
+ * Un-locks scroll from curren time marker
+ */
+ unlockScroll(){
+ //Disable scroll lock
+ this.autoscroll = false;
+ //Unlight the indicator
+ this.scrollLockButton.classList.remove('positive-button');
+ }
+
+ /**
+ * Handles re-rendering schedule upon window re-size
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ resizeRender(event){
+ const date = new Date();
+ this.renderQueue(date);
+ this.renderTimeMarker(date);
+ }
+
+ /**
+ * Clears out queue container for re-render
+ */
+ clearQueue(){
+ //If we have no body
+ if(this.ownerDoc.body == null){
+ //We have bigger issues
+ return;
+ }
+
+ //Clear out queue container
+ this.queueContainer.innerHTML = '';;
+ //Clear out queue marker container
+ this.queueMarkerContainer.innerHTML = '';
+
+ //Grab all related tooltips
+ const tooltips = this.ownerDoc.body.querySelectorAll('.media-tooltip');
+
+ //clear them out since we're clearing out the elements with the event to remove them
+ //These should clear out on their own on mousemove but this makes things look a *little* prettier :)
+ for(let tooltip of tooltips){
+ tooltip.parentNode.remove();
+ }
+
+ //Clear any marker timers
+ clearTimeout(this.renderIntervalTimer);
+
+ //If we have an existing time marker
+ if(this.timeMarker != null){
+ //Clear it out
+ this.timeMarker.remove();
+ this.timeMarker = null;
+ }
+ }
+
+ /**
+ *
+ * @param {Date} date - Current time,
+ */
+ async fullRender(date = new Date()){
+ //Clear the queue
+ this.clearQueue();
+
+ //If we have a scale label
+ if(this.scaleLabel != null){
+ //Take it out
+ this.scaleLabel.remove();
+ this.scaleLabel = null;
+ }
+
+ //Render out time scale
+ this.renderQueueScale(date);
+
+ //Grab the first marker
+ let firstMarker = this.panelDocument.querySelector('.queue-marker-first');
+
+ //Loop until first marker is properly positioned
+ while(firstMarker.offsetTop > 0){
+ //wait a few frames so the scale can finish rendering, because dom function aren't async for some fucking reason
+ await utils.ux.awaitNextFrame();
+ }
+
+ //render the time marker w/ force scroll
+ this.renderTimeMarker(date, true);
+
+ //Kick off render interval
+ this.renderInterval(date);
+
+ //render out the queue
+ this.renderQueue(date);
+ }
+
+ /**
+ * Renders out schedule
+ * @param {Date} date - Date representing current time, defaults to new date object
+ */
+ renderQueue(date = new Date()){
+ //Clear out queue container
+ this.queueContainer.innerHTML = '';
+
+ //for every entry in the queue
+ for(let entry of this.client.queue){
+ //Check if item starts today
+ const startsToday = utils.isSameDate(this.day, new Date(entry[1].startTime));
+ //Check if item ends today
+ const endsToday = utils.isSameDate(this.day, new Date(entry[1].startTime + (entry[1].duration * 1000)));
+
+ //If the item either starts or ends today
+ var playsToday = (startsToday || endsToday);
+
+ //If the media neither starts nor ends today
+ if(!playsToday){
+ //set playsToday based on whether or not we're playing something fucking huge and it's covering all of today
+ playsToday = utils.dateWithinRange(new Date(entry[1].startTime), new Date(entry[1].startTime + (entry[1].duration * 1000)), this.day);
+ }
+
+ //If part of the current item plays today
+ if(playsToday){
+ //Create entry div
+ const entryDiv = document.createElement('div');
+ entryDiv.classList.add('queue-entry');
+
+ //For each property of the media object
+ for(let key of Object.keys(entry[1])){
+ //add it to its given dataset
+ entryDiv.dataset[key] = entry[1][key];
+ }
+
+ //If this item starts today
+ if(startsToday){
+ //Place the top of the entry div based on time
+ entryDiv.style.top = `${this.offsetByDate(new Date(entry[1].startTime))}px`;
+ }else{
+ //Get dawn
+ const dawn = new Date();
+ dawn.setHours(0,0,0,0);
+
+ //Place item beginning at dawn
+ entryDiv.style.top = `${this.offsetByDate(dawn)}px`;
+
+ //Apply style rules for items that starrted yesterday
+ entryDiv.classList.add('started-yesterday');
+ }
+
+ //If the item ends today
+ if(endsToday){
+ //Place the bottom of the entry div based on time
+ entryDiv.style.bottom = `${this.offsetByDate(new Date(this.getMediaEnd(entry[1])), true)}px`;
+ }else{
+ //Get midnight
+ const dusk = new Date();
+ dusk.setHours(23,59,59,999);
+
+ //Place item beginning at dawn
+ entryDiv.style.bottom = `${this.offsetByDate(dusk, true)}px`;
+
+ //Run apply the rest of the styling rules
+ entryDiv.classList.add('ends-tomorrow');
+ }
+
+ //If we started in the middle of the video
+ if(entry[1].startTimeStamp > 0){
+ entryDiv.classList.add('started-late');
+ }
+
+ //If we ended early
+ if(entry[1].earlyEnd != null){
+ entryDiv.classList.add('ended-early');
+ }
+
+ //Create entry title
+ const entryTitle = document.createElement('p');
+ entryTitle.textContent = utils.unescapeEntities(entry[1].title);
+
+ //Tooltip generation
+ //tooltip div
+ const tooltipDiv = document.createElement('div');
+ tooltipDiv.classList.add('media-tooltip');
+
+ //tooltip components
+ //For each string
+ for(let string of [
+ `Title: ${entry[1].title}`,
+ `File Name: ${entry[1].fileName}`,
+ `Source: ${entry[1].type}`,
+ `Duration: ${utils.ux.humieFriendlyDuration(entry[1].duration)}`,
+ `Start Time: ${new Date(entry[1].startTime).toLocaleString()}${entry[1].startTimeStamp == 0 ? '' : ' (Started Late)'}`,
+ `End Time: ${new Date(this.getMediaEnd(entry[1])).toLocaleString()}${entry[1].earlyEnd == null ? '' : ' (Ended Early)'}`
+ ]){
+ //Create a 'p' node
+ const component = document.createElement('p');
+ //Fill it with the string
+ component.textContent = utils.unescapeEntities(string);
+ //Append it to the tooltip div
+ tooltipDiv.append(component);
+ }
+
+ //Setup media tooltip
+ entryDiv.addEventListener('mouseenter',(event)=>{
+ //If we're not dragging
+ if(event.target.dataset['drag'] != 'true'){
+ //Display tooltip
+ utils.ux.displayTooltip(event, tooltipDiv, false, null, true, this.ownerDoc);
+ }
+ });
+
+ //Create context menu map
+ const menuMap = new Map();
+ const now = new Date();
+
+ //If the item hasn't started yet
+ if(entry[1].startTime > now.getTime()){
+ //Add 'Play' option to context menu
+ menuMap.set("Play now", ()=>{this.client.socket.emit('move', {uuid: entry[1].uuid})});
+ //Add 'Move To...' option to context menu
+ menuMap.set("Move To...", (event)=>{new reschedulePopup(event, this.client, entry[1], null, this.ownerDoc)});
+ //Otherwise, if the item is currently playing (confirm with UUID since time might not always be reliable, such as during livestreams)
+ }else if(entry[1].uuid == this.client.player.mediaHandler.nowPlaying.uuid){
+ //Add 'Stop' option to context menu
+ menuMap.set("Stop", ()=>{this.client.socket.emit('stop')});
+ //Add the Now Playing glow, not the prettiest place to add this, but why let a good conditional go to waste?
+ entryDiv.classList.add('now-playing');
+ //Otherwise, if the item has been archived
+ }else{
+ entryDiv.classList.add('archived');
+ }
+
+ //Add 'Delete' option to context menu
+ menuMap.set("Delete", ()=>{this.client.socket.emit('delete', {uuid: entry[1].uuid})})
+ //Add 'New Tab' option to context menu
+ menuMap.set("Open in New Tab", ()=>{window.open(entry[1].url, '_blank').focus();})
+ //Add 'Copy URL' option to context menu
+ menuMap.set("Copy URL", ()=>{navigator.clipboard.writeText(entry[1].url);})
+
+ //If the item hasn't yet ended
+ if(this.getMediaEnd(entry[1]) > now.getTime()){
+ //Setup drag n drop
+ entryDiv.addEventListener('mousedown', clickEntry.bind(this));
+ }
+
+ //Setup context menu
+ entryDiv.addEventListener('contextmenu', (event)=>{
+ //If we're not dragging
+ if(event.target.dataset['drag'] != 'true'){
+ //Display context menu
+ utils.ux.displayContextMenu(event, '', menuMap, this.ownerDoc);
+ }
+ });
+
+ //Append entry elements
+ entryDiv.append(entryTitle);
+
+ //Append entry
+ this.queueContainer.append(entryDiv);
+ }
+
+ }
+
+ //Render out any playing livestreams
+ this.renderLiveStream(date);
+
+ function clickEntry(event){
+ //If it's not a left click
+ if(event.buttons != 1){
+ //fuck off
+ return;
+ }
+
+ //Grab existing height
+ let height = event.target.offsetHeight;
+ let cutoffOffset = 0;
+
+ //If the item got cut-off at the bottom
+ if(event.target.classList.contains("ends-tomorrow") || event.target.classList.contains("ended-early")){
+ //Calculate height from duration
+ height = this.offsetByMilliseconds(Number(event.target.dataset['duration']) * 1000);
+ //If the item got cut-off at the top
+ }else if(event.target.classList.contains('started-yesterday') || event.target.classList.contains("started-late")){
+ //Keep old height for now
+ const oldHeight = height;
+
+ //Calculate height from duration
+ height = this.offsetByMilliseconds(Number(event.target.dataset['duration']) * 1000);
+
+ //Calculate the mouse offset needed to keep it properly placed relative to the original click point
+ cutoffOffset = height - oldHeight;
+ }
+
+ //Remove any cut-off borders
+ event.target.classList.remove('ends-tomorrow', 'started-yesterday', 'ended-early', 'started-late');
+
+
+ //If we havent set height or width
+ if(event.target.style.height == ""){
+ //Preserve calculated entry height
+ event.target.style.height = `${height}px`;
+ }
+
+ //Add set dragging CSS class to target
+ event.target.classList.add('dragging-queue-entry');
+
+ //enable drag on target dataset
+ event.target.dataset['drag'] = true;
+
+ //Kill existing timetips
+ this.killTimetips();
+
+ //Create a tooltip to show the time we're dragging to
+ const timetip = new canopyUXUtils.tooltip('', false, null, this.ownerDoc);
+ timetip.tooltip.classList.add('media-tooltip','media-timetip');
+ timetip.tooltip.addEventListener('mousemove', this.killTimetips.bind(this));
+
+
+ //Drag entry with mouse
+ this.ownerDoc.body.addEventListener('mousemove', (nestedEvent)=>{(dragEntry.bind(this))(nestedEvent, event.target, timetip)});
+
+ //Drop on mouse up
+ this.ownerDoc.body.addEventListener('mouseup', (nestedEvent)=>{(dropEntry.bind(this))(nestedEvent, event.target)});
+
+ //Disable selection on body
+ this.ownerDoc.body.style.userSelect = 'none';
+
+ //Save top of target relative to window minus the mouse position as our drag offset
+ event.target.dataset['dragoffset'] = (event.target.offsetTop + this.ownerDoc.defaultView.scrollY) - event.clientY - cutoffOffset;
+
+ //Call the drag entry function to move the entry on click without re-writing the wheel
+ (dragEntry.bind(this))(event, event.target, timetip);
+
+ //Start dragscroll loop
+ this.dragScrollTimer = setInterval(()=>{(dragScroll.bind(this))(event.target)}, 10);
+ }
+
+ function dragScroll(target){
+ //Stop timeout loop
+ if(!target.isConnected || target.dataset['drag'] != "true"){
+ //Clear the interval
+ clearInterval(this.dragScrollTimer);
+ //Fuck off and die!
+ return;
+ }
+
+ //Set minimum distance to detect
+ const detectionDistance = 70;
+ //Set value to devide distance from edge during scroll speed calculation
+ const speedDevider = 6;
+
+ //Get top boundaries distance from the top relative to the scroll top and set as top input
+ let topInput = target.offsetTop - this.queueLayoutController.scrollTop;
+ //Get bottom boundaries distance from the top relative to the scroll top and set as bottom input
+ let bottomInput = this.queueContainer.offsetHeight - ((target.offsetTop + target.offsetHeight) + (this.queueLayoutController.scrollTopMax - this.queueLayoutController.scrollTop));
+
+ //If the item we're dragging is fackin uge'
+ if(target.offsetHeight > (this.queueLayoutController.offsetHeight - ((detectionDistance * 2) + 20))){
+ //AND THEY FUCKING SAID YOU COULDN'T GET MOUSE POS OUTSIDE OF AN EVENT WITHOUT :HOVER TRICKS EAT MY FUCKING ASS
+ topInput = Math.round(target.offsetTop - Number(target.dataset['dragoffset']) - (this.queueLayoutController.getBoundingClientRect().top + this.queueControlOffset.offsetHeight));
+ bottomInput = this.queueLayoutController.offsetHeight - (topInput + this.queueControlOffset.offsetHeight);
+ }
+
+ //If the top of the entry is within five pixels of the top of the parent and we have room to scroll up
+ if(topInput < detectionDistance && this.queueLayoutController.scrollTop > 0){
+ //Unlock auto scroll
+ this.unlockScroll();
+
+ //Filter out less than 0 from relative entry top to calculate speed
+ const speed = Math.round(((detectionDistance) - (topInput < 0 ? 0 : topInput)) / speedDevider);
+
+ //Scroll queue by distance to top
+ this.queueLayoutController.scrollBy(0, -speed);
+
+ //Add scroll amount to drag offset to keep entry aligned with mouse
+ target.dataset['dragoffset'] = Number(target.dataset['dragoffset']) - speed
+ //Move entry by speed to match new drag offset
+ target.style.top = `${target.offsetTop - speed}px`
+ //Otherwise if the bottom of the entry is within five pixels the bottom of the parent and we have room to scroll down
+ }else if(bottomInput < (detectionDistance) && this.queueLayoutController.scrollTop < this.queueLayoutController.scrollTopMax){
+ //Unlock auto scroll
+ this.unlockScroll();
+
+ //Calculate speed by distance to bottom
+ const offsetBottom = bottomInput;
+ //Filter out less than 0, reverse the range, and apply scroll dampen to avoid scrolling off the edge
+ const speed = Math.round((detectionDistance - (offsetBottom < 0 ? 0 : offsetBottom)) / speedDevider)
+
+ //Scroll queue by calculated speed
+ this.queueLayoutController.scrollBy(0, speed);
+
+ //Subtract speed from drag offset to keep aligned with mouse
+ target.dataset['dragoffset'] = Number(target.dataset['dragoffset']) + speed;
+ //Move entry by speed to match new drag offset
+ target.style.top = `${target.offsetTop + speed}px`
+ }
+ }
+
+ function dragEntry(event, target, timetip){
+ //Gross but works :P
+ if(!target.isConnected || target.dataset['drag'] != "true"){
+ return;
+ }
+
+ //Get current start time
+ const start = this.dateByOffset(target.offsetTop);
+
+ //Position timetip
+ timetip.moveToMouse(event);
+
+ //Inject timetip label
+ //Normally wouldn't do innerHTML but these values are calculated serverside and it saves us making a <br> element
+ timetip.tooltip.innerHTML = [
+ `Start Time: ${utils.ux.timeStringFromDate(start, true)}`,
+ `End Time: ${utils.ux.timeStringFromDate(new Date(start.getTime() + (target.dataset['duration'] * 1000)), true)}`
+ ].join('<br>');
+
+ //Calculate offset from rest of window
+ const windowOffset = this.queueContainer.offsetTop + this.ownerDoc.defaultView.scrollY;
+
+ //Move the entry to the mouse offset by the target nodes height and the queue layouts scroll
+ const entryTop = event.clientY + Number(target.dataset['dragoffset']) - windowOffset;
+
+ //Set target vertical position
+ target.style.top = `${entryTop}px`;
+ }
+
+ function dropEntry(event, target){
+ //Gross but works :P
+ if(!target.isConnected || target.dataset['drag'] != "true"){
+ return;
+ }
+
+ //Asynchronously send move command item by calculated time to server
+ this.client.socket.emit('move', {uuid: target.dataset['uuid'], start: this.dateByOffset(target.offsetTop).getTime()});
+
+ //allow selection on body
+ this.ownerDoc.body.style.userSelect = 'none';
+
+ //kill timetips
+ this.killTimetips();
+
+ //Finish dragging
+ target.dataset['drag'] = false;
+ }
+
+ }
+
+ /**
+ * Kills off hung tooltips
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ killTimetips(event){
+ //If we have an event and it's holding any mouse buttons
+ if(event != null && event.buttons != 0){
+ //Fuck off and die
+ return;
+ }
+
+ //Find any existing timetips
+ for(const timetip of this.ownerDoc.querySelectorAll('.media-timetip')){
+ //nukem
+ timetip.remove();
+ }
+ }
+
+ /**
+ * Render call called at 1-second intervals, handles time and livestream markers
+ * @param {Date} date - Date representing current time, defaults to new date object
+ */
+ renderInterval(date = new Date()){
+ this.renderTimeMarker(date);
+ this.renderLiveStream(date, true);
+
+
+ //Clear update timer
+ clearTimeout(this.renderIntervalTimer);
+
+ //Set timer to update marker every second
+ this.renderIntervalTimer = setTimeout(this.renderInterval.bind(this), 1000);
+ }
+
+ /**
+ * Renders current time marker on to the schedule
+ * @param {Date} date - Date representing current time, defaults to new date object
+ * @param {Boolean} forceScroll - Whether or not to scroll the schedule on increment
+ */
+ renderTimeMarker(date = new Date(), forceScroll = false){
+ //Calculate marker position by date
+ const markerPosition = Math.round(this.offsetByDate(date));
+
+ //If markers are null
+ if(markerPosition == null){
+ //Try again in a second since the user probably just popped the panel or something :P
+ return;
+ }
+
+ //If we're not looking at today
+ if(!utils.isSameDate(this.day, new Date())){
+ //If we still have at time marker
+ if(this.timeMarker != null){
+ this.timeMarker.remove();
+ this.timeMarker = null
+ }
+
+ //Stop here
+ return;
+ }
+
+ //if we need to make the time marker
+ if(this.timeMarker == null){
+ //Create the time marker
+ this.timeMarker = document.createElement('span');
+ //Add time marker class
+ this.timeMarker.id = 'time-marker';
+ //Append time marker
+ this.queue.appendChild(this.timeMarker);
+ }
+
+ //Set time marker position
+ this.timeMarker.style.top = `${markerPosition}px`;
+
+ //If the panel document isn't null (we're not actively switching panels)
+ if(this.panelDocument != null && (this.autoscroll || forceScroll)){
+ //Get height difference between window and queue layout controller
+ const docDifference = this.ownerDoc.defaultView.innerHeight - this.queueLayoutController.getBoundingClientRect().height;
+ //Calculate scroll target by body difference and marker position
+ const scrollTarget = (markerPosition - (this.queueLayoutController.getBoundingClientRect().height - docDifference) / 2) + docDifference;
+ //Calculate scroll behavior by distance
+ const scrollBehavior = Math.abs(scrollTarget - this.queueLayoutController.scrollTop) > 10 ? "smooth" : "instant";
+
+
+ //Scroll to the marker
+ this.queueLayoutController.scroll({
+ left: 0,
+ top: scrollTarget,
+ behavior: scrollBehavior
+ });
+ }
+ }
+
+ /**
+ * Renders queue scale markers
+ * @param {Date} inputDate - Date representing current time, defaults to new date object
+ */
+ renderQueueScale(inputDate = new Date()){
+ //Clear out queue marker container
+ this.queueMarkerContainer.innerHTML = '';
+
+ //Make sure we don't modify the date we're handed
+ const date = structuredClone(inputDate);
+
+ //Zero out time to midnight on the morning of the input date
+ date.setHours(0,0,0,0);
+
+ //Store epoch of current date at midnight for later user
+ const dateEpoch = date.getTime();
+
+ //Create array to hold entries for post processing
+ const entries = [];
+
+ //while we've counted less than the amount of time in the day, count up by scale
+ for(let time = 0; time <= 86400; time += this.scale){
+ //Get index of current chunk by dividing time by scale
+ const index = time / this.scale;
+
+ //Set time by scale, we could do this against this.scale and date.getTime(), but this seemed cleaner :P
+ date.setTime(dateEpoch + (time * 1000))
+
+ //Create marker span
+ const markerDiv = document.createElement('div');
+ markerDiv.classList.add('queue-marker');
+
+ //Create marker line (unfortunately <hr> tags don't seem to play nice with parents who have display:flex)
+ const marker = document.createElement('span');
+ marker.classList.add('queue-marker');
+
+ //If it's even/zero
+ if(index % 2 == 0){
+ //Create marker label
+ const markerLabel = document.createElement('p');
+
+ //If scale is over a minute then we don't need to display seconds
+ markerLabel.textContent = utils.ux.timeStringFromDate(date, this.scale < 60)
+
+ //Add marker label to marker span
+ markerDiv.appendChild(markerLabel);
+ }
+
+ //Append marker to marker span
+ markerDiv.appendChild(marker);
+
+ //Append marker span to queue container
+ this.queueMarkerContainer.appendChild(markerDiv);
+ //Add it to our postprocessing list
+ entries.push(markerDiv);
+ }
+
+ //If we made anything (should always be true :P)
+ if(entries.length > 0){
+ //Set the margin for the first queue marker
+ entries[0].classList.add('queue-marker-first');
+
+ //Set the margin for the last queue marker
+ entries[entries.length - 1].classList.add('queue-marker-last');
+ }
+ }
+
+ /**
+ * Renders live-stream marker
+ * @param {Date} inputDate - Date representing current time, defaults to new date object
+ * @param {Boolean} intervalRun - Whether or not this was run by renderInterval, defaults to false
+ */
+ renderLiveStream(date = new Date(), intervalRun = false){
+ //Grab all live queue entries
+ const staleEntry = this.queueContainer.querySelector('.queue-entry.live');
+
+ //If we're not livestreaming
+ if(client.player.mediaHandler.type != "livehls"){
+ //If we have a stale entry
+ if(staleEntry != null){
+ //Remove stale entry since we're no longer streaming
+ staleEntry.remove();
+ }
+
+ //Fuck off and die
+ return;
+ }
+
+ //If we haven't updated the Go-Live button yet
+ //This kinda needs to be done here since this might be opened mid-stream :P
+ if(this.goLiveButton.title != "Stop Stream"){
+ this.goLiveButton.classList.replace('danger-button', 'critical-danger-button');
+ this.goLiveButton.title = "Stop Stream";
+ }
+
+
+ //If this was called by the timer function, and not some other queue-rendering related function
+ if(intervalRun){
+ //Though there is reason to run an if statement here since we dont want the latter over-writing the former every run...
+ //If we're showing the regular icon
+ if(this.goLiveButton.classList.contains('bi-record2')){
+ //Animated it with the other live icon
+ this.goLiveButton.classList.replace('bi-record2', 'bi-record');
+ //otherwise
+ }else{
+ //Show the standard one we always do
+ this.goLiveButton.classList.replace('bi-record', 'bi-record2');
+ }
+ }
+
+ //Grab currently playing media
+ const nowPlaying = client.player.mediaHandler.nowPlaying;
+
+ //If we don't have a good stale entry to re-use
+ if(staleEntry == null || staleEntry.dataset.uuid != nowPlaying.uuid){
+ //If it wasn't null but just old
+ if(staleEntry != null){
+ //Nukem
+ staleEntry.remove();
+ }
+
+ //Create entry div
+ const entryDiv = document.createElement('div');
+ entryDiv.classList.add('queue-entry', 'live');
+
+ //Iterate through nowPlaying's properties
+ for(let key of Object.keys(nowPlaying)){
+ //Add them to the entry div's dataset
+ entryDiv.dataset[key] = nowPlaying[key];
+ }
+
+ //Convert start epoch to JS date object
+ const started = new Date(nowPlaying.startTime);
+
+ //If this started today
+ if(utils.isSameDate(this.day, started)){
+ //Set entryDiv top-border location based on start time
+ entryDiv.style.top = `${this.offsetByDate(started)}px`
+ }else{
+ //Get dawn
+ const dawn = new Date();
+ dawn.setHours(0,0,0,0)
+
+ //Place item at the beginning of dawn
+ entryDiv.style.top = `${this.offsetByDate(dawn)}px`;
+
+ //Apply rest of the styling rules for items that started yestarday
+ entryDiv.classList.add('started-yesterday')
+ }
+
+ //Create entry title
+ const entryTitle = document.createElement('p');
+ entryTitle.textContent = utils.unescapeEntities(nowPlaying.title);
+
+ //Set entry div bottom-border location based on current time, round to match time marker
+ entryDiv.style.bottom = `${Math.round(this.offsetByDate(date, true))}px`
+
+ //Assembly entryDiv
+ entryDiv.appendChild(entryTitle);
+
+ //Setup media tooltip
+ entryDiv.addEventListener('mouseenter',(event)=>{
+ //Construct tooltip on mouseover to re-calculate duration for live media
+ const tooltipDiv = buildTooltip();
+
+ //Display tooltip
+ utils.ux.displayTooltip(event, tooltipDiv, false, null, true, this.ownerDoc);
+ });
+
+ const menuMap = new Map([
+ ["Stop", ()=>{this.client.socket.emit('stop');}],
+ ["Delete", ()=>{this.client.socket.emit('delete', {uuid: nowPlaying.uuid});}],
+ ["Open in New Tab", ()=>{window.open(nowPlaying.url, '_blank').focus();}],
+ ["Copy URL", ()=>{navigator.clipboard.writeText(nowPlaying.url);}],
+
+ ]);
+
+ //Setup context menu
+ entryDiv.addEventListener('contextmenu', (event)=>{
+ //Display context menu
+ utils.ux.displayContextMenu(event, '', menuMap, this.ownerDoc);
+ });
+
+ //Append entry div to queue container
+ this.queueContainer.appendChild(entryDiv);
+ }else{
+ //Update existing entry, round offset to match time marker
+ staleEntry.style.bottom = `${Math.round(this.offsetByDate(date, true))}px`
+ }
+
+ //Keep tooltip date seperate so it re-calculates live duration properly
+ function buildTooltip(date = new Date()){
+ //Tooltip generation
+ //tooltip div
+ const tooltipDiv = document.createElement('div');
+ tooltipDiv.classList.add('media-tooltip');
+
+ //tooltip components
+ const tooltipStrings = [
+ `Title: ${nowPlaying.title}`,
+ `File Name: ${nowPlaying.fileName}`,
+ `Source: HLS Livestream (${nowPlaying.url})`,
+ `Duration: ${utils.ux.humieFriendlyDuration((date.getTime() - nowPlaying.startTime) / 1000)}`,
+ `Start Time: ${new Date(nowPlaying.startTime).toLocaleString()}`,
+ ];
+
+ //For each string in the tooltip
+ for(const string of tooltipStrings){
+
+ //Create a 'p' node
+ const component = document.createElement('p');
+ //Fill it with the string
+ component.textContent = utils.unescapeEntities(string);
+
+ //Append it to the tooltip div
+ tooltipDiv.append(component);
+ }
+
+ //Create End Date
+ const source = document.createElement('p');
+ const liveSpan = document.createElement('span');
+
+ //Fill end date label
+ source.textContent = "End Time: "
+ liveSpan.textContent = 'LIVE'
+
+ //Set class
+ liveSpan.classList.add('critical-danger-text');
+
+ //Assemble end date
+ source.appendChild(liveSpan);
+ tooltipDiv.appendChild(source);
+
+ return tooltipDiv;
+ }
+ }
+
+ /**
+ * Calculate schedule offset from top or bottom of div from date
+ * @param {Date} date - Date to calculate from, defaults to now
+ * @param {Boolean} bottomOffset - Whether or not offset should be calculated from top or bottom
+ * @returns {Number} Offset from top/bottom of div in PX relative to time markers, calculated from given date
+ */
+ offsetByDate(date = new Date(), bottomOffset = false){
+ //Pull start of day epoch from given date, make sure to use a new date object so we don't fuck up any date objects passed to us
+ const dayEpoch = new Date(date).setHours(0,0,0,0);
+ //Get difference between now and day epoch to get time since the start of the current day in milliseconds
+ const curTime = date.getTime() - dayEpoch;
+
+ //Calculate the offset from todays milliseconds
+ return this.offsetByMilliseconds(curTime, bottomOffset);
+ }
+
+ /**
+ * Calculates date by offset pixels relative to schedule div top
+ * @param {Number} input - Pixels date is away from schedule div top
+ * @returns {Date} Date object representing date which was calculated from given pixel offset
+ */
+ dateByOffset(input = 0){
+ //Get markers
+ const markers = this.panelDocument.querySelectorAll('span.queue-marker');
+ //get range of position from top to bottom marker
+ const range = [markers[0].offsetTop, markers[markers.length - 1].offsetTop]
+ //get max offset relative to markers
+ const offsetMax = range[1] - range[0];
+
+ //input offset + relative difference between top marker and parent devided by offset max
+ //save as 'float' between 0 and 1
+ const relativeInput = ((input - range[0]) / offsetMax);
+
+ //Get the currently viewed day
+ const date = new Date(this.day);
+
+ //Convert our 'float' from 0-1 to a time between 0-24
+ date.setHours(0,0,0,relativeInput * 86400000);
+
+ //return our date
+ return date;
+ }
+
+ /**
+ * Calculate schedule offset from top or bottom of div from JS Epoch (millis)
+ * @param {Number} input - JS Epoch (millis) to calculate from, defaults to 0
+ * @param {Boolean} bottomOffset - Whether or not offset should be calculated from top or bottom
+ * @returns {Number} Offset from top/bottom of div in PX relative to time markers, calculated from given date
+ */
+ offsetByMilliseconds(input = 0, bottomOffset = false){
+ //Convert amount of milliseconds to a float, 0 representing the start of the day and 1 representing the end.
+ //Make sure to reverse it if bottomOffset is set to true
+ const timeFloat = bottomOffset ? 1 - (input / 86400000) : input / 86400000;
+
+ //Get queue markers
+ const markers = this.panelDocument.querySelectorAll('span.queue-marker');
+
+ //If the marker is null for some reason
+ if(markers[0] == null){
+
+ return null;
+ }
+
+ //Get marker position range
+ const range = [markers[0].offsetTop, markers[markers.length - 1].offsetTop]
+ //Get maximum position relative to the range itself
+ const offsetMax = range[1] - range[0];
+ //return position relative to parent
+ return (offsetMax * timeFloat) + range[0];
+ }
+
+ /**
+ * Returns media end time
+ * @param {Object} media - media object to get end time from
+ * @returns {Number} Media end time as JS Epoch (millis)
+ */
+ getMediaEnd(media){
+ //If we have an early end
+ if(media.earlyEnd != null){
+ return media.startTime + (media.earlyEnd * 1000);
+ //Otherwise
+ }else{
+ return media.startTime + ((media.duration - media.startTimeStamp) * 1000);
+ }
+ }
+}
+
+/**
+ * Class representing pop-up dialogue to schedule a piece of media
+ */
+class schedulePopup{
+ /**
+ * Instantiates a new schedule media Pop-up
+ * @param {Event} event - Event passed down from Event Listener
+ * @param {channel} client - Parent Client Management Object
+ * @param {String} url - URL/link to media to queue
+ * @param {String} title - Title of media to queue
+ * @param {Function} cb - Callback function, passed upon pop-up creation
+ * @param {Document} doc - Current owner documnet of the panel, so we know where to drop our pop-up
+ */
+ constructor(event, client, url, title, cb, doc){
+ /**
+ * Parent Client Management Object
+ */
+ this.client = client;
+
+ /**
+ * URL/Link to media to queue
+ */
+ this.url = url;
+
+ /**
+ * Title of media to queue
+ */
+ this.title = title;
+
+ /**
+ * Callback function, passed upon pop-up creation
+ */
+ 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 :(
+ /**
+ * canopyUXUtils.popup() object
+ */
+ this.popup = new canopyUXUtils.popup('/scheduleMedia', true, this.asyncConstructor.bind(this), doc);
+ }
+
+ /**
+ * Continuation of object construction, called after child popup object construction
+ */
+ asyncConstructor(){
+ //Grab required UI elements
+ this.scheduleButton = this.popup.contentDiv.querySelector('#schedule-media-popup-schedule-button');
+ this.datePrompt = this.popup.contentDiv.querySelector('#schedule-media-popup-time-prompt');
+
+ //getCurrentDate
+ const curDate = new Date();
+ //Zero out time to the nearest minute
+ curDate.setSeconds(0,0);
+ //Set the date prompt to the next minute, adjusted to display local time
+ this.datePrompt.valueAsDate = utils.ux.localizeDate(curDate);
+
+ //Setup input
+ this.setupInput();
+
+ //If we have a function
+ if(typeof cb == "function"){
+ //Call any callbacks we where given
+ this.cb();
+ }
+ }
+
+ /**
+ * Defines input-related Event Handlers
+ */
+ setupInput(){
+ //Setup input
+ this.scheduleButton.addEventListener('click', this.schedule.bind(this));
+ this.popup.popupDiv.addEventListener('keydown', this.schedule.bind(this));
+ }
+
+ /**
+ * Handles sending request to schedule item to the queue
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ schedule(event){
+ //If we clicked or hit enter
+ if(event.key == null || event.key == "Enter"){
+ //Get localized input date
+ const inputDate = utils.ux.normalizeDate(this.datePrompt.valueAsDate);
+
+ //Tell the server to move the media
+ this.client.socket.emit("queue",{url: this.url, title: this.title, start: inputDate.getTime()});
+
+ //Close the popup
+ this.popup.closePopup();
+ }
+ }
+}
+
+/**
+ * Class representing pop-up dialogue for reschedule queue media
+ * @extends schedulePopup
+ */
+class reschedulePopup extends schedulePopup{
+ /**
+ * Instantiates a new schedule media Pop-up
+ * @param {Event} event - Event passed down from Event Listener
+ * @param {channel} client - Parent Client Management Object
+ * @param {Object} media - Media object to re-schedule
+ * @param {Function} cb - Callback function, passed upon pop-up creation
+ * @param {Document} doc - Current owner documnet of the panel, so we know where to drop our pop-up
+ */
+ constructor(event, client, media, cb, doc){
+ //Call derived constructor
+ super(event, client, null, null, cb, doc);
+
+ /**
+ * Media Object to Re-Schedule
+ */
+ this.media = media;
+ }
+
+ schedule(event){
+ //If we clicked or hit enter
+ if(event.key == null || event.key == "Enter"){
+ //Get localized input date
+ const inputDate = utils.ux.normalizeDate(this.datePrompt.valueAsDate);
+
+ //Tell the server to move the media
+ this.client.socket.emit('move', {uuid: this.media.uuid, start: inputDate.getTime()});
+
+ //Close the popup
+ this.popup.closePopup();
+ }
+ }
+}
+
+/**
+ * Class represneting pop-up dialogue for clearing queue between a range of two dates
+ */
+class clearPopup{
+ /**
+ * Instantiates a new queue Clear Popup
+ * @param {Event} event - Event passed down from Event Listener
+ * @param {channel} client - Parent Client Management Object
+ * @param {Function} cb - Callback function, passed upon pop-up creation
+ * @param {Document} doc - Current owner documnet of the panel, so we know where to drop our pop-up
+ */
+ constructor(event, client, cb, doc){
+ /**
+ * Parent Client Management Object
+ */
+ this.client = client;
+
+ /**
+ * Callback function, passed upon pop-up creation
+ */
+ 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 :(
+ /**
+ * canopyUXUtils.popup() object
+ */
+ this.popup = new canopyUXUtils.popup('/clearMedia', true, this.asyncConstructor.bind(this), doc);
+ }
+
+ /**
+ * Continuation of object construction, called after child popup object construction
+ */
+ asyncConstructor(){
+ //Grab required UI elements
+ this.clearButton = this.popup.contentDiv.querySelector('#clear-media-popup-clear-button');
+ this.startDatePrompt = this.popup.contentDiv.querySelector('#clear-media-popup-start-time-prompt');
+ this.endDatePrompt = this.popup.contentDiv.querySelector('#clear-media-popup-end-time-prompt');
+
+ //getCurrentDate
+ const curDate = new Date();
+ //Zero out current time to the nearest minute
+ curDate.setSeconds(0,0);
+ //Set the start date prompt to the next minute, adjusted to display local time
+ this.startDatePrompt.valueAsDate = utils.ux.localizeDate(curDate);
+ //Add 30 minutes
+ curDate.setMinutes(curDate.getMinutes() + 30);
+ //Set the end date prompt to 30 minutes in the futre, adjusted to display local time
+ this.endDatePrompt.valueAsDate = utils.ux.localizeDate(curDate);
+
+ //Setup input
+ this.setupInput();
+
+ //If we have a function
+ if(typeof cb == "function"){
+ //Call any callbacks we where given
+ this.cb();
+ }
+ }
+
+ /**
+ * Defines input-related Event Handlers
+ */
+ setupInput(){
+ //Setup input
+ this.clearButton.addEventListener('click', this.clear.bind(this));
+ this.popup.popupDiv.addEventListener('keydown', this.clear.bind(this));
+ }
+
+ /**
+ * Handles sending request to clear playlist between two dates to the server
+ * @param {Event} event - Event passed down from Event Listener
+ */
+ clear(event){
+ //If we clicked or hit enter
+ if(event.key == null || event.key == "Enter"){
+ //Get localized input date
+ const inputStartDate = utils.ux.normalizeDate(this.startDatePrompt.valueAsDate);
+ const inputEndDate = utils.ux.normalizeDate(this.endDatePrompt.valueAsDate);
+
+ //Tell the server to clear media between the input range
+ this.client.socket.emit("clear",{start: inputStartDate.getTime(), end: inputEndDate.getTime()});
+
+ //Close the popup
+ this.popup.closePopup();
+ }
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/doc/client/panels_settingsPanel.js.html b/www/doc/client/panels_settingsPanel.js.html
index 6b2601f..be0bdba 100644
--- a/www/doc/client/panels_settingsPanel.js.html
+++ b/www/doc/client/panels_settingsPanel.js.html
@@ -97,13 +97,13 @@ class settingsPanel extends panelObj{
diff --git a/www/doc/client/player.html b/www/doc/client/player.html
index 89d5b92..010c7aa 100644
--- a/www/doc/client/player.html
+++ b/www/doc/client/player.html
@@ -3368,13 +3368,13 @@ Might seem weird to keep this here instead of the HLS handler, but remember we m
diff --git a/www/doc/client/player.js.html b/www/doc/client/player.js.html
index 02bacb1..433f762 100644
--- a/www/doc/client/player.js.html
+++ b/www/doc/client/player.js.html
@@ -468,13 +468,13 @@ class player{
diff --git a/www/doc/client/playlistManager.html b/www/doc/client/playlistManager.html
index 9f37da6..c5d099d 100644
--- a/www/doc/client/playlistManager.html
+++ b/www/doc/client/playlistManager.html
@@ -141,7 +141,7 @@