Source: panels/queuePanel/queuePanel.js

/*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();
        }
    }
}