From 179a10fb720b09852fba02db7ae91ffc1e50404c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 9 Feb 2025 17:29:07 -0500 Subject: [PATCH] Added proper handling of items that begin late and end early. --- src/app/channel/media/queue.js | 140 +++++++++++++++++++----- src/app/channel/media/queuedMedia.js | 27 ++++- www/css/theme/movie-night.css | 22 +++- www/js/channel/panels/queuePanel.js | 152 +++++++++++++++++++++------ www/js/utils.js | 14 ++- 5 files changed, 286 insertions(+), 69 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 987a722..1b14147 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -51,6 +51,7 @@ module.exports = class{ defineListeners(socket){ socket.on("queue", (data) => {this.queueURL(socket, data)}); + socket.on("stop", (data) => {this.stopMedia(socket)}); socket.on("delete", (data) => {this.deleteMedia(socket, data)}); socket.on("move", (data) => {this.moveMedia(socket, data)}); socket.on("clear", (data) => {this.deleteRange(socket, data)}); @@ -108,19 +109,7 @@ module.exports = class{ loggerUtils.socketErrorHandler(socket, "No media found!", "queue"); //and ignore it! return; - } - - //If we have an invalid time - if(start == null || start < (new Date).getTime()){ - //Get last item from schedule - const lastItem = (Array.from(this.schedule)[this.schedule.size - 1]); - - //if we have a last item - if(lastItem != null){ - //Throw it on five ms after the last item - start = lastItem[1].startTime + (lastItem[1].duration * 1000) + 5; - } - } + } //Queue the first media object given this.queueMedia(mediaList[0], start, socket); @@ -143,7 +132,6 @@ module.exports = class{ //and ignore it! return; } - //If end time isn't an integer if(data.end != null && !validator.isInt(String(data.end))){ //Bitch, moan, complain... @@ -195,7 +183,7 @@ module.exports = class{ return; } - //If start time isn't an integer after the current epoch + //If start time isn't an integer if(data.start != null && !validator.isInt(String(data.start))){ //Null out time to tell the later parts of the function to start it now data.start = undefined; @@ -224,9 +212,32 @@ module.exports = class{ } //Default start time to now + half a second to give everyone time to process shit - queueMedia(inputMedia, start = new Date().getTime() + 50, socket){ + queueMedia(inputMedia, start, socket){ + //If we have an invalid time + if(start == null || start < (new Date).getTime()){ + //Get last item from schedule + const lastItem = (Array.from(this.schedule)[this.schedule.size - 1]); + + const now = new Date().getTime() + + //if we have a last item + if(lastItem != null){ + //If the last item has ended + if(lastItem[1].getEndTime() < now){ + start = now + 5; + //If it hasn't started yet + }else{ + //Throw it on five ms after the last item + start = lastItem[1].getEndTime() + 5; + } + }else{ + //Throw it on five ms after the last item + start = now + 5; + } + } + //Create a new media queued object, set start time to now - const mediaObj = queuedMedia.fromMedia(inputMedia, start); + const mediaObj = queuedMedia.fromMedia(inputMedia, start, 0); //schedule the media this.scheduleMedia(mediaObj, socket); @@ -244,7 +255,7 @@ module.exports = class{ //If we have a current item and it isn't currently playing if(currentItem != null && (this.nowPlaying == null || currentItem.uuid != this.nowPlaying.uuid)){ //Start the found item at w/ a pre-calculated time stamp to reflect the given start time - this.start(currentItem, Math.round((new Date().getTime() - currentItem.startTime) / 1000)); + this.start(currentItem, Math.round((new Date().getTime() - currentItem.startTime) / 1000) + currentItem.startTimeStamp); } //otherwise if we have an item }else{ @@ -254,7 +265,7 @@ module.exports = class{ //Clear out any item that might be up next clearTimeout(this.nextTimer); //Set the next timer - this.nextTimer = setTimeout(()=>{this.start(nextItem)}, startsIn); + this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp)}, startsIn); } } @@ -269,9 +280,9 @@ module.exports = class{ } } - rescheduleMedia(uuid, start = new Date().getTime() + 50, socket){ - //Find and remove media from the schedule by UUID - const media = this.removeMedia(uuid); + rescheduleMedia(uuid, start = new Date().getTime() + 5, socket){ + //Find our media, don't remove it yet since we want to do some more testing first + const media = this.getItemByUUID(uuid); //If we got a bad request if(media == null){ @@ -284,20 +295,50 @@ module.exports = class{ return; } + //If someone is trying to re-schedule something that starts in the past + if(media.startTime < new Date().getTime()){ + //If an originating socket was provided for this request + if(socket != null){ + + //If the item is currently playing + if(media.getEndTime() > new Date().getTime()){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "You cannot move an actively playing video!", "queue"); + //Otherwise, if it's already ended + }else{ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "You cannot alter the past!", "queue"); + } + } + + + //Ignore it + return; + } + + //Remove the media from the schedule + this.removeMedia(uuid); + //Grab the old start time for safe keeping const oldStart = media.startTime; //Set media time media.startTime = start; + //Reset the start time stamp for re-calculation + media.startTimeStamp = 0; + //Attempt to schedule media at given time //Otherwise, if it returns false for fuckup if(!this.scheduleMedia(media, socket)){ //Reset start time media.startTime = oldStart; + //Reset the start time stamp for re-calculation + media.startTimeStamp = 0; + //Schedule in old slot - this.scheduleMedia(media, socket); + this.scheduleMedia(media, socket, true); } } @@ -335,7 +376,33 @@ module.exports = class{ return media; } - scheduleMedia(mediaObj, socket){ + stopMedia(socket){ + //If we're not currently playing anything + if(this.nowPlaying == null){ + //If an originating socket was provided for this request + if(socket != null){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "No media playing!", "queue"); + } + + //Ignore it + return false; + } + + //Stop playing + const stoppedMedia = this.nowPlaying; + + //End the media + this.end(); + + //Get difference between current time and start time and set as early end + stoppedMedia.earlyEnd = (new Date().getTime() - stoppedMedia.startTime) / 1000; + + //Broadcast the channel queue + this.broadcastQueue(); + } + + scheduleMedia(mediaObj, socket, force = false){ /* This is a fun method and I think it deserves it's own little explination... Since we're working with a time based schedule, using start epochs as keys for our iterable seemed the best option I don't want to store everything in a sparse array because that *feels* icky, and would probably be a pain in the ass. @@ -364,8 +431,26 @@ module.exports = class{ https://community.appsmith.com/content/blog/dark-side-foreach-why-you-should-think-twice-using-it */ + //If someone is trying to schedule something that starts and ends in the past + if((mediaObj.getEndTime() < new Date().getTime()) && !force){ + //If an originating socket was provided for this request + if(socket != null){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "You cannot alter the past!", "queue"); + } + return false; + } + + //If the item has already started and it's not being forced + if((mediaObj.startTime < new Date().getTime())){ + //Set time stamp to existing timestamp plus the difference between the orginal start-date and now + mediaObj.startTimeStamp = mediaObj.startTimeStamp + ((new Date().getTime() - mediaObj.startTime) / 1000) + //Start the item now + mediaObj.startTime = new Date().getTime() + 5; + } + //If there's already something queued right now - if(this.getItemAtEpoch(mediaObj.startTime) != null || this.getItemAtEpoch(mediaObj.startTime + (mediaObj.duration * 1000))){ + if(this.getItemAtEpoch(mediaObj.startTime) != null || this.getItemAtEpoch(mediaObj.getEndTime())){ //If an originating socket was provided for this request if(socket != null){ //Yell at the user for being an asshole @@ -373,8 +458,7 @@ module.exports = class{ } //Ignore it return false; - } - + } //Create an empty temp array to sparsley populate with our schedule const tempSchedule = []; @@ -409,7 +493,7 @@ module.exports = class{ return mediaObj; } - start(mediaObj, timestamp = 0){ + start(mediaObj, timestamp = mediaObj.startTimeStamp){ //Silently end the media this.end(true); diff --git a/src/app/channel/media/queuedMedia.js b/src/app/channel/media/queuedMedia.js index 71525af..99b6a5a 100644 --- a/src/app/channel/media/queuedMedia.js +++ b/src/app/channel/media/queuedMedia.js @@ -18,11 +18,15 @@ along with this program. If not, see .*/ const media = require('./media'); module.exports = class extends media{ - constructor(title, fileName, url, id, type, duration, startTime){ + constructor(title, fileName, url, id, type, duration, startTime, startTimeStamp){ //Call derived constructor super(title, fileName, url, id, type, duration); //Set media start time this.startTime = startTime; + //Set the media start time stamp + this.startTimeStamp = startTimeStamp; + //Create empty variable to hold early end if media is stopped early + this.earlyEnd = null; //Generate id unique to this specific entry of this specific file within this specific channel's queue //That way even if we have six copies of the same video queued, we can still uniquely idenitify each instance @@ -30,9 +34,17 @@ module.exports = class extends media{ } //statics - static fromMedia(media, startTime){ + static fromMedia(media, startTime, startTimeStamp){ //Create and return queuedMedia object from given media object and arguments - return new this(media.title, media.fileName, media.url, media.id, media.type, media.duration, startTime); + return new this( + media.title, + media.fileName, + media.url, + media.id, + media.type, + media.duration, + startTime, + startTimeStamp); } //methods @@ -41,6 +53,13 @@ module.exports = class extends media{ } getEndTime(){ - return this.startTime + (this.duration * 1000); + //If we have an early ending + if(this.earlyEnd == null){ + //Calculate our ending + return this.startTime + ((this.duration - this.startTimeStamp) * 1000); + }else{ + //Return our early end + return this.startTime + (this.earlyEnd * 1000); + } } } \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index f40706a..d555679 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -505,18 +505,36 @@ div.queue-entry{ background-color: var(--bg2-alt1); } -div.started-before-today{ +div.started-yesterday{ border-top-left-radius: 0; border-top-right-radius: 0; border-top: 1px dashed var(--accent1); } -div.ends-after-today{ +div.ends-tomorrow{ border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 1px dashed var(--accent1); } +div.started-late{ + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 1px dashed var(--danger0-alt1); +} + +div.ended-early{ + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 1px dashed var(--danger0-alt1); +} + +div.now-playing{ + color: var(--focus0); + box-shadow: var(--focus-glow0); + text-shadow: var(--focus-glow0); +} + /* altcha theming*/ div.altcha{ box-shadow: 4px 4px 1px var(--bg1-alt0) inset; diff --git a/www/js/channel/panels/queuePanel.js b/www/js/channel/panels/queuePanel.js index e294e97..65349ab 100644 --- a/www/js/channel/panels/queuePanel.js +++ b/www/js/channel/panels/queuePanel.js @@ -83,6 +83,7 @@ class queuePanel extends panelObj{ this.client.socket.on("clientMetadata", (data) => {this.renderQueue();}); this.client.socket.on("queue", (data) => {this.renderQueue();}); this.client.socket.on("lock", this.handleScheduleLock.bind(this)); + this.client.socket.on("error", this.handleQueueError.bind(this)); } setupInput(){ @@ -123,6 +124,26 @@ class queuePanel extends panelObj{ } } + 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 */ toggleAddMedia(event){ //If the div is hidden @@ -491,13 +512,13 @@ class queuePanel extends panelObj{ entryDiv.style.top = `${this.offsetByDate(dawn)}px`; //Run apply the rest of the styling rules - entryDiv.classList.add('started-before-today'); + 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(entry[1].startTime + (entry[1].duration * 1000)), true)}px`; + entryDiv.style.bottom = `${this.offsetByDate(new Date(this.getMediaEnd(entry[1])), true)}px`; }else{ //Get midnight const dusk = new Date(); @@ -507,7 +528,17 @@ class queuePanel extends panelObj{ entryDiv.style.bottom = `${this.offsetByDate(dusk, true)}px`; //Run apply the rest of the styling rules - entryDiv.classList.add('ends-after-today'); + 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 @@ -526,8 +557,8 @@ class queuePanel extends panelObj{ `File Name: ${entry[1].fileName}`, `Source: ${entry[1].type}`, `Duration: ${entry[1].duration}`, - `Start Time: ${new Date(entry[1].startTime).toLocaleString()}`, - `End Time: ${new Date(entry[1].startTime + (entry[1].duration * 1000)).toLocaleString()}` + `Start Time: ${new Date(entry[1].startTime).toLocaleString()}${entry[1].startTimeStamp == 0 ? '' : ' (Started Late)'}`, + `End Time: ${new Date(this.getMediaEnd(entry[1])).toLocaleString()}` ]){ //Create a 'p' node const component = document.createElement('p'); @@ -546,14 +577,29 @@ class queuePanel extends panelObj{ } }); - //context menu - const menuMap = new Map([ - ["Play now", ()=>{this.client.socket.emit('move', {uuid: entry[1].uuid})}], - ["Move To...", (event)=>{new reschedulePopup(event, this.client, entry[1], null, this.ownerDoc)}], - ["Delete", ()=>{this.client.socket.emit('delete', {uuid: entry[1].uuid})}], - ["Open in New Tab", ()=>{window.open(entry[1].url, '_blank').focus();}], - ["Copy URL", ()=>{navigator.clipboard.writeText(entry[1].url);}], - ]); + //Create context menu map + const menuMap = new Map(); + + //If the item hasn't started yet + if(entry[1].startTime > new Date().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 + }else if(this.getMediaEnd(entry[1]) > new Date().getTime()){ + //Add 'Stop' option to context menu + menuMap.set("Stop", ()=>{this.client.socket.emit('stop', {uuid: entry[1].uuid})}); + //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'); + } + + //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);}) //Setup drag n drop entryDiv.addEventListener('mousedown', clickEntry.bind(this)); @@ -582,8 +628,29 @@ class queuePanel extends panelObj{ return; } - //Create variables to hold width and height - const height = event.target.offsetHeight; + //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 == ""){ @@ -611,7 +678,7 @@ class queuePanel extends panelObj{ 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; + 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); @@ -711,7 +778,7 @@ class queuePanel extends panelObj{ function dropEntry(event, target, timetip){ //Gross but works :P - if(!target.isConnected){ + if(!target.isConnected || target.dataset['drag'] != "true"){ return; } @@ -861,24 +928,9 @@ class queuePanel extends panelObj{ 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; - //Devide by amount of milliseconds in a day to convert time over to a floating point number between 0 and 1 - //Make sure to reverse it if bottomOffset is set to true - const timeFloat = bottomOffset ? 1 - (curTime / 86400000) : curTime / 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]; + //Calculate the offset from todays milliseconds + return this.offsetByMilliseconds(curTime, bottomOffset); } dateByOffset(input = 0){ @@ -902,6 +954,38 @@ class queuePanel extends panelObj{ //return our date return 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]; + } + + 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 schedulePopup{ diff --git a/www/js/utils.js b/www/js/utils.js index d2dab7e..63c1ac0 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -553,7 +553,19 @@ class canopyUXUtils{ } - fixCutoff(standalone = true, pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width){ + fixCutoff(standalone = true, pageBreak){ + //If we have no pagebreak + if(pageBreak == null){ + //If we have a document body + if(document.body != null){ + pageBreak = document.body.scrollWidth - document.body.getBoundingClientRect().width + //Otherwise + }else{ + //Pretend nothing happened because we probably have bigger issues then a fucked up click-dragger cutoff + return; + } + } + //Fix the page width if(this.flex){ this.element.style.flexBasis = `${this.calcWidth(this.element.getBoundingClientRect().width + pageBreak)}vw`;