/*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
${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
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('
');
//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