From ee3b942642371826ff0ec335fdd35ae9ad60a7f2 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 00:10:28 -0700 Subject: [PATCH 1/9] Create JukeboxPlus.js --- JukeboxPlus/JukeboxPlus.js | 2125 ++++++++++++++++++++++++++++++++++++ 1 file changed, 2125 insertions(+) create mode 100644 JukeboxPlus/JukeboxPlus.js diff --git a/JukeboxPlus/JukeboxPlus.js b/JukeboxPlus/JukeboxPlus.js new file mode 100644 index 000000000..3504dc705 --- /dev/null +++ b/JukeboxPlus/JukeboxPlus.js @@ -0,0 +1,2125 @@ +var API_Meta = API_Meta || {}; +API_Meta.JukeboxPlus = { + offset: Number.MAX_SAFE_INTEGER, + lineCount: -1 +}; { + try { + throw new Error(''); + } catch (e) { + API_Meta.JukeboxPlus.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (4)); + } +} + + +// Jukebox Plus Plus (Fully Enhanced UI with Album/Playlist Toggle, Track Tagging, and Layout Fixes) +on('ready', () => +{ + + const version = '1.0.0'; //version number set here + log('-=> Jukebox Plus v' + version + ' is loaded. Command !jb creates control handout and provides link. Click that to open.'); + + const HANDOUT_NAME = 'Jukebox Plus'; + const STATE_KEY = 'GraphicJukebox'; + + if(!state[STATE_KEY]) + { + state[STATE_KEY] = { + tracks: + {}, + albumSortOrder: + {}, + albums: + {}, + playlists: + {}, + rollbacks: [], + settings: + { + notifyOnPlay: 'on', + selectedAlbum: '', + selectedPlaylist: '', + viewMode: 'albums', + settingsExpanded: false, + nowPlayingOnly: false, + mode: 'dark', + helpVisible: false + } + }; + } + + + +// ✅ Ensure mixSession is present (without disrupting existing state) +if (!state[STATE_KEY].mixSession) { + state[STATE_KEY].mixSession = { + active: false, + loopIds: [], + randomIds: [], + timeoutId: null + }; +} + + // Declare once, top level within ready + const data = state[STATE_KEY]; + + // Define icon sets for each theme + const iconSetDark = { + play: 'https://files.d20.io/images/446752945/1lxeyU7yN1vPWXcrc3lFng/original.png?1751143927', + playActive: 'https://files.d20.io/images/446801469/hLU0ilPulBMcR2xBMFCYEQ/original.png?1751166667', + loop: 'https://files.d20.io/images/446752941/AJY4BveyKRfOvPPHGsY7jw/original.png?1751143926', + loopActive: 'https://files.d20.io/images/446801468/hJcBoRBqDlXqrJ5sSs69gA/original.png?1751166667', + isolate: 'https://files.d20.io/images/446752943/0YxEtYa40ld2L2qbLua07w/original.png?1751143927', + stop: 'https://files.d20.io/images/446752946/Jei3DhJjtd7AcQEMLoT2JQ/original.png?1751143927' + }; + + const iconSetLight = { + play: 'https://files.d20.io/images/446909842/EKV5MVZ4yWtPPahgW-yyxQ/original.png?1751231236', + playActive: 'https://files.d20.io/images/446801469/hLU0ilPulBMcR2xBMFCYEQ/original.png?1751166667', + loop: 'https://files.d20.io/images/446909844/RcZX7CnmpX_-_qeKrfr3ZQ/original.png?1751231236', + loopActive: 'https://files.d20.io/images/446909844/RcZX7CnmpX_-_qeKrfr3ZQ/original.png?1751231236', + isolate: 'https://files.d20.io/images/446909843/6IxkbARljNyoN78s26mLQg/original.png?1751231236', + stop: 'https://files.d20.io/images/446909850/AseQXEd16Xa77lPI2Hdeaw/original.png?1751231238' + }; + + // Define both style sets + const cssLight = { + // Layout Containers + sidebar: 'font-family: Nunito, Arial, sans-serif; background:#f5f5f5; vertical-align:top; padding:6px; border-right:1px solid #ccc; width:215px;', + tracklist: 'font-family: Nunito, Arial, sans-serif; padding:8px; vertical-align:top; width:100%; background:#ffffff;', + toggleWrap: 'display:block; margin-bottom:8px;width:160px;', + //deprecated + //tracklistScroll: 'max-height:600px !important; overflow-y: scroll; overflow-x: hidden;', + + // Header and Title + header: 'font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px; color:#222; background:#aaa; border-bottom:1px solid #ccc;', + gear: 'float:right; cursor:pointer; color:#666;', + trackCount: 'color:#666; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', + + // Buttons & Controls + button: 'display:block; margin-bottom:4px; width:100%; font-size:11px; background:#e0e0e0; color:#333; border:1px solid #bbb;', + utilityContainer: 'width:90%; font-size:12px; padding:4px 6px; background:#ddd; color:#333; border:1px solid #bbb; border-radius:4px; margin-top:6px; position:relative;', + utilitySubButton: 'font-size:11px; padding:1px 5px; background:#aaa; color:#333; border:1px solid #999; border-radius:3px; margin:-1px -1px 0px 3px; float:right; text-decoration:none;', + utilityButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:#ddd; color:#222; border:1px solid #bbb; border-radius:4px; text-align:center; margin-top:6px; text-decoration:none;', + settingsButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:transparent; color:#333; text-align:center; margin-top:6px; text-decoration:none;', + + // *** Updated header buttons to match cssDark measurements but cssLight colors from utility buttons *** + headerButtonContainer: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; border:1px solid #bbb; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#333;', + headerButton: 'float:right; font-size:12px; padding:4px 6px; border:1px solid #bbb; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#222;', + headerSubButton: 'font-size:11px; padding:1px 6px; border:1px solid #999; border-radius:3px; text-decoration:none; margin-top:-2px; background:#aaa; color:#333;', + headerSubButtonAcive: 'font-size:11px; padding:1px 6px; border:1px solid #999; border-radius:3px; text-decoration:none; margin-top:-2px; background:#C27575; color:#333;', + + nowPlayingButton: 'color:#444; padding:2px 4px; display:block; text-decoration:none; background:#eee; border-radius:4px; margin-top:6px;', + refreshButton: 'font-size:10px; margin-top:8px; display:block; color:#0066cc; text-decoration:underline; cursor:pointer;', + + //announce styles + announceButton: 'color:#888; font-size:10px; padding:0px 4px; display:inline-block; text-decoration:none; margin-top:4px;', + announceTitle: 'display:inline-block; font-size:16px; rexr-align:center; font-weight:bold; color:#333; margin-top:4px;', + announceDesc: 'margin-top:4px; font-size:11px; color:#555; line-height:15px;', + + // Sidebar Links & Rules + sidebarRule: 'border:0; border-top:1px solid #ccc; margin:20px 0 3px 0;', + sidebarLink: 'color:#444; padding:2px 4px; display:block; text-decoration:none;', + albumSelectedLink: 'background:#c22929; color:#fff; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + playlistSelectedLink: 'background:#2d5da6; color:#fff; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + + // Album/Playlist Tags + tags: 'margin-top:4px; margin-left:38px; display:block;', + albumTag: 'display:inline-block; background:#c22929; color:#fff; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + playlistTag: 'display:inline-block; background:#2d5da6; color:#fff; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + tagRemove: 'color:#fff; margin-left:2px; cursor:pointer;', + + // Toggle Buttons + toggleButton: 'display:inline-block; width:45%; padding:6px 0; font-weight:bold; border:1px solid #bbb; border-radius:4px; text-align:center; margin-right:4px;', + toggleActiveAlbums: 'background:#c22929; color:#fff;', + toggleActivePlaylists: 'background:#2d5da6; color:#fff;', + toggleInactive: 'background:#bbb; color:#666;', + + // Message styles + messageContainer: 'font-family: Nunito, Arial, sans-serif; background-color:#ccc; color:#111; padding:10px; position:relative; top:-15px; left:-5px; border: solid 1px #555; border-radius:5px;', + messageTitle: 'padding: 3px 0px; background-color:#444; border-radius:4px; color:#ddd; font-size:16px; text-transform: capitalize; text-align:center; margin-bottom:13px;', + messageButton: 'display:inline-block; background:#aaa; color:#111; border: solid 1px #666;border-radius:4px; padding:2px 6px; margin-right:2px; vertical-align:middle;', + descHelp: 'margin-top:4px; font-size:15px; color:#222;', + + // Track Item Styles + track: 'border-bottom:1px solid #ccc; padding:6px 0; display:table; width:100%; color:#333;', + trackTitle: 'display:inline-block; font-size:18px; font-weight:bold; color:#333;', + controls: 'float:right; margin-top:-2px;', + controlButtonImg: 'width:16px; height:16px; margin: 0px 2px; vertical-align:middle; cursor:pointer;', + desc: 'margin-top:4px; font-size:13px; color:#666; margin-left:38px;', + vol: 'font-size:11px; margin-top:4px; color:#999; margin-left:108px;', + albumEditLink: 'font-size:10px; margin-left:4px; vertical-align:middle; color:#666;', + descEditLink: 'font-size:10px; color:#888; font-style:italic; margin-left:6px; cursor:pointer;', + code: 'display:inline-block; font-size:0.75em; font-family:monospace; font-weight:bold; color:222; background-color:#ddd; padding:1px 4px; margin-left:4px; border-radius:3px; user-select:none;', + + // Images + image: 'width:100px; height:100px; background:#eee; text-align:center; font-size:11px; color:#999; border:1px solid #bbb; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', + imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #bbb; margin-right:8px; float:left; display:block;', + imagePlaceholder: 'width:100px; background:#eee; color:#999; text-align:center; font-size:11px; border:1px solid #bbb; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', + + // Album specific + albumImage: 'width:80px; height:80px; object-fit:cover; border:1px solid #bbb; margin-right:8px;', + albumHeaderDesc: 'font-size:12px; color:#666;', + addAlbum: 'font-size:10px; margin-top:8px; display:block; color:#666;' +}; + + + + + const cssDark = { + // Layout Containers + sidebar: 'font-family: Nunito, Arial, sans-serif; background:#222; vertical-align:top; padding:6px; border-right:1px solid #444; width:200px;', + tracklist: 'font-family: Nunito, Arial, sans-serif; padding:8px; vertical-align:top; width:100%; background:#1e1e1e;', + toggleWrap: 'display:block; margin-bottom:8px;width:160px;', + //deprecated + //tracklistScroll: 'max-height:600px !important; overflow-y: scroll; overflow-x: hidden;', + + // Header and Title + header: 'font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px; color:#ddd; background:#2a2a2a; border-bottom:1px solid #444;', + gear: 'float:right; cursor:pointer; color:#aaa;', + trackCount: 'color:#888; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', + + // Buttons & Controls + button: 'display:block; margin-bottom:4px; width:100%; font-size:11px; background:#333; color:#ccc; border:1px solid #555;', + utilityContainer: 'width:90%; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; margin-top:6px; position:relative;', + utilitySubButton: 'font-size:11px; padding:1px 5px; background:#444; color:#ccc; border:1px solid #444; border-radius:3px; margin:-1px -1px 0px 3px; float:right; text-decoration:none;', + utilityButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-align:center; margin-top:6px; text-decoration:none;', + settingsButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:transparent; color:#ddd; text-align:center; margin-top:6px; text-decoration:none;', + headerButtonContainer: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', + headerButton: 'float:right; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', + headerSubButton: 'font-size:11px; padding:1px 6px; background:#444; color:#ddd; border:1px solid #444; border-radius:2px; text-decoration:none; margin-top:-2px;', + nowPlayingButton: 'color:#ccc; padding:2px 4px; display:block; text-decoration:none; background:#444; border-radius:4px; margin-top:6px;', + refreshButton: 'font-size:10px; margin-top:8px; display:block; color:#66aaff; text-decoration:underline; cursor:pointer;', + + //announce styles + announceButton: 'color:#888; font-size:10px; padding:0px 4px; display:inline-block; text-decoration:none; margin-top:4px;', + announceTitle: 'display:inline-block; font-size:16px; font-weight:bold; color:#ccc; margin-top:4px;', + announceDesc: 'margin-top:4px; font-size:11px; color:#aaa; line-height:15px;', + + // Sidebar Links & Rules + sidebarRule: 'border:0; border-top:1px solid #444; margin:20px 0 3px 0;', + sidebarLink: 'color:#ccc; padding:2px 4px; display:block; text-decoration:none;', + albumSelectedLink: 'background:#993333; color:#eee; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + playlistSelectedLink: 'background:#334477; color:#eee; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + + // Album/Playlist Tags + tags: 'margin-top:4px; margin-left:38px; display:block;', + albumTag: 'display:inline-block; background:#993333; color:#eee; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + playlistTag: 'display:inline-block; background:#334477; color:#eee; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + tagRemove: 'color:#eee; margin-left:2px; cursor:pointer;', + + // Toggle Buttons + toggleButton: 'display:inline-block; width:45%; padding:6px 0; font-weight:bold; border:1px solid #555; border-radius:4px; text-align:center; margin-right:4px;', + toggleActiveAlbums: 'background:#993333; color:#eee;', + toggleActivePlaylists: 'background:#334477; color:#eee;', + toggleInactive: 'background:#444; color:#aaa;', + + //Chat message Styles + messageContainer: 'font-family: Nunito, Arial, sans-serif; background-color:#222; color:#ccc; padding:10px; position:relative; top:-15px; left:-5px; Border: solid 1px #444; border-radius:5px', + messageTitle: 'color:#ddd; font-size:16px; text-transform: capitalize; text-align:center;margin-bottom:13px;', + messageButton: 'display:inline-block; background:#444; color:#ccc; border-radius:4px; padding:2px 6px; margin-right:2px; vertical-align:middle', + descHelp: 'margin-top:4px; font-size:15px; color:#eee; ', + + // Track Item Styles + track: 'border-bottom:1px solid #444; padding:6px 0; display:table; width:100%; color:#ccc;', + trackTitle: 'display:inline-block; font-size:18px; font-weight:bold; color:#ccc;margin-top:2px;', + controls: 'float:right; margin-top:-2px;', + controlButtonImg: 'width:16px; height:16px; margin: 4px 2px; vertical-align:middle; cursor:pointer;', + desc: 'margin-top:4px; font-size:13px; color:#aaa; margin-left:38px;', + vol: 'font-size:11px; margin-top:4px; color:#999; margin-left:108px;', + albumEditLink: 'font-size:10px; margin-left:4px; vertical-align:middle; color:#aaa;', + descEditLink: 'font-size:10px; color:#888; font-style:italic; margin-left:6px; cursor:pointer;', + code: 'display:inline-block; font-size:0.75em; font-family:monospace; font-weight:bold; color:eee; background-color:#444; padding:1px 4px 0px 4px; margin-left:4px; border-radius:3px; user-select:none;', + + // Images + image: 'width:100px; height:100px; background:#444; text-align:center; font-size:11px; color:#999; border:1px solid #666; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', + imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #666; margin-right:8px; float:left; display:block;', + imagePlaceholder: 'width:100px; background:#444; color:#999; text-align:center; font-size:11px; border:1px solid #666; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', + + // Album specific + albumImage: 'width:80px; height:80px; object-fit:cover; border:1px solid #666; margin-right:8px;', + albumHeaderDesc: 'font-size:12px; color:#bbb;', + addAlbum: 'font-size:10px; margin-top:8px; display:block; color:#ccc;' + }; + + + // Set active theme styles and icons based on saved mode + let css = data.settings.mode === 'light' ? cssLight : cssDark; + let icons = data.settings.mode === 'light' ? iconSetLight : iconSetDark; + +let mixTimeoutId = null; + + + + const renderHelpView = () => + { + const handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + if(!handout) return; + + const css = data.settings.mode === 'light' ? cssLight : cssDark; + +const helpHTML = ` +
+ Jukebox Plus — Help + Return to Player +
+
+ +
Getting Started
+
+Jukebox Plus lets you organize and control music tracks by albums or playlists. +Use the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control +buttons appear for each one. +
+

+ +
Header Buttons
+
+At the top right of the interface: +
+ 10 tracks +
Play All + Together + In Order + Loop + Mix +
+
Loop All + Off + On +
+ Stop All + Find +Help + +

+
+ + Play All
+
+ Together — Plays all visible tracks simultaneously. Limited to the first five visible.
+ In order — Plays all visible tracks one after the other.
+ Loop — Plays all visible tracks one after the other, then starts over.
+ Mix — Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by StopAll
+
+ + Loop
+
+ Off — Disables loop mode for all visible tracks
+ On — Enables loop mode for all visible tracks
+
+ + Stop All — Stops all currently playing tracks. Also use to stop a Mix.
+ Find — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called Found. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel.
If you input "d" as the search term, it will create a temporary album of any duplicate tracks, grouped by name.
+ Help — Displays this help page. Click Return to Player to return. +
+ +

+ +
Sidebar: Navigation & Now Playing
+
+
+ View Mode Toggle
+The left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view. +
+
Albums Playlists
+
+These buttons let you switch between organizing by: +
+Album tags or by manual Playlists. Albums are groupings of tracks that you define through Jukebox Plus. You can make as many of these as you like, and any track may belong to multiple albums. +Playlists are managed by the Roll20 Jukebox interface. You can view and play them here, but you cannot move them about. +
+
+At the bottom of the list is:
+Now Playing Choosing this filters the list to show only tracks currently playing. +
+

+ +
Track Controls
+
+Each track shows these control buttons:
+ +
+ Play: Start the track.
+ Loop: Toggle loop mode for the track.
+ Isolate: Stops all others and plays only this one.
+ Stop: Stops this track.
+ Announce: Sends the track name and description to the chat window.
+
+

+ +
Track Info and Management
+
+Edit — Click the track description "edit" link to create a description.
+
+ Description special characters:
+ "---" to insert a line break.
+ "*italic*" surround a word in single asterisks to have it display in italic
+ "**bold**" surround a word in double asterisks to have it display in bold
+ "!d" or "!desc" to include the description of the track when you announce it. Default is title only.
+ "!a" or "!announce" to have a track announce itself automatically whenever you play it. Default is manual announcement only. +
+
+Tags — Each track has a Playlist tag, and the ability to add album tags. Playlist tags are in blue, and album tags are in red. Click + Add to add a track to an Album. Click a Playlist or Album tag to jump immediately to that Playlist or Album. Click the "x" in an Album tag to remove the track from that Album: Album name | x
+Image Area — Click the image area to submit a valid image URL or a hexadecimal color code, such as "#00ff00". You can also enter a common CSS color name such as "red".
+If you submit an image URL, the image will display here next to the title, or in the chat tab while Announcing a track. The URL can come from your Roll20 image library or any valid image host.
+If you submit a valid color code or name, the square will turn that color, and that color will be used when Announcing a track. +
+
+
+ +
Utility Panel
+
+Click to expand the utility tools. Includes: +
+
+
+
Edit Albums: + + + + +
+ +These buttons change the name of an album, add a new album, or remove the currently selected album. There is no verification, so use with care. +
+ +
+
A—Z +albums +tracks +
+These buttons alphabetize Albums, or Tracks within an Album. + + +
+
Mode: +dark +light +
+ +These buttons switch between light and dark mode. + + Rebuilds the interface if something breaks. + +
+
Backup +make +restore +
+These buttons create a backup handout of the custom data you have entered: playlists, descriptions, and images. Higher numbered handouts are later backups. You can restore from a backup if your data gets screwed up, or you can transmogrify or copy the handout to a new game and restore from there. This is a useful way to move your customizations from game to game. Use with caution — Roll20 stores tracks by ID number which are different in every game, and the script tries hard to match title to ID. If you have multiple tracks with the same name or have renamed a track, this may not perform as expected. +
+

+ +
Find
+
+Use the !jb find keyword command to search all track names and descriptions for the keyword. +All matching tracks will be assigned to a temporary album called Found. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the utility panel. +
+

+ +
Useful Macros
+
+Here are some chat commands that can be used in macros:
+
+ !jb — Puts a link to this handout in chat
+ !jb play TrackName — play the named track
+ !jb stopall — stops all audio
+ !jb loopall — sets loop mode on all visible tracks
+ !jb unloopall — disables loop mode on all tracks
+ !jb jump album AlbumName — switch to a specific album
+ !jb help — open this help screen
+ !jb find keyword search for tracks by keyword in name or description
+
+ You can also discover commands by pressing a button, clicking in the chat window, and pressing the up arrow to see what was sent. +
+




+`; + + + + handout.set('notes', helpHTML); + }; + + function sendStyledMessage(titleOrMessage, messageOrUndefined, isPublic = false) + { + let title, message; + + if(messageOrUndefined === undefined) + { + title = 'Jukebox Plus'; + message = titleOrMessage; + } + else + { + title = titleOrMessage || 'Jukebox Plus'; + message = messageOrUndefined; + } + + message = String(message); // ← Fix added here + + + // Replace markdown-style [label](command) with styled + message = message.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, command) => + { + return `${label}`; + }); + + const html = `
${title}
${message}
`; + + const target = isPublic ? '' : '/w gm '; + sendChat('Jukebox Plus', `${target}${html}`, null, + { + noarchive: true + }); + } + +const renderFormattedText = (text) => { + if(!text) return ''; + return esc(text) + .replace(/---+/g, '
') // replace --- with
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** + .replace(/\*(.+?)\*/g, `$1`) + .replace(/`(.+?)`/g, '$1') // `code` + .replace(/!a/gi, `announce`) // announce codes + .replace(/!d/gi, `desc`); +}; + + +const escapeForRoll20Query = (str) => { + if (!str) return ''; + return str + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\?/g, '\\?') + .replace(/\{/g, '\\{') + .replace(/\}/g, '\\}'); +}; + + + + + + + const esc = (s) => s.replace(/[&<>"']/g, c => ( + { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } [c])) + .replace(/\/{2}/g, '
'); + +const cssNamedColors = new Set([ + 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', + 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', + 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', + 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', + 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', + 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', + 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', + 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', + 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', + 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', + 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', + 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', + 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', + 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', + 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', + 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', + 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', + 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', + 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', + 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', + 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', + 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', + 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', + 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', + 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', + 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', + 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', + 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen' +]); + + + +let sequentialPlayState = { + trackIds: [], + currentIndex: -1, + active: false, + loop: false // <--- New! +}; + +const getVisibleTrackList = () => { + const getPlaylistTracks = () => + { + const plist = data.playlists[data.settings.selectedPlaylist]; + return Array.isArray(plist) ? plist : []; + }; + + if (data.settings.nowPlayingOnly) { + return Object.values(data.tracks).filter(t => { + const actual = getAllTracks().find(j => j.id === t.id); + return actual && actual.get('playing'); + }); + } + + if (data.settings.viewMode === 'albums') { + const selected = data.settings.selectedAlbum; + let tracks = Object.values(data.tracks).filter(t => t.albums.includes(selected)); + + if (selected === 'Duplicates') { + tracks.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + } else if (data.trackSortOrder?.length) { + const ordered = data.trackSortOrder + .map(title => tracks.find(t => t.title === title)) + .filter(Boolean); + const leftovers = tracks.filter(t => !data.trackSortOrder.includes(t.title)); + tracks = [...ordered, ...leftovers]; + } + + return tracks; + } + + if (data.settings.viewMode === 'playlists') { + const selected = data.settings.selectedPlaylist; + const trackIds = data.playlists[selected] || []; + return trackIds.map(id => data.tracks[id]).filter(Boolean); + } + + return []; +}; + + + const getAllTracks = () => findObjs( + { + _type: 'jukeboxtrack' + }) || []; + + // Sync playlists by walking the jukebox folder structure and building playlists object + const syncPlaylists = () => + { + let folderJSON = Campaign() + .get('jukeboxfolder'); + if(!folderJSON) + { + log('Jukebox Plus: No jukebox folder found.'); + data.playlists = { + 'Unassigned': [] + }; + return; + } + + let folder; + try + { + folder = JSON.parse(folderJSON); + } + catch (e) + { + log('Jukebox Plus: Failed to parse jukeboxfolder JSON:', e); + data.playlists = { + 'Unassigned': [] + }; + return; + } + + log('Jukebox Plus: jukeboxfolder parsed:', JSON.stringify(folder)); + + // Clear previous playlists before repopulating + data.playlists = {}; + + // Flatten walk through the folder structure + // Each element is an object: { n: playlist name, i: array of track IDs } + folder.forEach(playlist => + { + if(!playlist.n || !Array.isArray(playlist.i)) return; + + const playlistName = playlist.n; + if(!data.playlists[playlistName]) + { + data.playlists[playlistName] = []; + } + + playlist.i.forEach(trackId => + { + const track = data.tracks[trackId]; + if(track) + { + // Add track ID to playlist if not already present + if(!data.playlists[playlistName].includes(trackId)) + { + data.playlists[playlistName].push(trackId); + } + // Also ensure this playlist is tracked in track.albums or track.playlists if you want + } + else + { + log(`Jukebox Plus: Track ID [${trackId}] not found in jukebox tracks.`); + } + }); + }); + + // If no playlists found, fallback to Unassigned + if(Object.keys(data.playlists) + .length === 0) + { + log('Jukebox Plus: No playlists created, adding Unassigned fallback.'); + data.playlists = { + 'Unassigned': [] + }; + } + }; + + // Sync tracks and playlists + const syncTracks = () => + { + getAllTracks() + .forEach(track => + { + const id = track.get('_id'); + const title = track.get('title'); + const volume = track.get('volume'); + if(!data.tracks[id]) + { + data.tracks[id] = { + id, + title, + volume, + albums: [], + description: '', + image: '', + sortOrder: + {} + }; + } + else + { + data.tracks[id].title = title; + data.tracks[id].volume = volume; + } + }); + syncPlaylists(); + }; + +const buildTrackRow = (track) => +{ + const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(track.image || ''); + const isNamedColor = cssNamedColors.has((track.image || '').toLowerCase()); + const isURL = /^https?:\/\/.+/.test(track.image || ''); + + const img = track.image + ? ` +
+
+
` + : ` +
click to add image or color code
+
`; + + // Build album options respecting albumSortOrder, exclude albums already assigned to track + const albumOptions = (data.albumSortOrder && data.albumSortOrder.length + ? data.albumSortOrder.filter(a => data.albums.hasOwnProperty(a)) + : Object.keys(data.albums || {})) + .filter(a => !track.albums.includes(a)) + .concat('New Album'); + + // Escape and join for prompt + const addAlbumQuery = albumOptions.map(a => esc(a)).join('|'); + + const desc = track.description ? + `
${renderFormattedText(track.description)} [edit]
` : + `
click to add description
`; + + // Live state of the jukebox track + const actualTrack = getAllTracks() + .find(t => t.id === track.id); + const isPlaying = actualTrack && actualTrack.get("playing"); + const isLooping = actualTrack && actualTrack.get("loop"); + + const playImg = isPlaying ? icons.playActive : icons.play; + const loopImg = isLooping ? icons.loopActive : icons.loop; + + // Determine view modes + const isAlbumView = data.settings.viewMode === 'albums' && !data.settings.nowPlayingOnly; + const isNowPlaying = data.settings.nowPlayingOnly; + + // Determine playlist tag HTML + let playlistTagHTML = ''; + if ((isAlbumView || isNowPlaying) && data.playlists) { + const matchingPlaylist = Object.entries(data.playlists) + .find(([name, ids]) => + ids.includes(track.id) + ); + if (matchingPlaylist) { + const [playlistName] = matchingPlaylist; + const encoded = encodeURIComponent(playlistName); + playlistTagHTML = ` ${esc(playlistName)}`; + } + } + + // Build add album button with sorted options + const addAlbumButton = `+ Add`; + + return ` +
+ ${img} +
+
+
+ ${esc(track.title)} + +
+ + Play + Loop + Isolate + Stop + +
+ ${desc} +
+ ${ + (track.albums || []).map(name => + ` + ${esc(name)} +  |  + x + ` + ).join(' ') + } + ${addAlbumButton} + ${playlistTagHTML} +
+
+
+`; +}; + + + + +const getTrackFlags = (track) => { + const desc = (track.description || '').toLowerCase(); + return { + announce: desc.includes('!a') || desc.includes('!announce'), + includeDesc: desc.includes('!d') || desc.includes('!desc') + }; +}; + +const getSortedAlbumNames = () => { + let albumNames = data.albumSortOrder?.length + ? data.albumSortOrder.filter(name => data.albums.hasOwnProperty(name)) + : Object.keys(data.albums); + + albumNames = albumNames.filter(name => name !== 'Found'); + if ('Found' in data.albums) albumNames.push('Found'); + + return albumNames; +}; + + +const updateInterface = () => +{ + css = data.settings.mode === 'light' ? cssLight : cssDark; + icons = data.settings.mode === 'light' ? iconSetLight : iconSetDark; + + const handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + if(!handout) return; + + // Ensure selected playlist/album exists, fallback if not + if(data.settings.viewMode === 'playlists') + { + let selected = data.settings.selectedPlaylist; + if(!selected || !data.playlists[selected]) + { + const keys = Object.keys(data.playlists); + if(keys.length) + { + selected = keys[0]; + } + else + { + selected = 'Unassigned'; + data.playlists[selected] = []; + } + data.settings.selectedPlaylist = selected; + } + } + if(data.settings.viewMode === 'albums') + { + let selected = data.settings.selectedAlbum; + if(!selected || !data.albums[selected]) + { + const keys = Object.keys(data.albums); + data.settings.selectedAlbum = keys.length ? keys[0] : ''; + } + } + + const getPlaylistTracks = () => + { + const plist = data.playlists[data.settings.selectedPlaylist]; + return Array.isArray(plist) ? plist : []; + }; + + const toggleHTML = ` +
+ Albums + Playlists +
+ `; + + const isAnyTrackPlaying = getAllTracks() + .some(t => t.get('playing')); + + const sidebarList = (() => + { + const entries = []; + + if(data.settings.viewMode === 'albums') + { +const albumNames = data.albumSortOrder?.length + ? data.albumSortOrder.filter(name => data.albums.hasOwnProperty(name)) + : Object.keys(data.albums); + +getSortedAlbumNames().forEach(albumName => { + const encodedName = encodeURIComponent(albumName); + const style = (albumName === data.settings.selectedAlbum && !data.settings.nowPlayingOnly) + ? css.albumSelectedLink + : css.sidebarLink; + entries.push(`${esc(albumName)}`); +}); + + } + else + { + Object.keys(data.playlists || {}) + .forEach(playlistName => + { + const encodedName = encodeURIComponent(playlistName); + const style = (playlistName === data.settings.selectedPlaylist && !data.settings.nowPlayingOnly) ? + css.playlistSelectedLink : + css.sidebarLink; + entries.push(`${esc(playlistName)}`); + }); + } + + // Highlight Now Playing if active + const nowPlayingStyle = data.settings.nowPlayingOnly ? + (data.settings.viewMode === 'albums' ? css.albumSelectedLink : css.playlistSelectedLink) : + css.sidebarLink; + + entries.push(`Now Playing`); + + return entries.join(''); + })(); + + const visibleTracks = Object.values(data.tracks) + .filter(track => + { + let matchesView = false; + + if(data.settings.viewMode === 'albums') + { + matchesView = data.settings.selectedAlbum ? + track.albums.includes(data.settings.selectedAlbum) : + true; + } + else + { + const plist = getPlaylistTracks(); + matchesView = plist.includes(track.id); + } + + if(data.settings.nowPlayingOnly) + { + const t = getAllTracks() + .find(t => t.id === track.id); + return matchesView && t && t.get('playing'); + } + + return matchesView; + }); + +const trackList = getVisibleTrackList().map(buildTrackRow).join(''); + + + + const utilityToggleText = data.settings.utilityExpanded ? 'Settings ▲' : 'Settings ▼'; + + const utilityToggleButton = ` + Settings ${data.settings.settingsExpanded ? '▴' : '▾'} +`; + + const utilityButtons = data.settings.settingsExpanded ? ` + ${utilityToggleButton} +
+ Edit Albums + + + n !== "Found")].join('|'); + } else { + return names.join('|'); + } + })() + }}" style="${css.utilitySubButton}">– + +
+ +
+ A–Z + tracks + albums +
+ + ↻ Refresh + +
+ Mode + light + dark +
+ +
+ Backup + make + restore +
+` : utilityToggleButton; + + const html = ` + + + + + + + + +
+ Jukebox Plus + + ${data.settings.helpVisible ? 'Return to Player' : 'Help'} + + Find Tracks + Stop All + +
Loop All + Off + On +
+ +
Play All + Together + In Order + Loop + Mix + +
+ + + + + ${visibleTracks.length} track${visibleTracks.length !== 1 ? 's' : ''} + +
+ ${toggleHTML} + ${sidebarList} +
+ ${utilityButtons} +
${trackList}
+`; + + handout.set('notes', html); +}; + + + + const sendHandoutLink = () => + { + let handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + + if(!handout) + { + handout = createObj('handout', + { + name: HANDOUT_NAME, + inplayerjournals: 'all', + archived: false + }); + + // Defer rendering just slightly to allow Roll20 to index the handout + setTimeout(() => updateInterface(), 500); + } + + sendStyledMessage(`[Open Jukebox Plus Handout](http://journal.roll20.net/handout/${handout.id})`); + + }; + +on('change:jukeboxtrack', (obj, prev) => { + if (!sequentialPlayState.active) return; + + // Detect when a track finishes playing (softstop turns true) + if (prev.softstop === false && obj.get('softstop') === true && !obj.get('loop')) { + const currentId = sequentialPlayState.trackIds[sequentialPlayState.currentIndex]; + + if (obj.id === currentId) { + obj.set('playing', false); // Optional cleanup + + sequentialPlayState.currentIndex++; + + // If we've reached the end of the list + if (sequentialPlayState.currentIndex >= sequentialPlayState.trackIds.length) { + if (sequentialPlayState.loop) { + sequentialPlayState.currentIndex = 0; + } else { + sequentialPlayState.active = false; + sendStyledMessage('Sequence finished', 'Finished playing all tracks sequentially.'); + updateInterface(); + return; + } + } + + // Play next track in sequence + const nextId = sequentialPlayState.trackIds[sequentialPlayState.currentIndex]; + const nextTrack = getAllTracks().find(t => t.id === nextId); + if (nextTrack) { + nextTrack.set('softstop', false); // Reset in case it was marked finished before + nextTrack.set('playing', true); + } else { + sequentialPlayState.currentIndex++; // skip missing track + } + + updateInterface(); + } + } +}); + + + + on('chat:message', (msg) => + { + if(msg.type !== 'api' || !msg.content.startsWith('!jb')) return; + const match = msg.content.slice(3) + .trim() + .match(/(?:"[^"]*"|'[^']*'|\S)+/g); + const args = match ? match.map(s => s.replace(/^['"]|['"]$/g, '')) : []; + const command = args.shift() || ''; + + const findTrackByIdOrName = (idOrName) => + { + return data.tracks[idOrName] || Object.values(data.tracks) + .find(t => t.title === idOrName); + }; + + if(command === '') + { + sendHandoutLink(); + return; + } + + if(command === 'help') + { + const sub = args[0]?.toLowerCase(); + data.settings.helpVisible = (sub !== 'close'); + if(data.settings.helpVisible) + { + renderHelpView(); + } + else + { + updateInterface(); + } + return; + } + + + if(["play", "loop", "stop", "isolate"].includes(command)) + { + const idOrName = args.join(' ') + .trim(); + const track = findTrackByIdOrName(idOrName); + if(track) + { + const actual = getAllTracks() + .find(t => t.id === track.id); + if(actual) + { +if (command === "play") { + actual.set("playing", true); + const flags = getTrackFlags(track); + if (flags.announce) { + const descHtml = flags.includeDesc ? `
${esc(track.description || '')}
` : ''; + const imageHtml = track.image ? + `` : + ''; + const messageHtml = `${imageHtml}
${esc(track.title)}
${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); + } +} +if(command === "stop") actual.set("playing", false); + if(command === "loop") actual.set("loop", !actual.get("loop")); + if(command === "isolate") + { + getAllTracks() + .forEach(t => t.set("playing", t.id === track.id)); + } + } + else + { +sendStyledMessage( + 'Track Not Playable', + `"${esc(track.title)}"

This track is listed in your saved data, but no matching Roll20 jukebox track exists.

This can happen if the track was deleted, if it was imported from another game, or if its ID changed. If you are sure this track no longer exists or needed, you can remove it from your saved data.

Remove this broken track
`, + false +); + } + } + else + { + sendStyledMessage('Warning', 'Track not found: ' + idOrName); + } + + // Refresh the interface to show correct play/loop icons + updateInterface(); + } + + +if(command === 'playall') +{ + const trackList = (() => + { + if(data.settings.viewMode === 'albums') + { + const albumName = data.settings.selectedAlbum; + return Object.values(data.tracks) + .filter(t => t.albums.includes(albumName)); + } + else if(data.settings.viewMode === 'playlists') + { + const trackIds = data.playlists[data.settings.selectedPlaylist] || []; + return trackIds.map(id => data.tracks[id]) + .filter(Boolean); + } + return []; + })(); + + const actualTracks = trackList + .map(t => getAllTracks() + .find(j => j.id === t.id)) + .filter(t => t); + + const max = 5; + + actualTracks.slice(0, max) + .forEach(t => { + t.set('playing', true); + + const flags = getTrackFlags(data.tracks[t.id]); + if (flags.announce) { + const descHtml = flags.includeDesc ? `
${esc(data.tracks[t.id].description || '')}
` : ''; + const imageHtml = data.tracks[t.id].image ? + `` : + ''; + const messageHtml = `${imageHtml}
${esc(data.tracks[t.id].title)}
${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); + } + }); + + if(actualTracks.length > max) + { + sendStyledMessage('Notice', 'Only the first 5 tracks were played to avoid clutter.'); + } + + updateInterface(); +} + +if (command === 'playall-seq') { + const visibleTracks = getVisibleTrackList(); + const max = 20; + + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + // Store track IDs for sequential playing + sequentialPlayState.trackIds = actualTracks.map(t => t.id); + sequentialPlayState.currentIndex = 0; + sequentialPlayState.active = true; + + // Start first track + const firstTrack = actualTracks[0]; + firstTrack.set('softstop', false); + firstTrack.set('playing', true); + + updateInterface(); +} + + +if(command === 'playall-seq-loop') { + const visibleTracks = getVisibleTrackList(); // Same display order used by updateInterface + + const max = 20; + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + sequentialPlayState.trackIds = actualTracks.map(t => t.id); + sequentialPlayState.currentIndex = 0; + sequentialPlayState.active = true; + sequentialPlayState.loop = true; // 👈 enables looping after the last track + + // Stop all currently playing tracks + getAllTracks().forEach(t => t.set('playing', false)); + + const firstTrack = actualTracks[0]; + firstTrack.set('softstop', false); // ensure it will start + firstTrack.set('playing', true); + + + updateInterface(); +} + +// Global timer variable, declared once outside the command handler +let mixAccentTimer = null; + +if (command === 'mix') { + if (args[0] === 'stop') { + sendStyledMessage('Info', 'Use !jb stopall to stop all playback including mix mode.'); + return; + } + + // Cancel existing timer + if (mixAccentTimer) { + clearTimeout(mixAccentTimer); + mixAccentTimer = null; + } + + // Don't store timer in state! + state.GraphicJukebox.mixSession = null; + + const visibleTracks = getVisibleTrackList(); + const max = 20; + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + const loopingTracks = actualTracks.filter(t => t.get('loop')); + const nonLoopingTracks = actualTracks.filter(t => !t.get('loop')); + + // Stop all tracks + getAllTracks().forEach(t => t.set('playing', false)); + + // Start looping tracks + loopingTracks.forEach(t => { + t.set('softstop', false); + t.set('playing', true); + }); + + // Save only IDs in state (no timer) + state.GraphicJukebox.mixSession = { + tracks: nonLoopingTracks.map(t => t.id) + }; + + // Inner accent track loop + const playRandomAccent = () => { + const mix = state.GraphicJukebox.mixSession; + if (!mix || !Array.isArray(mix.tracks) || mix.tracks.length === 0) return; + + const randomId = mix.tracks[Math.floor(Math.random() * mix.tracks.length)]; + const track = getAllTracks().find(t => t.id === randomId); + if (track) { + track.set('softstop', false); + track.set('playing', true); + } + + // Re-schedule next play + if (state.GraphicJukebox.mixSession) { + const nextInterval = 10000 + Math.floor(Math.random() * 50000); + mixAccentTimer = setTimeout(playRandomAccent, nextInterval); + } + }; + + // First call after short delay + mixAccentTimer = setTimeout(playRandomAccent, 3000); + + updateInterface(); +} + + +if (command === 'stopall') { + getAllTracks().forEach(t => t.set('playing', false)); + + // Cancel mix timer if active + if (mixAccentTimer) { + clearTimeout(mixAccentTimer); + mixAccentTimer = null; + } + + state.GraphicJukebox.mixSession = null; + + updateInterface(); +} + + + + else if(command === 'loopall' || command === 'unloopall') + { + const trackList = (() => + { + if(data.settings.viewMode === 'albums') + { + const albumName = data.settings.selectedAlbum; + return Object.values(data.tracks) + .filter(t => t.albums.includes(albumName)); + } + else if(data.settings.viewMode === 'playlists') + { + const trackIds = data.playlists[data.settings.selectedPlaylist] || []; + return trackIds.map(id => data.tracks[id]) + .filter(Boolean); + } + return []; + })(); + const shouldLoop = (command === 'loopall'); + + const actualTracks = trackList + .map(t => getAllTracks() + .find(j => j.id === t.id)) + .filter(t => t); + + actualTracks.forEach(t => t.set('loop', shouldLoop)); + + updateInterface(); + } + + if(command === 'refresh') + { + syncTracks(); + updateInterface(); + sendStyledMessage('Track data refreshed.'); + } + + if(command === 'backup') + { + const backupData = { + tracks: + {}, + albums: + { + ...data.albums + }, + playlists: + {}, + albumSortOrder: + { + ...data.albumSortOrder + } + }; + + // Store track data keyed by title + Object.values(data.tracks) + .forEach(t => + { + backupData.tracks[t.title] = { + title: t.title, + description: t.description, + image: t.image, + albums: t.albums.slice(), + volume: t.volume + }; + }); + + // Store playlist data using track titles + Object.entries(data.playlists) + .forEach(([playlistName, trackIds]) => + { + const titles = trackIds.map(id => data.tracks[id]?.title) + .filter(Boolean); + backupData.playlists[playlistName] = titles; + }); + + // Generate sequential backup handout name + let index = 1; + let name = `Jukebox Backup 001`; + while(findObjs( + { + _type: 'handout', + name + }) + .length > 0) + { + index++; + name = `Jukebox Backup ${String(index).padStart(3, '0')}`; + } + + const handout = createObj('handout', + { + name, +archived: true + }); + + handout.set('notes', `
${JSON.stringify(backupData, null, 2)}
`); + sendStyledMessage('Backup created', `[${name}](http://journal.roll20.net/handout/${handout.id})`); + } + + + if(command === 'restore') + { + const backupName = args.join(' ') + .trim(); + const handout = findObjs( + { + _type: 'handout', + name: backupName + })[0]; + + if(!handout) + { + sendStyledMessage(`Backup handout not found: ${backupName}`); + return; + } + + handout.get('notes', notes => + { + const raw = notes.replace(/^
|<\/pre>$/g, '')
+                    .trim();
+                let backup;
+
+                try
+                {
+                    backup = JSON.parse(raw);
+                }
+                catch (e)
+                {
+                    sendStyledMessage('Backup', `Failed to parse backup JSON in "${backupName}".`);
+                    return;
+                }
+
+                const titleToId = {};
+                getAllTracks()
+                    .forEach(track =>
+                    {
+                        titleToId[track.get('title')] = track.get('_id');
+                    });
+
+                const restoredTracks = {};
+                Object.values(backup.tracks ||
+                    {})
+                    .forEach(bt =>
+                    {
+                        const id = titleToId[bt.title];
+                        if(id)
+                        {
+                            restoredTracks[id] = {
+                                id,
+                                title: bt.title,
+                                description: bt.description || '',
+                                image: bt.image || '',
+                                albums: bt.albums || [],
+                                volume: bt.volume ?? 0.5,
+                                sortOrder:
+                                {}
+                            };
+                        }
+                        else
+                        {
+                            sendStyledMessage('Restore', `Track not found in current game: "${bt.title}"`);
+                        }
+                    });
+
+                const restoredPlaylists = {};
+                Object.entries(backup.playlists ||
+                    {})
+                    .forEach(([plistName, titles]) =>
+                    {
+                        restoredPlaylists[plistName] = titles
+                            .map(t => titleToId[t])
+                            .filter(Boolean);
+                    });
+
+                // Apply restored data
+                data.tracks = restoredTracks;
+                data.albums = {
+                    ...backup.albums
+                };
+                data.albumSortOrder = {
+                    ...backup.albumSortOrder
+                };
+                data.playlists = restoredPlaylists;
+
+                updateInterface();
+                sendStyledMessage('Restore', `Backup "${backupName}" restored successfully.`);
+            });
+        }
+
+if(command === 'delete-track') {
+    const id = args.join(' ').trim();
+    const track = data.tracks[id];
+    if(track) {
+        delete data.tracks[id];
+        sendStyledMessage('Track Removed', `Track "${esc(track.title)}" has been removed from your saved data.`, false);
+        updateInterface();
+    } else {
+        sendStyledMessage('Error', 'Track not found in saved data.', false);
+    }
+}
+
+if (command === 'announce') {
+    const idOrName = args.join(' ').trim();
+    const track = findTrackByIdOrName(idOrName);
+
+    if (!track) {
+        sendStyledMessage('Warning', 'Track not found.');
+        return;
+    }
+
+    const actual = getAllTracks().find(t => t.id === track.id);
+    if (!actual) {
+        sendStyledMessage('Warning', 'Track ID found but not playable: ' + track.title);
+        return;
+    }
+
+    const flags = getTrackFlags(track);
+
+    const value = (track.image || '').trim();
+    const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value);
+    const isNamedColor = /^[a-zA-Z]+$/.test(value);
+    const isImageURL = /^https?:\/\/.+/.test(value);
+
+    // Convert hex to luminance to determine brightness
+    const isDarkHex = (hex) => {
+        let r, g, b;
+        hex = hex.replace('#', '');
+        if (hex.length === 3) {
+            r = parseInt(hex[0] + hex[0], 16);
+            g = parseInt(hex[1] + hex[1], 16);
+            b = parseInt(hex[2] + hex[2], 16);
+        } else {
+            r = parseInt(hex.substr(0, 2), 16);
+            g = parseInt(hex.substr(2, 2), 16);
+            b = parseInt(hex.substr(4, 2), 16);
+        }
+        const luminance = 0.2126*r + 0.7152*g + 0.0722*b;
+        return luminance < 128;
+    };
+
+    // Guess named color brightness (simple hardcoded list for safety)
+    const darkNamedColors = ['black', 'navy', 'purple', 'maroon', 'darkgreen', 'teal', 'indigo', 'midnightblue', 'darkblue', 'darkslategray'];
+    const isDarkNamed = darkNamedColors.includes(value.toLowerCase());
+
+    let imageHtml = '';
+    let titleHtml = `
${esc(track.title)}
`; + + if (value && (isHexColor || isNamedColor)) { + const isDark = isHexColor ? isDarkHex(value) : isDarkNamed; + const textColor = isDark ? '#fff' : '#111'; + imageHtml = `
${esc(track.title)}
`; + titleHtml = ''; // Suppress normal title line + } else if (isImageURL) { + imageHtml = ``; + } + + let cleanDesc = track.description || ''; + if (flags.includeDesc) { + cleanDesc = cleanDesc.replace(/\s*!a(nnounce)?\b/gi, ''); + cleanDesc = cleanDesc.replace(/\s*!d(esc)?\b/gi, ''); + } + + const descHtml = flags.includeDesc + ? `
${renderFormattedText(cleanDesc.trim())}
` + : ''; + + const messageHtml = `${imageHtml}${titleHtml}${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); +} + + + + + + + if(command === 'view') + { + const mode = args[0]; + if(['albums', 'playlists'].includes(mode)) + { + data.settings.viewMode = mode; + updateInterface(); + } + } + + if(command === 'view' && args[0] === 'nowplaying') + { + data.settings.nowPlayingOnly = true; + updateInterface(); + } + + + if(command === 'view' && args[0] === 'all') + { + data.settings.nowPlayingOnly = false; + updateInterface(); + } + +if(command === 'jump' && args[0] === 'album') +{ + const encodedName = args.slice(1).join(' ').trim(); + const name = decodeURIComponent(encodedName); + + if(name in data.albums) + { + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = name; + updateInterface(); + } + else + { + sendStyledMessage(`Album not found: ${name}`); + } +} + + if(command === 'jump-playlist') + { + const name = decodeURIComponent(args.join(' ') + .trim()); + + if(!(name in data.playlists)) + { + sendStyledMessage(`Playlist not found: ${name}`); + return; + } + + data.settings.viewMode = 'playlists'; + data.settings.selectedPlaylist = name; + data.settings.nowPlayingOnly = false; + updateInterface(); + } + +if (command === 'sort-albums') { + const sorted = Object.keys(data.albums).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + data.albumSortOrder = sorted; + sendStyledMessage('Albums Sorted', 'Album list has been sorted alphabetically.'); + updateInterface(); +} + +if (command === 'sort-tracks') { + const sorted = Object.values(data.tracks) + .map(t => t.title) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + data.trackSortOrder = sorted; + sendStyledMessage('Tracks Sorted', 'Track database has been sorted alphabetically.'); + updateInterface(); +} + + +if(command === 'edit') +{ + const idOrName = args.shift(); + const field = args.shift(); + const value = args.join(' ').trim(); + const track = findTrackByIdOrName(idOrName); + if(!track) + { + sendStyledMessage(`Track not found: ${idOrName}`); + return; + } + + if(field === 'image') + { + const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value); + const isNamedColor = cssNamedColors.has(value.toLowerCase()); + + if(value === '') { + track.image = ''; // Clear image + } + else if(isHexColor || isNamedColor || value.length) + { + track.image = value; + } + else + { + sendStyledMessage(`Invalid input: must be a valid image URL or color code (hex or named).`); + return; + } + } + else if(field === 'description') + { + track.description = value; + } + else if(field === 'albums') + { + const [action, ...rest] = value.split(' '); + let target = decodeURIComponent(rest.join(' ').trim()); + + if(action === 'add') + { + if(target === 'New Album') + { + const player = getObj('player', msg.playerid); + const playerName = player ? player.get('displayname') : 'GM'; + const safeTrackId = track.id.replace(/[^A-Za-z0-9\-_]/g, ''); + + sendStyledMessage(`[Click here to create a new album and assign this track](!jb add-album-and-assign ${safeTrackId} ?{Enter new album name})`, false); + return; + } + + if(!track.albums.includes(target)) + { + track.albums.push(target); + } + } + else if(action === 'remove') + { + track.albums = track.albums.filter(a => a !== target); + } + } + + updateInterface(); +} + + + + if(command === 'add' && args[0] === 'album') + { + const albumName = args.slice(1) + .join(' ') + .trim(); + if(albumName) + { +data.albums[albumName] = true; +if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; +if (!data.albumSortOrder.includes(albumName)) data.albumSortOrder.push(albumName); +data.settings.selectedAlbum = albumName; +updateInterface(); + } + } + + if(command === 'add-album-and-assign') + { + const trackId = args.shift(); + const albumName = args.join(' ') + .trim(); + + if(!trackId || !albumName) + { + sendStyledMessage('Missing track ID or album name.', false); + + + + + return; + } + + + const track = data.tracks[trackId]; + if(!track) + { + sendStyledMessage('Track not found.', false); + return; + } + + // If "New Album" is selected, create it only if it doesn't already exist +if (!data.albums[albumName]) { + data.albums[albumName] = true; + if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; + if (!data.albumSortOrder.includes(albumName)) data.albumSortOrder.push(albumName); +} + + + if(!track.albums.includes(albumName)) + { + track.albums.push(albumName); + } + + updateInterface(); + } + + if(command === 'remove-album') + { + const name = args.join(' ') + .trim(); + if(name in data.albums) + { + delete data.albums[name]; + if (Array.isArray(data.albumSortOrder)) { + data.albumSortOrder = data.albumSortOrder.filter(n => n !== name); +} + + + // Remove the album from any tracks that had it + Object.values(data.tracks) + .forEach(track => + { + if(track.albums.includes(name)) + { + track.albums = track.albums.filter(a => a !== name); + } + }); + + // Reset selection if the deleted album was selected + if(data.settings.selectedAlbum === name) + { + const remaining = Object.keys(data.albums); + data.settings.selectedAlbum = remaining.length ? remaining[0] : ''; + } + + updateInterface(); + sendStyledMessage(`Album "${name}" has been removed.`, false); + } + else + { + sendStyledMessage(`Album "${name}" not found.`, false); + } + } + + if(command === 'rename-album') + { + const knownAlbums = Object.keys(data.albums) + .sort((a, b) => b.length - a.length); // Longest match first + const joinedArgs = args.join(' ') + .trim(); + + // Try to find which known album name this starts with + let oldName = null; + let newName = null; + + for(let album of knownAlbums) + { + if(joinedArgs.startsWith(album)) + { + oldName = album; + newName = joinedArgs.slice(album.length) + .trim(); + break; + } + } + + if(!oldName || !newName) + { + sendStyledMessage(`Could not determine album names. Got: ${joinedArgs}`, false); + return; + } + + if(!data.albums[oldName]) + { + sendStyledMessage(`Album "${oldName}" not found.`, false); + return; + } + + if(data.albums[newName]) + { + sendStyledMessage('Rename Failed', `An album named "${newName}" already exists.`, false); + return; + } + + // Rename in album list + data.albums[newName] = true; + delete data.albums[oldName]; + if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; +data.albumSortOrder = data.albumSortOrder.map(n => n === oldName ? newName : n); + + + // Update all tracks that had the old album name + Object.values(data.tracks) + .forEach(track => + { + if(track.albums?.includes(oldName)) + { + track.albums = track.albums.map(name => name === oldName ? newName : name); + } + }); + + // Switch view to the renamed album + data.view = { + mode: 'album', + name: newName + }; + + updateInterface(); + } + +if (command === 'find') { + const searchTerm = args.join(' ').toLowerCase().trim(); + + if (!searchTerm) { + sendStyledMessage('Find Tracks', 'You must provide a search term.', false); + return; + } + + // Remove previous "Found" or "Duplicates" albums + ['Found', 'Duplicates'].forEach(name => { + if (name in data.albums) { + delete data.albums[name]; + Object.values(data.tracks).forEach(track => { + if (track.albums && track.albums.includes(name)) { + track.albums = track.albums.filter(a => a !== name); + } + }); + } + }); + + if (searchTerm === 'd') { + // Special case: Find tracks with duplicate names + const nameMap = {}; + Object.values(data.tracks).forEach(track => { + const title = track.title?.toLowerCase().trim(); + if (!title) return; + if (!nameMap[title]) nameMap[title] = []; + nameMap[title].push(track); + }); + + const duplicates = Object.values(nameMap) + .filter(list => list.length > 1) + .flat(); + + if (duplicates.length === 0) { + sendStyledMessage('Find Duplicates', 'No duplicate track titles found.', false); + return; + } + + data.albums['Duplicates'] = true; + + duplicates.forEach(track => { + if (!track.albums.includes('Duplicates')) { + track.albums.push('Duplicates'); + } + }); + + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = 'Duplicates'; + + +// Sort tracklist by title (case-insensitive) +data.trackOrder = duplicates + .slice() + .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())) + .map(track => track.id); + + + + updateInterface(); + sendStyledMessage('Find Duplicates', `Found ${duplicates.length} duplicate track${duplicates.length !== 1 ? 's' : ''}.`, false); + return; + } + + // Normal search mode + data.albums['Found'] = true; + + const matches = Object.values(data.tracks).filter(track => { + const title = track.title?.toLowerCase() || ''; + const desc = track.description?.toLowerCase() || ''; + return title.includes(searchTerm) || desc.includes(searchTerm); + }); + + matches.forEach(track => { + if (!track.albums.includes('Found')) { + track.albums.push('Found'); + } + }); + + if (matches.length === 0) { + sendStyledMessage('Find Tracks', `No tracks matched the search: "${searchTerm}"`, false); + return; + } + + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = 'Found'; + + updateInterface(); + sendStyledMessage('Find Tracks', `Found ${matches.length} track${matches.length !== 1 ? 's' : ''} matching "${searchTerm}"`, false); +} + + if(command === 'toggle-settings') + { + data.settings.settingsExpanded = !data.settings.settingsExpanded; + updateInterface(); + } + if(command === 'mode') + { + const theme = args[0]?.toLowerCase(); + if(theme === 'light' || theme === 'dark') + { + data.settings.mode = theme; + updateInterface(); + } + else + { + sendStyledMessage('Unknown Mode', `Mode "${theme}" is not recognized. Must be *light* or *dark*`, false); + } + } + + + if(command === 'select') + { + const type = args.shift(); + let name = args.join(' ') + .trim(); + name = decodeURIComponent(name); + + // Reset the "Now Playing Only" view + data.settings.nowPlayingOnly = false; + + if(type === 'album' && (name in data.albums)) + { + data.settings.selectedAlbum = name; + } + if(type === 'playlist') + { + if(!(name in data.playlists)) + { + data.playlists[name] = []; + } + data.settings.selectedPlaylist = name; + } + + updateInterface(); + } + }); + + syncTracks(); + updateInterface(); +}); + +{ try { throw new Error(''); } catch (e) { API_Meta.JukeboxPlus.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.JukeboxPlus.offset); } } From c7ab06a1e4e340ef0790c7c4e2b64aaf2294623f Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 00:11:18 -0700 Subject: [PATCH 2/9] Create JukeboxPlus.js --- JukeboxPlus/1.0.0/JukeboxPlus.js | 2125 ++++++++++++++++++++++++++++++ 1 file changed, 2125 insertions(+) create mode 100644 JukeboxPlus/1.0.0/JukeboxPlus.js diff --git a/JukeboxPlus/1.0.0/JukeboxPlus.js b/JukeboxPlus/1.0.0/JukeboxPlus.js new file mode 100644 index 000000000..3504dc705 --- /dev/null +++ b/JukeboxPlus/1.0.0/JukeboxPlus.js @@ -0,0 +1,2125 @@ +var API_Meta = API_Meta || {}; +API_Meta.JukeboxPlus = { + offset: Number.MAX_SAFE_INTEGER, + lineCount: -1 +}; { + try { + throw new Error(''); + } catch (e) { + API_Meta.JukeboxPlus.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (4)); + } +} + + +// Jukebox Plus Plus (Fully Enhanced UI with Album/Playlist Toggle, Track Tagging, and Layout Fixes) +on('ready', () => +{ + + const version = '1.0.0'; //version number set here + log('-=> Jukebox Plus v' + version + ' is loaded. Command !jb creates control handout and provides link. Click that to open.'); + + const HANDOUT_NAME = 'Jukebox Plus'; + const STATE_KEY = 'GraphicJukebox'; + + if(!state[STATE_KEY]) + { + state[STATE_KEY] = { + tracks: + {}, + albumSortOrder: + {}, + albums: + {}, + playlists: + {}, + rollbacks: [], + settings: + { + notifyOnPlay: 'on', + selectedAlbum: '', + selectedPlaylist: '', + viewMode: 'albums', + settingsExpanded: false, + nowPlayingOnly: false, + mode: 'dark', + helpVisible: false + } + }; + } + + + +// ✅ Ensure mixSession is present (without disrupting existing state) +if (!state[STATE_KEY].mixSession) { + state[STATE_KEY].mixSession = { + active: false, + loopIds: [], + randomIds: [], + timeoutId: null + }; +} + + // Declare once, top level within ready + const data = state[STATE_KEY]; + + // Define icon sets for each theme + const iconSetDark = { + play: 'https://files.d20.io/images/446752945/1lxeyU7yN1vPWXcrc3lFng/original.png?1751143927', + playActive: 'https://files.d20.io/images/446801469/hLU0ilPulBMcR2xBMFCYEQ/original.png?1751166667', + loop: 'https://files.d20.io/images/446752941/AJY4BveyKRfOvPPHGsY7jw/original.png?1751143926', + loopActive: 'https://files.d20.io/images/446801468/hJcBoRBqDlXqrJ5sSs69gA/original.png?1751166667', + isolate: 'https://files.d20.io/images/446752943/0YxEtYa40ld2L2qbLua07w/original.png?1751143927', + stop: 'https://files.d20.io/images/446752946/Jei3DhJjtd7AcQEMLoT2JQ/original.png?1751143927' + }; + + const iconSetLight = { + play: 'https://files.d20.io/images/446909842/EKV5MVZ4yWtPPahgW-yyxQ/original.png?1751231236', + playActive: 'https://files.d20.io/images/446801469/hLU0ilPulBMcR2xBMFCYEQ/original.png?1751166667', + loop: 'https://files.d20.io/images/446909844/RcZX7CnmpX_-_qeKrfr3ZQ/original.png?1751231236', + loopActive: 'https://files.d20.io/images/446909844/RcZX7CnmpX_-_qeKrfr3ZQ/original.png?1751231236', + isolate: 'https://files.d20.io/images/446909843/6IxkbARljNyoN78s26mLQg/original.png?1751231236', + stop: 'https://files.d20.io/images/446909850/AseQXEd16Xa77lPI2Hdeaw/original.png?1751231238' + }; + + // Define both style sets + const cssLight = { + // Layout Containers + sidebar: 'font-family: Nunito, Arial, sans-serif; background:#f5f5f5; vertical-align:top; padding:6px; border-right:1px solid #ccc; width:215px;', + tracklist: 'font-family: Nunito, Arial, sans-serif; padding:8px; vertical-align:top; width:100%; background:#ffffff;', + toggleWrap: 'display:block; margin-bottom:8px;width:160px;', + //deprecated + //tracklistScroll: 'max-height:600px !important; overflow-y: scroll; overflow-x: hidden;', + + // Header and Title + header: 'font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px; color:#222; background:#aaa; border-bottom:1px solid #ccc;', + gear: 'float:right; cursor:pointer; color:#666;', + trackCount: 'color:#666; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', + + // Buttons & Controls + button: 'display:block; margin-bottom:4px; width:100%; font-size:11px; background:#e0e0e0; color:#333; border:1px solid #bbb;', + utilityContainer: 'width:90%; font-size:12px; padding:4px 6px; background:#ddd; color:#333; border:1px solid #bbb; border-radius:4px; margin-top:6px; position:relative;', + utilitySubButton: 'font-size:11px; padding:1px 5px; background:#aaa; color:#333; border:1px solid #999; border-radius:3px; margin:-1px -1px 0px 3px; float:right; text-decoration:none;', + utilityButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:#ddd; color:#222; border:1px solid #bbb; border-radius:4px; text-align:center; margin-top:6px; text-decoration:none;', + settingsButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:transparent; color:#333; text-align:center; margin-top:6px; text-decoration:none;', + + // *** Updated header buttons to match cssDark measurements but cssLight colors from utility buttons *** + headerButtonContainer: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; border:1px solid #bbb; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#333;', + headerButton: 'float:right; font-size:12px; padding:4px 6px; border:1px solid #bbb; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#222;', + headerSubButton: 'font-size:11px; padding:1px 6px; border:1px solid #999; border-radius:3px; text-decoration:none; margin-top:-2px; background:#aaa; color:#333;', + headerSubButtonAcive: 'font-size:11px; padding:1px 6px; border:1px solid #999; border-radius:3px; text-decoration:none; margin-top:-2px; background:#C27575; color:#333;', + + nowPlayingButton: 'color:#444; padding:2px 4px; display:block; text-decoration:none; background:#eee; border-radius:4px; margin-top:6px;', + refreshButton: 'font-size:10px; margin-top:8px; display:block; color:#0066cc; text-decoration:underline; cursor:pointer;', + + //announce styles + announceButton: 'color:#888; font-size:10px; padding:0px 4px; display:inline-block; text-decoration:none; margin-top:4px;', + announceTitle: 'display:inline-block; font-size:16px; rexr-align:center; font-weight:bold; color:#333; margin-top:4px;', + announceDesc: 'margin-top:4px; font-size:11px; color:#555; line-height:15px;', + + // Sidebar Links & Rules + sidebarRule: 'border:0; border-top:1px solid #ccc; margin:20px 0 3px 0;', + sidebarLink: 'color:#444; padding:2px 4px; display:block; text-decoration:none;', + albumSelectedLink: 'background:#c22929; color:#fff; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + playlistSelectedLink: 'background:#2d5da6; color:#fff; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + + // Album/Playlist Tags + tags: 'margin-top:4px; margin-left:38px; display:block;', + albumTag: 'display:inline-block; background:#c22929; color:#fff; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + playlistTag: 'display:inline-block; background:#2d5da6; color:#fff; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + tagRemove: 'color:#fff; margin-left:2px; cursor:pointer;', + + // Toggle Buttons + toggleButton: 'display:inline-block; width:45%; padding:6px 0; font-weight:bold; border:1px solid #bbb; border-radius:4px; text-align:center; margin-right:4px;', + toggleActiveAlbums: 'background:#c22929; color:#fff;', + toggleActivePlaylists: 'background:#2d5da6; color:#fff;', + toggleInactive: 'background:#bbb; color:#666;', + + // Message styles + messageContainer: 'font-family: Nunito, Arial, sans-serif; background-color:#ccc; color:#111; padding:10px; position:relative; top:-15px; left:-5px; border: solid 1px #555; border-radius:5px;', + messageTitle: 'padding: 3px 0px; background-color:#444; border-radius:4px; color:#ddd; font-size:16px; text-transform: capitalize; text-align:center; margin-bottom:13px;', + messageButton: 'display:inline-block; background:#aaa; color:#111; border: solid 1px #666;border-radius:4px; padding:2px 6px; margin-right:2px; vertical-align:middle;', + descHelp: 'margin-top:4px; font-size:15px; color:#222;', + + // Track Item Styles + track: 'border-bottom:1px solid #ccc; padding:6px 0; display:table; width:100%; color:#333;', + trackTitle: 'display:inline-block; font-size:18px; font-weight:bold; color:#333;', + controls: 'float:right; margin-top:-2px;', + controlButtonImg: 'width:16px; height:16px; margin: 0px 2px; vertical-align:middle; cursor:pointer;', + desc: 'margin-top:4px; font-size:13px; color:#666; margin-left:38px;', + vol: 'font-size:11px; margin-top:4px; color:#999; margin-left:108px;', + albumEditLink: 'font-size:10px; margin-left:4px; vertical-align:middle; color:#666;', + descEditLink: 'font-size:10px; color:#888; font-style:italic; margin-left:6px; cursor:pointer;', + code: 'display:inline-block; font-size:0.75em; font-family:monospace; font-weight:bold; color:222; background-color:#ddd; padding:1px 4px; margin-left:4px; border-radius:3px; user-select:none;', + + // Images + image: 'width:100px; height:100px; background:#eee; text-align:center; font-size:11px; color:#999; border:1px solid #bbb; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', + imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #bbb; margin-right:8px; float:left; display:block;', + imagePlaceholder: 'width:100px; background:#eee; color:#999; text-align:center; font-size:11px; border:1px solid #bbb; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', + + // Album specific + albumImage: 'width:80px; height:80px; object-fit:cover; border:1px solid #bbb; margin-right:8px;', + albumHeaderDesc: 'font-size:12px; color:#666;', + addAlbum: 'font-size:10px; margin-top:8px; display:block; color:#666;' +}; + + + + + const cssDark = { + // Layout Containers + sidebar: 'font-family: Nunito, Arial, sans-serif; background:#222; vertical-align:top; padding:6px; border-right:1px solid #444; width:200px;', + tracklist: 'font-family: Nunito, Arial, sans-serif; padding:8px; vertical-align:top; width:100%; background:#1e1e1e;', + toggleWrap: 'display:block; margin-bottom:8px;width:160px;', + //deprecated + //tracklistScroll: 'max-height:600px !important; overflow-y: scroll; overflow-x: hidden;', + + // Header and Title + header: 'font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px; color:#ddd; background:#2a2a2a; border-bottom:1px solid #444;', + gear: 'float:right; cursor:pointer; color:#aaa;', + trackCount: 'color:#888; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', + + // Buttons & Controls + button: 'display:block; margin-bottom:4px; width:100%; font-size:11px; background:#333; color:#ccc; border:1px solid #555;', + utilityContainer: 'width:90%; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; margin-top:6px; position:relative;', + utilitySubButton: 'font-size:11px; padding:1px 5px; background:#444; color:#ccc; border:1px solid #444; border-radius:3px; margin:-1px -1px 0px 3px; float:right; text-decoration:none;', + utilityButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-align:center; margin-top:6px; text-decoration:none;', + settingsButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:transparent; color:#ddd; text-align:center; margin-top:6px; text-decoration:none;', + headerButtonContainer: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', + headerButton: 'float:right; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', + headerSubButton: 'font-size:11px; padding:1px 6px; background:#444; color:#ddd; border:1px solid #444; border-radius:2px; text-decoration:none; margin-top:-2px;', + nowPlayingButton: 'color:#ccc; padding:2px 4px; display:block; text-decoration:none; background:#444; border-radius:4px; margin-top:6px;', + refreshButton: 'font-size:10px; margin-top:8px; display:block; color:#66aaff; text-decoration:underline; cursor:pointer;', + + //announce styles + announceButton: 'color:#888; font-size:10px; padding:0px 4px; display:inline-block; text-decoration:none; margin-top:4px;', + announceTitle: 'display:inline-block; font-size:16px; font-weight:bold; color:#ccc; margin-top:4px;', + announceDesc: 'margin-top:4px; font-size:11px; color:#aaa; line-height:15px;', + + // Sidebar Links & Rules + sidebarRule: 'border:0; border-top:1px solid #444; margin:20px 0 3px 0;', + sidebarLink: 'color:#ccc; padding:2px 4px; display:block; text-decoration:none;', + albumSelectedLink: 'background:#993333; color:#eee; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + playlistSelectedLink: 'background:#334477; color:#eee; padding:2px 4px; display:block; border-radius:4px; text-decoration:none;', + + // Album/Playlist Tags + tags: 'margin-top:4px; margin-left:38px; display:block;', + albumTag: 'display:inline-block; background:#993333; color:#eee; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + playlistTag: 'display:inline-block; background:#334477; color:#eee; border-radius:4px; padding:2px 6px; font-size:10px; margin-right:2px; vertical-align:middle;', + tagRemove: 'color:#eee; margin-left:2px; cursor:pointer;', + + // Toggle Buttons + toggleButton: 'display:inline-block; width:45%; padding:6px 0; font-weight:bold; border:1px solid #555; border-radius:4px; text-align:center; margin-right:4px;', + toggleActiveAlbums: 'background:#993333; color:#eee;', + toggleActivePlaylists: 'background:#334477; color:#eee;', + toggleInactive: 'background:#444; color:#aaa;', + + //Chat message Styles + messageContainer: 'font-family: Nunito, Arial, sans-serif; background-color:#222; color:#ccc; padding:10px; position:relative; top:-15px; left:-5px; Border: solid 1px #444; border-radius:5px', + messageTitle: 'color:#ddd; font-size:16px; text-transform: capitalize; text-align:center;margin-bottom:13px;', + messageButton: 'display:inline-block; background:#444; color:#ccc; border-radius:4px; padding:2px 6px; margin-right:2px; vertical-align:middle', + descHelp: 'margin-top:4px; font-size:15px; color:#eee; ', + + // Track Item Styles + track: 'border-bottom:1px solid #444; padding:6px 0; display:table; width:100%; color:#ccc;', + trackTitle: 'display:inline-block; font-size:18px; font-weight:bold; color:#ccc;margin-top:2px;', + controls: 'float:right; margin-top:-2px;', + controlButtonImg: 'width:16px; height:16px; margin: 4px 2px; vertical-align:middle; cursor:pointer;', + desc: 'margin-top:4px; font-size:13px; color:#aaa; margin-left:38px;', + vol: 'font-size:11px; margin-top:4px; color:#999; margin-left:108px;', + albumEditLink: 'font-size:10px; margin-left:4px; vertical-align:middle; color:#aaa;', + descEditLink: 'font-size:10px; color:#888; font-style:italic; margin-left:6px; cursor:pointer;', + code: 'display:inline-block; font-size:0.75em; font-family:monospace; font-weight:bold; color:eee; background-color:#444; padding:1px 4px 0px 4px; margin-left:4px; border-radius:3px; user-select:none;', + + // Images + image: 'width:100px; height:100px; background:#444; text-align:center; font-size:11px; color:#999; border:1px solid #666; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', + imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #666; margin-right:8px; float:left; display:block;', + imagePlaceholder: 'width:100px; background:#444; color:#999; text-align:center; font-size:11px; border:1px solid #666; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', + + // Album specific + albumImage: 'width:80px; height:80px; object-fit:cover; border:1px solid #666; margin-right:8px;', + albumHeaderDesc: 'font-size:12px; color:#bbb;', + addAlbum: 'font-size:10px; margin-top:8px; display:block; color:#ccc;' + }; + + + // Set active theme styles and icons based on saved mode + let css = data.settings.mode === 'light' ? cssLight : cssDark; + let icons = data.settings.mode === 'light' ? iconSetLight : iconSetDark; + +let mixTimeoutId = null; + + + + const renderHelpView = () => + { + const handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + if(!handout) return; + + const css = data.settings.mode === 'light' ? cssLight : cssDark; + +const helpHTML = ` +
+ Jukebox Plus — Help + Return to Player +
+
+ +
Getting Started
+
+Jukebox Plus lets you organize and control music tracks by albums or playlists. +Use the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control +buttons appear for each one. +
+

+ +
Header Buttons
+
+At the top right of the interface: +
+ 10 tracks +
Play All + Together + In Order + Loop + Mix +
+
Loop All + Off + On +
+ Stop All + Find +Help + +

+
+ + Play All
+
+ Together — Plays all visible tracks simultaneously. Limited to the first five visible.
+ In order — Plays all visible tracks one after the other.
+ Loop — Plays all visible tracks one after the other, then starts over.
+ Mix — Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by StopAll
+
+ + Loop
+
+ Off — Disables loop mode for all visible tracks
+ On — Enables loop mode for all visible tracks
+
+ + Stop All — Stops all currently playing tracks. Also use to stop a Mix.
+ Find — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called Found. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel.
If you input "d" as the search term, it will create a temporary album of any duplicate tracks, grouped by name.
+ Help — Displays this help page. Click Return to Player to return. +
+ +

+ +
Sidebar: Navigation & Now Playing
+
+
+ View Mode Toggle
+The left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view. +
+ +
+These buttons let you switch between organizing by: +
+Album tags or by manual Playlists. Albums are groupings of tracks that you define through Jukebox Plus. You can make as many of these as you like, and any track may belong to multiple albums. +Playlists are managed by the Roll20 Jukebox interface. You can view and play them here, but you cannot move them about. +
+
+At the bottom of the list is:
+Now Playing Choosing this filters the list to show only tracks currently playing. +
+

+ +
Track Controls
+
+Each track shows these control buttons:
+ +
+ Play: Start the track.
+ Loop: Toggle loop mode for the track.
+ Isolate: Stops all others and plays only this one.
+ Stop: Stops this track.
+ Announce: Sends the track name and description to the chat window.
+
+

+ +
Track Info and Management
+
+Edit — Click the track description "edit" link to create a description.
+
+ Description special characters:
+ "---" to insert a line break.
+ "*italic*" surround a word in single asterisks to have it display in italic
+ "**bold**" surround a word in double asterisks to have it display in bold
+ "!d" or "!desc" to include the description of the track when you announce it. Default is title only.
+ "!a" or "!announce" to have a track announce itself automatically whenever you play it. Default is manual announcement only. +
+
+Tags — Each track has a Playlist tag, and the ability to add album tags. Playlist tags are in blue, and album tags are in red. Click + Add to add a track to an Album. Click a Playlist or Album tag to jump immediately to that Playlist or Album. Click the "x" in an Album tag to remove the track from that Album: Album name | x
+Image Area — Click the image area to submit a valid image URL or a hexadecimal color code, such as "#00ff00". You can also enter a common CSS color name such as "red".
+If you submit an image URL, the image will display here next to the title, or in the chat tab while Announcing a track. The URL can come from your Roll20 image library or any valid image host.
+If you submit a valid color code or name, the square will turn that color, and that color will be used when Announcing a track. +
+
+
+ +
Utility Panel
+
+Click to expand the utility tools. Includes: +
+
+
+
Edit Albums: + + + + +
+ +These buttons change the name of an album, add a new album, or remove the currently selected album. There is no verification, so use with care. +
+ +
+
A—Z +albums +tracks +
+These buttons alphabetize Albums, or Tracks within an Album. + + +
+
Mode: +dark +light +
+ +These buttons switch between light and dark mode. + + Rebuilds the interface if something breaks. + +
+
Backup +make +restore +
+These buttons create a backup handout of the custom data you have entered: playlists, descriptions, and images. Higher numbered handouts are later backups. You can restore from a backup if your data gets screwed up, or you can transmogrify or copy the handout to a new game and restore from there. This is a useful way to move your customizations from game to game. Use with caution — Roll20 stores tracks by ID number which are different in every game, and the script tries hard to match title to ID. If you have multiple tracks with the same name or have renamed a track, this may not perform as expected. +
+

+ +
Find
+
+Use the !jb find keyword command to search all track names and descriptions for the keyword. +All matching tracks will be assigned to a temporary album called Found. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the utility panel. +
+

+ +
Useful Macros
+
+Here are some chat commands that can be used in macros:
+
+ !jb — Puts a link to this handout in chat
+ !jb play TrackName — play the named track
+ !jb stopall — stops all audio
+ !jb loopall — sets loop mode on all visible tracks
+ !jb unloopall — disables loop mode on all tracks
+ !jb jump album AlbumName — switch to a specific album
+ !jb help — open this help screen
+ !jb find keyword search for tracks by keyword in name or description
+
+ You can also discover commands by pressing a button, clicking in the chat window, and pressing the up arrow to see what was sent. +
+




+`; + + + + handout.set('notes', helpHTML); + }; + + function sendStyledMessage(titleOrMessage, messageOrUndefined, isPublic = false) + { + let title, message; + + if(messageOrUndefined === undefined) + { + title = 'Jukebox Plus'; + message = titleOrMessage; + } + else + { + title = titleOrMessage || 'Jukebox Plus'; + message = messageOrUndefined; + } + + message = String(message); // ← Fix added here + + + // Replace markdown-style [label](command) with styled + message = message.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, command) => + { + return `${label}`; + }); + + const html = `
${title}
${message}
`; + + const target = isPublic ? '' : '/w gm '; + sendChat('Jukebox Plus', `${target}${html}`, null, + { + noarchive: true + }); + } + +const renderFormattedText = (text) => { + if(!text) return ''; + return esc(text) + .replace(/---+/g, '
') // replace --- with
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** + .replace(/\*(.+?)\*/g, `$1`) + .replace(/`(.+?)`/g, '$1') // `code` + .replace(/!a/gi, `announce`) // announce codes + .replace(/!d/gi, `desc`); +}; + + +const escapeForRoll20Query = (str) => { + if (!str) return ''; + return str + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\?/g, '\\?') + .replace(/\{/g, '\\{') + .replace(/\}/g, '\\}'); +}; + + + + + + + const esc = (s) => s.replace(/[&<>"']/g, c => ( + { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } [c])) + .replace(/\/{2}/g, '
'); + +const cssNamedColors = new Set([ + 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', + 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', + 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', + 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', + 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', + 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', + 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', + 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', + 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', + 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', + 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', + 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', + 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', + 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', + 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', + 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', + 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', + 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', + 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', + 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', + 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', + 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', + 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', + 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', + 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', + 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', + 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', + 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen' +]); + + + +let sequentialPlayState = { + trackIds: [], + currentIndex: -1, + active: false, + loop: false // <--- New! +}; + +const getVisibleTrackList = () => { + const getPlaylistTracks = () => + { + const plist = data.playlists[data.settings.selectedPlaylist]; + return Array.isArray(plist) ? plist : []; + }; + + if (data.settings.nowPlayingOnly) { + return Object.values(data.tracks).filter(t => { + const actual = getAllTracks().find(j => j.id === t.id); + return actual && actual.get('playing'); + }); + } + + if (data.settings.viewMode === 'albums') { + const selected = data.settings.selectedAlbum; + let tracks = Object.values(data.tracks).filter(t => t.albums.includes(selected)); + + if (selected === 'Duplicates') { + tracks.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + } else if (data.trackSortOrder?.length) { + const ordered = data.trackSortOrder + .map(title => tracks.find(t => t.title === title)) + .filter(Boolean); + const leftovers = tracks.filter(t => !data.trackSortOrder.includes(t.title)); + tracks = [...ordered, ...leftovers]; + } + + return tracks; + } + + if (data.settings.viewMode === 'playlists') { + const selected = data.settings.selectedPlaylist; + const trackIds = data.playlists[selected] || []; + return trackIds.map(id => data.tracks[id]).filter(Boolean); + } + + return []; +}; + + + const getAllTracks = () => findObjs( + { + _type: 'jukeboxtrack' + }) || []; + + // Sync playlists by walking the jukebox folder structure and building playlists object + const syncPlaylists = () => + { + let folderJSON = Campaign() + .get('jukeboxfolder'); + if(!folderJSON) + { + log('Jukebox Plus: No jukebox folder found.'); + data.playlists = { + 'Unassigned': [] + }; + return; + } + + let folder; + try + { + folder = JSON.parse(folderJSON); + } + catch (e) + { + log('Jukebox Plus: Failed to parse jukeboxfolder JSON:', e); + data.playlists = { + 'Unassigned': [] + }; + return; + } + + log('Jukebox Plus: jukeboxfolder parsed:', JSON.stringify(folder)); + + // Clear previous playlists before repopulating + data.playlists = {}; + + // Flatten walk through the folder structure + // Each element is an object: { n: playlist name, i: array of track IDs } + folder.forEach(playlist => + { + if(!playlist.n || !Array.isArray(playlist.i)) return; + + const playlistName = playlist.n; + if(!data.playlists[playlistName]) + { + data.playlists[playlistName] = []; + } + + playlist.i.forEach(trackId => + { + const track = data.tracks[trackId]; + if(track) + { + // Add track ID to playlist if not already present + if(!data.playlists[playlistName].includes(trackId)) + { + data.playlists[playlistName].push(trackId); + } + // Also ensure this playlist is tracked in track.albums or track.playlists if you want + } + else + { + log(`Jukebox Plus: Track ID [${trackId}] not found in jukebox tracks.`); + } + }); + }); + + // If no playlists found, fallback to Unassigned + if(Object.keys(data.playlists) + .length === 0) + { + log('Jukebox Plus: No playlists created, adding Unassigned fallback.'); + data.playlists = { + 'Unassigned': [] + }; + } + }; + + // Sync tracks and playlists + const syncTracks = () => + { + getAllTracks() + .forEach(track => + { + const id = track.get('_id'); + const title = track.get('title'); + const volume = track.get('volume'); + if(!data.tracks[id]) + { + data.tracks[id] = { + id, + title, + volume, + albums: [], + description: '', + image: '', + sortOrder: + {} + }; + } + else + { + data.tracks[id].title = title; + data.tracks[id].volume = volume; + } + }); + syncPlaylists(); + }; + +const buildTrackRow = (track) => +{ + const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(track.image || ''); + const isNamedColor = cssNamedColors.has((track.image || '').toLowerCase()); + const isURL = /^https?:\/\/.+/.test(track.image || ''); + + const img = track.image + ? ` +
+
+
` + : ` +
click to add image or color code
+
`; + + // Build album options respecting albumSortOrder, exclude albums already assigned to track + const albumOptions = (data.albumSortOrder && data.albumSortOrder.length + ? data.albumSortOrder.filter(a => data.albums.hasOwnProperty(a)) + : Object.keys(data.albums || {})) + .filter(a => !track.albums.includes(a)) + .concat('New Album'); + + // Escape and join for prompt + const addAlbumQuery = albumOptions.map(a => esc(a)).join('|'); + + const desc = track.description ? + `
${renderFormattedText(track.description)} [edit]
` : + ``; + + // Live state of the jukebox track + const actualTrack = getAllTracks() + .find(t => t.id === track.id); + const isPlaying = actualTrack && actualTrack.get("playing"); + const isLooping = actualTrack && actualTrack.get("loop"); + + const playImg = isPlaying ? icons.playActive : icons.play; + const loopImg = isLooping ? icons.loopActive : icons.loop; + + // Determine view modes + const isAlbumView = data.settings.viewMode === 'albums' && !data.settings.nowPlayingOnly; + const isNowPlaying = data.settings.nowPlayingOnly; + + // Determine playlist tag HTML + let playlistTagHTML = ''; + if ((isAlbumView || isNowPlaying) && data.playlists) { + const matchingPlaylist = Object.entries(data.playlists) + .find(([name, ids]) => + ids.includes(track.id) + ); + if (matchingPlaylist) { + const [playlistName] = matchingPlaylist; + const encoded = encodeURIComponent(playlistName); + playlistTagHTML = ` ${esc(playlistName)}`; + } + } + + // Build add album button with sorted options + const addAlbumButton = `+ Add`; + + return ` +
+ ${img} +
+
+
+ ${esc(track.title)} + +
+ + Play + Loop + Isolate + Stop + +
+ ${desc} +
+ ${ + (track.albums || []).map(name => + ` + ${esc(name)} +  |  + x + ` + ).join(' ') + } + ${addAlbumButton} + ${playlistTagHTML} +
+
+
+`; +}; + + + + +const getTrackFlags = (track) => { + const desc = (track.description || '').toLowerCase(); + return { + announce: desc.includes('!a') || desc.includes('!announce'), + includeDesc: desc.includes('!d') || desc.includes('!desc') + }; +}; + +const getSortedAlbumNames = () => { + let albumNames = data.albumSortOrder?.length + ? data.albumSortOrder.filter(name => data.albums.hasOwnProperty(name)) + : Object.keys(data.albums); + + albumNames = albumNames.filter(name => name !== 'Found'); + if ('Found' in data.albums) albumNames.push('Found'); + + return albumNames; +}; + + +const updateInterface = () => +{ + css = data.settings.mode === 'light' ? cssLight : cssDark; + icons = data.settings.mode === 'light' ? iconSetLight : iconSetDark; + + const handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + if(!handout) return; + + // Ensure selected playlist/album exists, fallback if not + if(data.settings.viewMode === 'playlists') + { + let selected = data.settings.selectedPlaylist; + if(!selected || !data.playlists[selected]) + { + const keys = Object.keys(data.playlists); + if(keys.length) + { + selected = keys[0]; + } + else + { + selected = 'Unassigned'; + data.playlists[selected] = []; + } + data.settings.selectedPlaylist = selected; + } + } + if(data.settings.viewMode === 'albums') + { + let selected = data.settings.selectedAlbum; + if(!selected || !data.albums[selected]) + { + const keys = Object.keys(data.albums); + data.settings.selectedAlbum = keys.length ? keys[0] : ''; + } + } + + const getPlaylistTracks = () => + { + const plist = data.playlists[data.settings.selectedPlaylist]; + return Array.isArray(plist) ? plist : []; + }; + + const toggleHTML = ` + + `; + + const isAnyTrackPlaying = getAllTracks() + .some(t => t.get('playing')); + + const sidebarList = (() => + { + const entries = []; + + if(data.settings.viewMode === 'albums') + { +const albumNames = data.albumSortOrder?.length + ? data.albumSortOrder.filter(name => data.albums.hasOwnProperty(name)) + : Object.keys(data.albums); + +getSortedAlbumNames().forEach(albumName => { + const encodedName = encodeURIComponent(albumName); + const style = (albumName === data.settings.selectedAlbum && !data.settings.nowPlayingOnly) + ? css.albumSelectedLink + : css.sidebarLink; + entries.push(`${esc(albumName)}`); +}); + + } + else + { + Object.keys(data.playlists || {}) + .forEach(playlistName => + { + const encodedName = encodeURIComponent(playlistName); + const style = (playlistName === data.settings.selectedPlaylist && !data.settings.nowPlayingOnly) ? + css.playlistSelectedLink : + css.sidebarLink; + entries.push(`${esc(playlistName)}`); + }); + } + + // Highlight Now Playing if active + const nowPlayingStyle = data.settings.nowPlayingOnly ? + (data.settings.viewMode === 'albums' ? css.albumSelectedLink : css.playlistSelectedLink) : + css.sidebarLink; + + entries.push(`Now Playing`); + + return entries.join(''); + })(); + + const visibleTracks = Object.values(data.tracks) + .filter(track => + { + let matchesView = false; + + if(data.settings.viewMode === 'albums') + { + matchesView = data.settings.selectedAlbum ? + track.albums.includes(data.settings.selectedAlbum) : + true; + } + else + { + const plist = getPlaylistTracks(); + matchesView = plist.includes(track.id); + } + + if(data.settings.nowPlayingOnly) + { + const t = getAllTracks() + .find(t => t.id === track.id); + return matchesView && t && t.get('playing'); + } + + return matchesView; + }); + +const trackList = getVisibleTrackList().map(buildTrackRow).join(''); + + + + const utilityToggleText = data.settings.utilityExpanded ? 'Settings ▲' : 'Settings ▼'; + + const utilityToggleButton = ` + Settings ${data.settings.settingsExpanded ? '▴' : '▾'} +`; + + const utilityButtons = data.settings.settingsExpanded ? ` + ${utilityToggleButton} + + +
+ A–Z + tracks + albums +
+ + ↻ Refresh + +
+ Mode + light + dark +
+ +
+ Backup + make + restore +
+` : utilityToggleButton; + + const html = ` + + + + + + + + +
+ Jukebox Plus + + ${data.settings.helpVisible ? 'Return to Player' : 'Help'} + + Find Tracks + Stop All + +
Loop All + Off + On +
+ +
Play All + Together + In Order + Loop + Mix + +
+ + + + + ${visibleTracks.length} track${visibleTracks.length !== 1 ? 's' : ''} + +
+ ${toggleHTML} + ${sidebarList} +
+ ${utilityButtons} +
${trackList}
+`; + + handout.set('notes', html); +}; + + + + const sendHandoutLink = () => + { + let handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + + if(!handout) + { + handout = createObj('handout', + { + name: HANDOUT_NAME, + inplayerjournals: 'all', + archived: false + }); + + // Defer rendering just slightly to allow Roll20 to index the handout + setTimeout(() => updateInterface(), 500); + } + + sendStyledMessage(`[Open Jukebox Plus Handout](http://journal.roll20.net/handout/${handout.id})`); + + }; + +on('change:jukeboxtrack', (obj, prev) => { + if (!sequentialPlayState.active) return; + + // Detect when a track finishes playing (softstop turns true) + if (prev.softstop === false && obj.get('softstop') === true && !obj.get('loop')) { + const currentId = sequentialPlayState.trackIds[sequentialPlayState.currentIndex]; + + if (obj.id === currentId) { + obj.set('playing', false); // Optional cleanup + + sequentialPlayState.currentIndex++; + + // If we've reached the end of the list + if (sequentialPlayState.currentIndex >= sequentialPlayState.trackIds.length) { + if (sequentialPlayState.loop) { + sequentialPlayState.currentIndex = 0; + } else { + sequentialPlayState.active = false; + sendStyledMessage('Sequence finished', 'Finished playing all tracks sequentially.'); + updateInterface(); + return; + } + } + + // Play next track in sequence + const nextId = sequentialPlayState.trackIds[sequentialPlayState.currentIndex]; + const nextTrack = getAllTracks().find(t => t.id === nextId); + if (nextTrack) { + nextTrack.set('softstop', false); // Reset in case it was marked finished before + nextTrack.set('playing', true); + } else { + sequentialPlayState.currentIndex++; // skip missing track + } + + updateInterface(); + } + } +}); + + + + on('chat:message', (msg) => + { + if(msg.type !== 'api' || !msg.content.startsWith('!jb')) return; + const match = msg.content.slice(3) + .trim() + .match(/(?:"[^"]*"|'[^']*'|\S)+/g); + const args = match ? match.map(s => s.replace(/^['"]|['"]$/g, '')) : []; + const command = args.shift() || ''; + + const findTrackByIdOrName = (idOrName) => + { + return data.tracks[idOrName] || Object.values(data.tracks) + .find(t => t.title === idOrName); + }; + + if(command === '') + { + sendHandoutLink(); + return; + } + + if(command === 'help') + { + const sub = args[0]?.toLowerCase(); + data.settings.helpVisible = (sub !== 'close'); + if(data.settings.helpVisible) + { + renderHelpView(); + } + else + { + updateInterface(); + } + return; + } + + + if(["play", "loop", "stop", "isolate"].includes(command)) + { + const idOrName = args.join(' ') + .trim(); + const track = findTrackByIdOrName(idOrName); + if(track) + { + const actual = getAllTracks() + .find(t => t.id === track.id); + if(actual) + { +if (command === "play") { + actual.set("playing", true); + const flags = getTrackFlags(track); + if (flags.announce) { + const descHtml = flags.includeDesc ? `
${esc(track.description || '')}
` : ''; + const imageHtml = track.image ? + `` : + ''; + const messageHtml = `${imageHtml}
${esc(track.title)}
${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); + } +} +if(command === "stop") actual.set("playing", false); + if(command === "loop") actual.set("loop", !actual.get("loop")); + if(command === "isolate") + { + getAllTracks() + .forEach(t => t.set("playing", t.id === track.id)); + } + } + else + { +sendStyledMessage( + 'Track Not Playable', + `"${esc(track.title)}"

This track is listed in your saved data, but no matching Roll20 jukebox track exists.

This can happen if the track was deleted, if it was imported from another game, or if its ID changed. If you are sure this track no longer exists or needed, you can remove it from your saved data.

Remove this broken track
`, + false +); + } + } + else + { + sendStyledMessage('Warning', 'Track not found: ' + idOrName); + } + + // Refresh the interface to show correct play/loop icons + updateInterface(); + } + + +if(command === 'playall') +{ + const trackList = (() => + { + if(data.settings.viewMode === 'albums') + { + const albumName = data.settings.selectedAlbum; + return Object.values(data.tracks) + .filter(t => t.albums.includes(albumName)); + } + else if(data.settings.viewMode === 'playlists') + { + const trackIds = data.playlists[data.settings.selectedPlaylist] || []; + return trackIds.map(id => data.tracks[id]) + .filter(Boolean); + } + return []; + })(); + + const actualTracks = trackList + .map(t => getAllTracks() + .find(j => j.id === t.id)) + .filter(t => t); + + const max = 5; + + actualTracks.slice(0, max) + .forEach(t => { + t.set('playing', true); + + const flags = getTrackFlags(data.tracks[t.id]); + if (flags.announce) { + const descHtml = flags.includeDesc ? `
${esc(data.tracks[t.id].description || '')}
` : ''; + const imageHtml = data.tracks[t.id].image ? + `` : + ''; + const messageHtml = `${imageHtml}
${esc(data.tracks[t.id].title)}
${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); + } + }); + + if(actualTracks.length > max) + { + sendStyledMessage('Notice', 'Only the first 5 tracks were played to avoid clutter.'); + } + + updateInterface(); +} + +if (command === 'playall-seq') { + const visibleTracks = getVisibleTrackList(); + const max = 20; + + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + // Store track IDs for sequential playing + sequentialPlayState.trackIds = actualTracks.map(t => t.id); + sequentialPlayState.currentIndex = 0; + sequentialPlayState.active = true; + + // Start first track + const firstTrack = actualTracks[0]; + firstTrack.set('softstop', false); + firstTrack.set('playing', true); + + updateInterface(); +} + + +if(command === 'playall-seq-loop') { + const visibleTracks = getVisibleTrackList(); // Same display order used by updateInterface + + const max = 20; + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + sequentialPlayState.trackIds = actualTracks.map(t => t.id); + sequentialPlayState.currentIndex = 0; + sequentialPlayState.active = true; + sequentialPlayState.loop = true; // 👈 enables looping after the last track + + // Stop all currently playing tracks + getAllTracks().forEach(t => t.set('playing', false)); + + const firstTrack = actualTracks[0]; + firstTrack.set('softstop', false); // ensure it will start + firstTrack.set('playing', true); + + + updateInterface(); +} + +// Global timer variable, declared once outside the command handler +let mixAccentTimer = null; + +if (command === 'mix') { + if (args[0] === 'stop') { + sendStyledMessage('Info', 'Use !jb stopall to stop all playback including mix mode.'); + return; + } + + // Cancel existing timer + if (mixAccentTimer) { + clearTimeout(mixAccentTimer); + mixAccentTimer = null; + } + + // Don't store timer in state! + state.GraphicJukebox.mixSession = null; + + const visibleTracks = getVisibleTrackList(); + const max = 20; + const actualTracks = visibleTracks + .map(t => getAllTracks().find(j => j.id === t.id)) + .filter(Boolean) + .slice(0, max); + + if (actualTracks.length === 0) { + sendStyledMessage('No tracks', 'No tracks found to play.'); + return; + } + + const loopingTracks = actualTracks.filter(t => t.get('loop')); + const nonLoopingTracks = actualTracks.filter(t => !t.get('loop')); + + // Stop all tracks + getAllTracks().forEach(t => t.set('playing', false)); + + // Start looping tracks + loopingTracks.forEach(t => { + t.set('softstop', false); + t.set('playing', true); + }); + + // Save only IDs in state (no timer) + state.GraphicJukebox.mixSession = { + tracks: nonLoopingTracks.map(t => t.id) + }; + + // Inner accent track loop + const playRandomAccent = () => { + const mix = state.GraphicJukebox.mixSession; + if (!mix || !Array.isArray(mix.tracks) || mix.tracks.length === 0) return; + + const randomId = mix.tracks[Math.floor(Math.random() * mix.tracks.length)]; + const track = getAllTracks().find(t => t.id === randomId); + if (track) { + track.set('softstop', false); + track.set('playing', true); + } + + // Re-schedule next play + if (state.GraphicJukebox.mixSession) { + const nextInterval = 10000 + Math.floor(Math.random() * 50000); + mixAccentTimer = setTimeout(playRandomAccent, nextInterval); + } + }; + + // First call after short delay + mixAccentTimer = setTimeout(playRandomAccent, 3000); + + updateInterface(); +} + + +if (command === 'stopall') { + getAllTracks().forEach(t => t.set('playing', false)); + + // Cancel mix timer if active + if (mixAccentTimer) { + clearTimeout(mixAccentTimer); + mixAccentTimer = null; + } + + state.GraphicJukebox.mixSession = null; + + updateInterface(); +} + + + + else if(command === 'loopall' || command === 'unloopall') + { + const trackList = (() => + { + if(data.settings.viewMode === 'albums') + { + const albumName = data.settings.selectedAlbum; + return Object.values(data.tracks) + .filter(t => t.albums.includes(albumName)); + } + else if(data.settings.viewMode === 'playlists') + { + const trackIds = data.playlists[data.settings.selectedPlaylist] || []; + return trackIds.map(id => data.tracks[id]) + .filter(Boolean); + } + return []; + })(); + const shouldLoop = (command === 'loopall'); + + const actualTracks = trackList + .map(t => getAllTracks() + .find(j => j.id === t.id)) + .filter(t => t); + + actualTracks.forEach(t => t.set('loop', shouldLoop)); + + updateInterface(); + } + + if(command === 'refresh') + { + syncTracks(); + updateInterface(); + sendStyledMessage('Track data refreshed.'); + } + + if(command === 'backup') + { + const backupData = { + tracks: + {}, + albums: + { + ...data.albums + }, + playlists: + {}, + albumSortOrder: + { + ...data.albumSortOrder + } + }; + + // Store track data keyed by title + Object.values(data.tracks) + .forEach(t => + { + backupData.tracks[t.title] = { + title: t.title, + description: t.description, + image: t.image, + albums: t.albums.slice(), + volume: t.volume + }; + }); + + // Store playlist data using track titles + Object.entries(data.playlists) + .forEach(([playlistName, trackIds]) => + { + const titles = trackIds.map(id => data.tracks[id]?.title) + .filter(Boolean); + backupData.playlists[playlistName] = titles; + }); + + // Generate sequential backup handout name + let index = 1; + let name = `Jukebox Backup 001`; + while(findObjs( + { + _type: 'handout', + name + }) + .length > 0) + { + index++; + name = `Jukebox Backup ${String(index).padStart(3, '0')}`; + } + + const handout = createObj('handout', + { + name, +archived: true + }); + + handout.set('notes', `
${JSON.stringify(backupData, null, 2)}
`); + sendStyledMessage('Backup created', `[${name}](http://journal.roll20.net/handout/${handout.id})`); + } + + + if(command === 'restore') + { + const backupName = args.join(' ') + .trim(); + const handout = findObjs( + { + _type: 'handout', + name: backupName + })[0]; + + if(!handout) + { + sendStyledMessage(`Backup handout not found: ${backupName}`); + return; + } + + handout.get('notes', notes => + { + const raw = notes.replace(/^
|<\/pre>$/g, '')
+                    .trim();
+                let backup;
+
+                try
+                {
+                    backup = JSON.parse(raw);
+                }
+                catch (e)
+                {
+                    sendStyledMessage('Backup', `Failed to parse backup JSON in "${backupName}".`);
+                    return;
+                }
+
+                const titleToId = {};
+                getAllTracks()
+                    .forEach(track =>
+                    {
+                        titleToId[track.get('title')] = track.get('_id');
+                    });
+
+                const restoredTracks = {};
+                Object.values(backup.tracks ||
+                    {})
+                    .forEach(bt =>
+                    {
+                        const id = titleToId[bt.title];
+                        if(id)
+                        {
+                            restoredTracks[id] = {
+                                id,
+                                title: bt.title,
+                                description: bt.description || '',
+                                image: bt.image || '',
+                                albums: bt.albums || [],
+                                volume: bt.volume ?? 0.5,
+                                sortOrder:
+                                {}
+                            };
+                        }
+                        else
+                        {
+                            sendStyledMessage('Restore', `Track not found in current game: "${bt.title}"`);
+                        }
+                    });
+
+                const restoredPlaylists = {};
+                Object.entries(backup.playlists ||
+                    {})
+                    .forEach(([plistName, titles]) =>
+                    {
+                        restoredPlaylists[plistName] = titles
+                            .map(t => titleToId[t])
+                            .filter(Boolean);
+                    });
+
+                // Apply restored data
+                data.tracks = restoredTracks;
+                data.albums = {
+                    ...backup.albums
+                };
+                data.albumSortOrder = {
+                    ...backup.albumSortOrder
+                };
+                data.playlists = restoredPlaylists;
+
+                updateInterface();
+                sendStyledMessage('Restore', `Backup "${backupName}" restored successfully.`);
+            });
+        }
+
+if(command === 'delete-track') {
+    const id = args.join(' ').trim();
+    const track = data.tracks[id];
+    if(track) {
+        delete data.tracks[id];
+        sendStyledMessage('Track Removed', `Track "${esc(track.title)}" has been removed from your saved data.`, false);
+        updateInterface();
+    } else {
+        sendStyledMessage('Error', 'Track not found in saved data.', false);
+    }
+}
+
+if (command === 'announce') {
+    const idOrName = args.join(' ').trim();
+    const track = findTrackByIdOrName(idOrName);
+
+    if (!track) {
+        sendStyledMessage('Warning', 'Track not found.');
+        return;
+    }
+
+    const actual = getAllTracks().find(t => t.id === track.id);
+    if (!actual) {
+        sendStyledMessage('Warning', 'Track ID found but not playable: ' + track.title);
+        return;
+    }
+
+    const flags = getTrackFlags(track);
+
+    const value = (track.image || '').trim();
+    const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value);
+    const isNamedColor = /^[a-zA-Z]+$/.test(value);
+    const isImageURL = /^https?:\/\/.+/.test(value);
+
+    // Convert hex to luminance to determine brightness
+    const isDarkHex = (hex) => {
+        let r, g, b;
+        hex = hex.replace('#', '');
+        if (hex.length === 3) {
+            r = parseInt(hex[0] + hex[0], 16);
+            g = parseInt(hex[1] + hex[1], 16);
+            b = parseInt(hex[2] + hex[2], 16);
+        } else {
+            r = parseInt(hex.substr(0, 2), 16);
+            g = parseInt(hex.substr(2, 2), 16);
+            b = parseInt(hex.substr(4, 2), 16);
+        }
+        const luminance = 0.2126*r + 0.7152*g + 0.0722*b;
+        return luminance < 128;
+    };
+
+    // Guess named color brightness (simple hardcoded list for safety)
+    const darkNamedColors = ['black', 'navy', 'purple', 'maroon', 'darkgreen', 'teal', 'indigo', 'midnightblue', 'darkblue', 'darkslategray'];
+    const isDarkNamed = darkNamedColors.includes(value.toLowerCase());
+
+    let imageHtml = '';
+    let titleHtml = `
${esc(track.title)}
`; + + if (value && (isHexColor || isNamedColor)) { + const isDark = isHexColor ? isDarkHex(value) : isDarkNamed; + const textColor = isDark ? '#fff' : '#111'; + imageHtml = `
${esc(track.title)}
`; + titleHtml = ''; // Suppress normal title line + } else if (isImageURL) { + imageHtml = ``; + } + + let cleanDesc = track.description || ''; + if (flags.includeDesc) { + cleanDesc = cleanDesc.replace(/\s*!a(nnounce)?\b/gi, ''); + cleanDesc = cleanDesc.replace(/\s*!d(esc)?\b/gi, ''); + } + + const descHtml = flags.includeDesc + ? `
${renderFormattedText(cleanDesc.trim())}
` + : ''; + + const messageHtml = `${imageHtml}${titleHtml}${descHtml}`; + sendStyledMessage('Now Playing', messageHtml, true); +} + + + + + + + if(command === 'view') + { + const mode = args[0]; + if(['albums', 'playlists'].includes(mode)) + { + data.settings.viewMode = mode; + updateInterface(); + } + } + + if(command === 'view' && args[0] === 'nowplaying') + { + data.settings.nowPlayingOnly = true; + updateInterface(); + } + + + if(command === 'view' && args[0] === 'all') + { + data.settings.nowPlayingOnly = false; + updateInterface(); + } + +if(command === 'jump' && args[0] === 'album') +{ + const encodedName = args.slice(1).join(' ').trim(); + const name = decodeURIComponent(encodedName); + + if(name in data.albums) + { + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = name; + updateInterface(); + } + else + { + sendStyledMessage(`Album not found: ${name}`); + } +} + + if(command === 'jump-playlist') + { + const name = decodeURIComponent(args.join(' ') + .trim()); + + if(!(name in data.playlists)) + { + sendStyledMessage(`Playlist not found: ${name}`); + return; + } + + data.settings.viewMode = 'playlists'; + data.settings.selectedPlaylist = name; + data.settings.nowPlayingOnly = false; + updateInterface(); + } + +if (command === 'sort-albums') { + const sorted = Object.keys(data.albums).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + data.albumSortOrder = sorted; + sendStyledMessage('Albums Sorted', 'Album list has been sorted alphabetically.'); + updateInterface(); +} + +if (command === 'sort-tracks') { + const sorted = Object.values(data.tracks) + .map(t => t.title) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + data.trackSortOrder = sorted; + sendStyledMessage('Tracks Sorted', 'Track database has been sorted alphabetically.'); + updateInterface(); +} + + +if(command === 'edit') +{ + const idOrName = args.shift(); + const field = args.shift(); + const value = args.join(' ').trim(); + const track = findTrackByIdOrName(idOrName); + if(!track) + { + sendStyledMessage(`Track not found: ${idOrName}`); + return; + } + + if(field === 'image') + { + const isHexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value); + const isNamedColor = cssNamedColors.has(value.toLowerCase()); + + if(value === '') { + track.image = ''; // Clear image + } + else if(isHexColor || isNamedColor || value.length) + { + track.image = value; + } + else + { + sendStyledMessage(`Invalid input: must be a valid image URL or color code (hex or named).`); + return; + } + } + else if(field === 'description') + { + track.description = value; + } + else if(field === 'albums') + { + const [action, ...rest] = value.split(' '); + let target = decodeURIComponent(rest.join(' ').trim()); + + if(action === 'add') + { + if(target === 'New Album') + { + const player = getObj('player', msg.playerid); + const playerName = player ? player.get('displayname') : 'GM'; + const safeTrackId = track.id.replace(/[^A-Za-z0-9\-_]/g, ''); + + sendStyledMessage(`[Click here to create a new album and assign this track](!jb add-album-and-assign ${safeTrackId} ?{Enter new album name})`, false); + return; + } + + if(!track.albums.includes(target)) + { + track.albums.push(target); + } + } + else if(action === 'remove') + { + track.albums = track.albums.filter(a => a !== target); + } + } + + updateInterface(); +} + + + + if(command === 'add' && args[0] === 'album') + { + const albumName = args.slice(1) + .join(' ') + .trim(); + if(albumName) + { +data.albums[albumName] = true; +if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; +if (!data.albumSortOrder.includes(albumName)) data.albumSortOrder.push(albumName); +data.settings.selectedAlbum = albumName; +updateInterface(); + } + } + + if(command === 'add-album-and-assign') + { + const trackId = args.shift(); + const albumName = args.join(' ') + .trim(); + + if(!trackId || !albumName) + { + sendStyledMessage('Missing track ID or album name.', false); + + + + + return; + } + + + const track = data.tracks[trackId]; + if(!track) + { + sendStyledMessage('Track not found.', false); + return; + } + + // If "New Album" is selected, create it only if it doesn't already exist +if (!data.albums[albumName]) { + data.albums[albumName] = true; + if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; + if (!data.albumSortOrder.includes(albumName)) data.albumSortOrder.push(albumName); +} + + + if(!track.albums.includes(albumName)) + { + track.albums.push(albumName); + } + + updateInterface(); + } + + if(command === 'remove-album') + { + const name = args.join(' ') + .trim(); + if(name in data.albums) + { + delete data.albums[name]; + if (Array.isArray(data.albumSortOrder)) { + data.albumSortOrder = data.albumSortOrder.filter(n => n !== name); +} + + + // Remove the album from any tracks that had it + Object.values(data.tracks) + .forEach(track => + { + if(track.albums.includes(name)) + { + track.albums = track.albums.filter(a => a !== name); + } + }); + + // Reset selection if the deleted album was selected + if(data.settings.selectedAlbum === name) + { + const remaining = Object.keys(data.albums); + data.settings.selectedAlbum = remaining.length ? remaining[0] : ''; + } + + updateInterface(); + sendStyledMessage(`Album "${name}" has been removed.`, false); + } + else + { + sendStyledMessage(`Album "${name}" not found.`, false); + } + } + + if(command === 'rename-album') + { + const knownAlbums = Object.keys(data.albums) + .sort((a, b) => b.length - a.length); // Longest match first + const joinedArgs = args.join(' ') + .trim(); + + // Try to find which known album name this starts with + let oldName = null; + let newName = null; + + for(let album of knownAlbums) + { + if(joinedArgs.startsWith(album)) + { + oldName = album; + newName = joinedArgs.slice(album.length) + .trim(); + break; + } + } + + if(!oldName || !newName) + { + sendStyledMessage(`Could not determine album names. Got: ${joinedArgs}`, false); + return; + } + + if(!data.albums[oldName]) + { + sendStyledMessage(`Album "${oldName}" not found.`, false); + return; + } + + if(data.albums[newName]) + { + sendStyledMessage('Rename Failed', `An album named "${newName}" already exists.`, false); + return; + } + + // Rename in album list + data.albums[newName] = true; + delete data.albums[oldName]; + if (!Array.isArray(data.albumSortOrder)) data.albumSortOrder = []; +data.albumSortOrder = data.albumSortOrder.map(n => n === oldName ? newName : n); + + + // Update all tracks that had the old album name + Object.values(data.tracks) + .forEach(track => + { + if(track.albums?.includes(oldName)) + { + track.albums = track.albums.map(name => name === oldName ? newName : name); + } + }); + + // Switch view to the renamed album + data.view = { + mode: 'album', + name: newName + }; + + updateInterface(); + } + +if (command === 'find') { + const searchTerm = args.join(' ').toLowerCase().trim(); + + if (!searchTerm) { + sendStyledMessage('Find Tracks', 'You must provide a search term.', false); + return; + } + + // Remove previous "Found" or "Duplicates" albums + ['Found', 'Duplicates'].forEach(name => { + if (name in data.albums) { + delete data.albums[name]; + Object.values(data.tracks).forEach(track => { + if (track.albums && track.albums.includes(name)) { + track.albums = track.albums.filter(a => a !== name); + } + }); + } + }); + + if (searchTerm === 'd') { + // Special case: Find tracks with duplicate names + const nameMap = {}; + Object.values(data.tracks).forEach(track => { + const title = track.title?.toLowerCase().trim(); + if (!title) return; + if (!nameMap[title]) nameMap[title] = []; + nameMap[title].push(track); + }); + + const duplicates = Object.values(nameMap) + .filter(list => list.length > 1) + .flat(); + + if (duplicates.length === 0) { + sendStyledMessage('Find Duplicates', 'No duplicate track titles found.', false); + return; + } + + data.albums['Duplicates'] = true; + + duplicates.forEach(track => { + if (!track.albums.includes('Duplicates')) { + track.albums.push('Duplicates'); + } + }); + + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = 'Duplicates'; + + +// Sort tracklist by title (case-insensitive) +data.trackOrder = duplicates + .slice() + .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())) + .map(track => track.id); + + + + updateInterface(); + sendStyledMessage('Find Duplicates', `Found ${duplicates.length} duplicate track${duplicates.length !== 1 ? 's' : ''}.`, false); + return; + } + + // Normal search mode + data.albums['Found'] = true; + + const matches = Object.values(data.tracks).filter(track => { + const title = track.title?.toLowerCase() || ''; + const desc = track.description?.toLowerCase() || ''; + return title.includes(searchTerm) || desc.includes(searchTerm); + }); + + matches.forEach(track => { + if (!track.albums.includes('Found')) { + track.albums.push('Found'); + } + }); + + if (matches.length === 0) { + sendStyledMessage('Find Tracks', `No tracks matched the search: "${searchTerm}"`, false); + return; + } + + data.settings.viewMode = 'albums'; + data.settings.selectedAlbum = 'Found'; + + updateInterface(); + sendStyledMessage('Find Tracks', `Found ${matches.length} track${matches.length !== 1 ? 's' : ''} matching "${searchTerm}"`, false); +} + + if(command === 'toggle-settings') + { + data.settings.settingsExpanded = !data.settings.settingsExpanded; + updateInterface(); + } + if(command === 'mode') + { + const theme = args[0]?.toLowerCase(); + if(theme === 'light' || theme === 'dark') + { + data.settings.mode = theme; + updateInterface(); + } + else + { + sendStyledMessage('Unknown Mode', `Mode "${theme}" is not recognized. Must be *light* or *dark*`, false); + } + } + + + if(command === 'select') + { + const type = args.shift(); + let name = args.join(' ') + .trim(); + name = decodeURIComponent(name); + + // Reset the "Now Playing Only" view + data.settings.nowPlayingOnly = false; + + if(type === 'album' && (name in data.albums)) + { + data.settings.selectedAlbum = name; + } + if(type === 'playlist') + { + if(!(name in data.playlists)) + { + data.playlists[name] = []; + } + data.settings.selectedPlaylist = name; + } + + updateInterface(); + } + }); + + syncTracks(); + updateInterface(); +}); + +{ try { throw new Error(''); } catch (e) { API_Meta.JukeboxPlus.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.JukeboxPlus.offset); } } From 8b8ee349f4a356acb15e8d96b7591c7625cd85df Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 00:14:44 -0700 Subject: [PATCH 3/9] Create script.json --- JukeboxPlus/1.0.0/script.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 JukeboxPlus/1.0.0/script.json diff --git a/JukeboxPlus/1.0.0/script.json b/JukeboxPlus/1.0.0/script.json new file mode 100644 index 000000000..66ad49610 --- /dev/null +++ b/JukeboxPlus/1.0.0/script.json @@ -0,0 +1,15 @@ +{ + "name": "Jukebox Plus", + "script": "jukebox-plus.js", + "version": "1.0.0", + "description": "A powerful Jukebox UI and controller for Roll20. Organize tracks by album or playlist, play multiple tracks, loop, create randomized mixes, tag tracks, and announce them in chat.", + "author": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "modifies": { + "jukeboxtrack": "read,write", + "state": "read, write", + }, + "conflicts": [], + "previousversions": ["1.0.0"] +} From 2a0a19db6b0f0a56487a96544bc705193d6f3735 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 00:20:43 -0700 Subject: [PATCH 4/9] Create readme.md --- JukeboxPlus/1.0.0/readme.md | 153 ++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 JukeboxPlus/1.0.0/readme.md diff --git a/JukeboxPlus/1.0.0/readme.md b/JukeboxPlus/1.0.0/readme.md new file mode 100644 index 000000000..9332bab04 --- /dev/null +++ b/JukeboxPlus/1.0.0/readme.md @@ -0,0 +1,153 @@ +# Jukebox Plus + +Jukebox Plus lets you organize and control music tracks by **albums** or **playlists**. +Use the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control buttons appear for each one. + +--- + +## Getting Started + +Jukebox Plus lets you organize and control music tracks by **albums** or **playlists**. +Use the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control buttons appear for each one. + +--- + +## Header Buttons + +At the top right of the interface: + +[`Play All`][`Together`][`In Order`][`Loop`][`Mix`] +[`Loop All`][`Off`][`On`] +[`Stop All`] [`Find`] [`Help`] + +### Button Descriptions + +**Play All** +    **Together** — Plays all visible tracks simultaneously. Limited to the first five visible. +    **In Order** — Plays all visible tracks one after the other. +    **Loop** — Plays all visible tracks one after the other, then starts over. +    **Mix** — Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by [`Stop All`]. + +**Loop All** +    **Off** — Disables loop mode for all visible tracks +    **On** — Enables loop mode for all visible tracks + +**Stop All** — Stops all currently playing tracks. Also use to stop a Mix. +**Find** — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called **Found**. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel. +If you input `"d"` as the search term, it will create a temporary playlist of any duplicate tracks, grouped by name. +**Help** — Displays this help page. Click **Return to Player** to return. + +--- + +## Sidebar: Navigation & Now Playing + +**View Mode Toggle** + +The left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view. + +[`Albums`] [`Playlists`] + +These buttons let you switch between organizing by: +- **Albums** you define and tag yourself +- **Playlists** as defined in the Roll20 Jukebox system (not editable here) + +At the bottom of the list: +[`Now Playing`] — Filters the list to show only tracks currently playing. + +--- + +## Track Controls + +Each track shows these control buttons: + +- [`▶`] **Play** — Start the track +- [`⟲`] **Loop** — Toggle loop mode for the track +- [`⦿`] **Isolate** — Stops all others and plays only this one +- [`■`] **Stop** — Stops this track +- [`➤`] **Announce** — Sends the track name and description to the chat window + +--- + +## Track Info and Management + +**Edit** — Click the track description "edit" link to create a description. + +Description special characters: +- `---` inserts a line break +- `*italic*` uses single asterisks for italic text +- `**bold**` uses double asterisks for bold text +- `!d` or `!desc` includes the description when you Announce a track +- `!a` or `!announce` makes the track auto-announce on play + +**Tags** — +Each track has a Playlist tag and may have one or more Album tags. +- `Playlist` tags are in blue +- `Album` tags are in red + +Click [+ Add] to add an Album tag. +Click a tag to jump to that Album or Playlist. +Click the "x" on an Album tag to remove it: `Album name | x` + +**Image Area** — +Click the image area to enter either: +- a valid image URL +- a CSS color name (e.g. `"red"`) +- a hexadecimal color code (e.g. `#00ff00`) + +If you provide an image URL, it will display beside the track name and in the chat on announce. +If you provide a color code, the square will show that color and use it when Announcing. + +--- + +## Utility Panel + +Click [`Settings ▾`] to expand the utility tools. Includes: + +### Album Controls +[`Edit Albums:`][`–`][`+`][`✎`] +- Rename, add, or delete the currently selected album + +### Sorting +[`A—Z:`][`albums`][`tracks`] +- Alphabetize Albums or the tracks within an Album + +### Mode +[`Mode:`][`dark`][`light`] +- Switch between dark and light interface modes + +### Refresh +[`↻ Refresh`] — Rebuilds the interface if something breaks + +### Backup +[`Backup:`][`make`][`restore`] +- Create or restore from backup handouts containing your album and playlist data +- Use this to move data between games via the Roll20 Transmogrifier + +**Note:** Tracks are linked by ID, which changes between games. The script tries to match by name during restore, but renames and duplicates may cause mismatches. + +--- + +## Find + +Use the `!jb find keyword` command to search all track names and descriptions. +Matching tracks are added to a temporary album called **Found**. +Delete the Found album to clear the search results. + +--- + +## Useful Macros + +Here are some chat commands you can use in macros: + +- `!jb` — Show link to open the interface +- `!jb play TrackName` — Play the named track +- `!jb stopall` — Stop all currently playing audio +- `!jb loopall` — Enable loop mode for visible tracks +- `!jb unloopall` — Disable loop mode on all tracks +- `!jb jump album AlbumName` — Switch to the given album +- `!jb help` — Open this help screen +- `!jb find keyword` — Search for keyword and assign matches to the "Found" album + +You can also discover commands by pressing a control button, clicking in the chat window, and pressing the **Up Arrow** to see what was sent. + +--- From 19be4afe8e38791dd714f96aaec788036e939a54 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 00:27:11 -0700 Subject: [PATCH 5/9] Update script.json --- JukeboxPlus/1.0.0/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JukeboxPlus/1.0.0/script.json b/JukeboxPlus/1.0.0/script.json index 66ad49610..345782700 100644 --- a/JukeboxPlus/1.0.0/script.json +++ b/JukeboxPlus/1.0.0/script.json @@ -2,7 +2,7 @@ "name": "Jukebox Plus", "script": "jukebox-plus.js", "version": "1.0.0", - "description": "A powerful Jukebox UI and controller for Roll20. Organize tracks by album or playlist, play multiple tracks, loop, create randomized mixes, tag tracks, and announce them in chat.", + "description": "# Jukebox Plus\n\nJukebox Plus lets you organize and control music tracks by **albums** or **playlists**. \nUse the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control buttons appear for each one.\n\n---\n\n## Getting Started\n\nJukebox Plus lets you organize and control music tracks by **albums** or **playlists**. \nUse the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control buttons appear for each one.\n\n---\n\n## Header Buttons\n\nAt the top right of the interface:\n\n[`Play All`][`Together`][`In Order`][`Loop`][`Mix`] \n[`Loop All`][`Off`][`On`] \n[`Stop All`] [`Find`] [`Help`]\n\n### Button Descriptions\n\n**Play All** \n    **Together** — Plays all visible tracks simultaneously. Limited to the first five visible. \n    **In Order** — Plays all visible tracks one after the other. \n    **Loop** — Plays all visible tracks one after the other, then starts over. \n    **Mix** — Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by [`Stop All`].\n\n**Loop All** \n    **Off** — Disables loop mode for all visible tracks \n    **On** — Enables loop mode for all visible tracks\n\n**Stop All** — Stops all currently playing tracks. Also use to stop a Mix. \n**Find** — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called **Found**. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel. \nIf you input \"d\" as the search term, it will create a temporary playlist of any duplicate tracks, grouped by name. \n**Help** — Displays this help page. Click **Return to Player** to return.\n\n---\n\n## Sidebar: Navigation & Now Playing\n\n**View Mode Toggle**\n\nThe left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view.\n\n[`Albums`] [`Playlists`]\n\nThese buttons let you switch between organizing by: \n- **Albums** you define and tag yourself \n- **Playlists** as defined in the Roll20 Jukebox system (not editable here)\n\nAt the bottom of the list: \n[`Now Playing`] — Filters the list to show only tracks currently playing.\n\n---\n\n## Track Controls\n\nEach track shows these control buttons:\n\n- [`▶`] **Play** — Start the track \n- [`⟲`] **Loop** — Toggle loop mode for the track \n- [`⦿`] **Isolate** — Stops all others and plays only this one \n- [`■`] **Stop** — Stops this track \n- [`➤`] **Announce** — Sends the track name and description to the chat window\n\n---\n\n## Track Info and Management\n\n**Edit** — Click the track description \"edit\" link to create a description.\n\nDescription special characters:\n- `---` inserts a line break\n- `*italic*` uses single asterisks for italic text\n- `**bold**` uses double asterisks for bold text\n- `!d` or `!desc` includes the description when you Announce a track\n- `!a` or `!announce` makes the track auto-announce on play\n\n**Tags** — \nEach track has a Playlist tag and may have one or more Album tags. \n- `Playlist` tags are in blue \n- `Album` tags are in red \n\nClick [+ Add] to add an Album tag. \nClick a tag to jump to that Album or Playlist. \nClick the \"x\" on an Album tag to remove it: `Album name | x`\n\n**Image Area** — \nClick the image area to enter either:\n- a valid image URL\n- a CSS color name (e.g. \"red\")\n- a hexadecimal color code (e.g. `#00ff00`)\n\nIf you provide an image URL, it will display beside the track name and in the chat on announce. \nIf you provide a color code, the square will show that color and use it when Announcing.\n\n---\n\n## Uti "author": "Keith Curtis", "roll20userid": "162065", "dependencies": [], From b1c3a5cac52f0e6135045d0cf39e52e17761ddd0 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 01:11:59 -0700 Subject: [PATCH 6/9] Update JukeboxPlus.js --- JukeboxPlus/JukeboxPlus.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JukeboxPlus/JukeboxPlus.js b/JukeboxPlus/JukeboxPlus.js index 3504dc705..3cceb1eb0 100644 --- a/JukeboxPlus/JukeboxPlus.js +++ b/JukeboxPlus/JukeboxPlus.js @@ -787,7 +787,7 @@ const buildTrackRow = (track) => ` ${esc(name)}  |  - x + x ` ).join(' ') } From faf1c5b0f31dfc12e55952fa5adfe79e90bce5f6 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 01:12:21 -0700 Subject: [PATCH 7/9] Update JukeboxPlus.js --- JukeboxPlus/1.0.0/JukeboxPlus.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JukeboxPlus/1.0.0/JukeboxPlus.js b/JukeboxPlus/1.0.0/JukeboxPlus.js index 3504dc705..3cceb1eb0 100644 --- a/JukeboxPlus/1.0.0/JukeboxPlus.js +++ b/JukeboxPlus/1.0.0/JukeboxPlus.js @@ -787,7 +787,7 @@ const buildTrackRow = (track) => ` ${esc(name)}  |  - x + x ` ).join(' ') } From 5760cf5ecf654d5f4db80acac89cbe837a57a2b7 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 01:14:09 -0700 Subject: [PATCH 8/9] Rename JukeboxPlus/1.0.0/script.json to JukeboxPlus/script.json Moved up a level --- JukeboxPlus/{1.0.0 => }/script.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename JukeboxPlus/{1.0.0 => }/script.json (100%) diff --git a/JukeboxPlus/1.0.0/script.json b/JukeboxPlus/script.json similarity index 100% rename from JukeboxPlus/1.0.0/script.json rename to JukeboxPlus/script.json From bd05326c3fda9884b37064f0633533e070ebfbff Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 5 Jul 2025 01:14:37 -0700 Subject: [PATCH 9/9] Rename JukeboxPlus/1.0.0/readme.md to JukeboxPlus/readme.md Moved up a level --- JukeboxPlus/{1.0.0 => }/readme.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename JukeboxPlus/{1.0.0 => }/readme.md (100%) diff --git a/JukeboxPlus/1.0.0/readme.md b/JukeboxPlus/readme.md similarity index 100% rename from JukeboxPlus/1.0.0/readme.md rename to JukeboxPlus/readme.md