Skip to content

Instantly share code, notes, and snippets.

@keithcurtis1
Last active July 3, 2025 05:39
Show Gist options
  • Save keithcurtis1/dbfa84d8fe2eb08a68fd745510e1388b to your computer and use it in GitHub Desktop.
Save keithcurtis1/dbfa84d8fe2eb08a68fd745510e1388b to your computer and use it in GitHub Desktop.
Jukebox Plus
// Graphic Jukebox Plus (Fully Enhanced UI with Album/Playlist Toggle, Track Tagging, and Layout Fixes)
on('ready', () =>
{
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
}
};
}
// 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;',
headerButton: 'float:right; font-size:12px; padding:4px 6px; background:#ddd; color:#333; border:1px solid #bbb; border-radius:4px; text-decoration:none; margin-top:-2px;',
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; height:100px; background:#eee; color:#999; text-align:center; line-height:100px; font-size:11px; border:1px solid #bbb; margin-right:8px; float:left; display:block;',
// 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;',
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;',
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; height:100px; background:#444; color:#999; text-align:center; line-height:100px; font-size:11px; border:1px solid #666; margin-right:8px; float:left; display:block;',
// 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;
const renderHelpView = () =>
{
const handout = findObjs(
{
_type: 'handout',
name: HANDOUT_NAME
})[0];
if(!handout) return;
const css = data.settings.mode === 'light' ? cssLight : cssDark;
const helpHTML = `
<div style="${css.header}">
Jukebox Plus — Help
<a href="!jb help close" style="${css.headerButton}">Return to Player</a>
</div>
<div style="${css.tracklist}; padding:8px; line-height:1.6;">
<div style="${css.trackTitle}">Getting Started</div>
<div style="${css.descHelp}">
Jukebox Plus lets you organize and control music tracks by <strong>albums</strong> or <strong>playlists</strong>.
Use the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control
buttons appear for each one.
</div>
<br><br>
<div style="${css.trackTitle}">Header Buttons</div>
<div style="${css.descHelp}">
At the top right of the interface:
</div>
<div style="display:inline:block;width: 370px; text-align:left;">
<a style="${css.headerButton}">Help</a>
<a style="${css.headerButton}">Find</a>
<a style="${css.headerButton}">Stop All Audio</a>
<a style="${css.headerButton}">Play All</a>
<a style="${css.headerButton}">Loop All</a>
<a style="${css.headerButton}">Unloop All</a>
</div>
<br><br>
<div style="${css.descHelp}">
• Play All — Plays all visible tracks simultaneously. Limited to the first five visible.<br>
• Stop All Audio — Stops all currently playing tracks<br>
• Loop All — Sets loop mode for all visible tracks<br>
• Unloop All — Disables loop mode for all tracks<br>
• Find — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called <b>Found</b>. 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.<br>If you input "d" as the search term it will create a temporary play list of any duplicate trcks, grouped by name.<br>
• Help — Disables this help page. Click <b>Return to Player</b> to return.
</div>
<br><br>
<div style="${css.trackTitle}">Sidebar: Navigation & Now Playing</div>
<br>
<div style="${css.descHelp}">
<b>View Mode Toggle</b><br>
The left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view.
<br>
<div style="display:block; width:250px;"><a style="${css.albumSelectedLink}display:inline-block;">Albums</a> <a style="${css.playlistSelectedLink}display:inline-block;">Playlists</a></div>
<div style="${css.descHelp}">
These buttons let you switch between organizing by <b>Album</b> tags or by manual <b>Playlists</b>. 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.
</div>
<div style="${css.descHelp}">
At the bottom of the list is:<br>
<a style="${css.sidebarLink}">Now Playing</a> — filters the list to show only tracks currently playing.
</div>
<br><br>
<div style="${css.trackTitle}">Track Controls</div>
<div style="${css.descHelp}">
Each track shows these control buttons:<br>
<img src="${icons.play}" width="20" height="20"> <strong>Play</strong>: Start the track.<br>
<img src="${icons.loop}" width="20" height="20"> <strong>Loop</strong>: Toggle loop mode for the track.<br>
<img src="${icons.isolate}" width="20" height="20"> <strong>Isolate</strong>: Stops all others and plays only this one.<br>
<img src="${icons.stop}" width="20" height="20"> <strong>Stop</strong>: Stops this track.<br>
<a style="${css.announceButton}">➤</a> <strong>Announce</strong>: Sends the track name and description to the chat window.<br>
<br><br>
<div style="${css.trackTitle}">Track Info and Management</div>
<div style="${css.descHelp}">
• Click the track description "edit" link to create a description.<br>
Description special characters:<br>
&nbsp;&nbsp;&nbsp;"//" to insert a line break.<br>
&nbsp;&nbsp;&nbsp;"!d" or "!desc" to include the description of the track when you announce it. Default is title only.<br>
&nbsp;&nbsp;&nbsp;"!a" or "!announce" to have a track announce itself automatically whenever you play if. Default is manual announcement only.<br><br>
• Each track has a Playlist tag, and the ability to add album tags. <span style="${css.playlistTag}">Playlist</span> tags are in blue, and <span style="${css.albumTag}">Album</span> tags are in red. Click <span style="${css.albumTag}">+ Add</span> 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: <span style="${css.albumTag}">Album name | x</span><br>
• Click the image area to submit a valid image URL. This will be used as the image for that track. It will be cropped to fit a 100px square. The URL can come from your Roll20 image library or any valid image host.
</div>
<br><br>
<div style="${css.trackTitle}">Utility Panel</div>
<div style="${css.descHelp}">
Click <div style="display: inline-block; width: 100px; text-align:left;"><a style="${css.settingsButton}">Settings ▾</a></div> to expand the utility tools. Includes:
<br>
<div style="width: 170px; text-align:left;">
<div style="${css.utilityContainer}">Edit Albums:</strong>
<a style="${css.utilitySubButton}">–</a>
<a style="${css.utilitySubButton}">+</a>
<a style="${css.utilitySubButton}">✎</a>
</div></div>
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.
<br>
<div style="width: 170px; text-align:left;">
<div style="${css.utilityContainer}">Mode:</strong>
<a style="${css.utilitySubButton}">Dark</a>
<a style="${css.utilitySubButton}">Light</a>
</div></div>
These buttons switch between light and dark mode.
<div style="width: 170px; text-align:left;"><a style="${css.utilityButton}">↻ Refresh</a></div> Rebuilds the interface if something breaks.
<div style="width: 170px; text-align:left;">
<div style="${css.utilityContainer}">Backup</strong>
<a style="${css.utilitySubButton}">make</a>
<a style="${css.utilitySubButton}">restore</a>
</div></div>
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 the handout, or copy it 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.
<br><br>
<div style="${css.trackTitle}">Find</div>
<div style="${css.descHelp}">
Use the <code>!jb find keyword</code> command to search all track names and descriptions for the keyword.
All matching tracks will be assigned to a temporary album called <b>Found</b>. 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.
</div>
<br><br>
<div style="${css.trackTitle}">Useful Macros</div>
<div style="${css.descHelp}">
Here are some chat commands that can be used in macros:<br>
<code>!jb</code> — Puts a link to this handout in chat<br>
<code>!jb play TrackName</code> — play the named track<br>
<code>!jb stopall</code> — stops all audio<br>
<code>!jb loopall</code> — sets loop mode on all visible tracks<br>
<code>!jb unloopall</code> — disables loop mode on all tracks<br>
<code>!jb jump album AlbumName</code> — switch to a specific album<br>
<code>!jb help</code> — open this help screen<br>
<code>!jb find keyword</code> command to search all track names and descriptions for the keyword.
All matching tracks will be assigned to a temporary album called <b>Found</b>. 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.<br>
You can also discover commands by pressing a button, clicking in the chat window, and pressing the up arrow to see what was sent.
</div>
<br><br><br><br><br></div>
`;
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 <a>
message = message.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, command) =>
{
return `<a href="${command}" style="${css.messageButton}">${label}</a>`;
});
const html = `<div style="${css.messageContainer}"> <div style="${css.messageTitle}">${title}</div>${message}</div>`;
const target = isPublic ? '' : '/w gm ';
sendChat('Jukebox Plus', `${target}${html}`, null,
{
noarchive: true
});
}
const renderFormattedText = (text) => {
if(!text) return '';
return esc(text)
.replace(/---+/g, '<br>') // replace --- with <br>
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>') // **bold**
.replace(/\*(.+?)\*/g, `<span style="font-style:italic; font-family: Arial, sans-serif;">$1</span>`)
.replace(/`(.+?)`/g, '<code>$1</code>') // `code`
.replace(/!a/gi, `<span style="${css.code}">announce</span>`) // announce codes
.replace(/!d/gi, `<span style="${css.code}">desc</span>`);
};
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 => (
{
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [c]))
.replace(/\/{2}/g, '<br>');
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('GraphicJukebox: No jukebox folder found.');
data.playlists = {
'Unassigned': []
};
return;
}
let folder;
try
{
folder = JSON.parse(folderJSON);
}
catch (e)
{
log('GraphicJukebox: Failed to parse jukeboxfolder JSON:', e);
data.playlists = {
'Unassigned': []
};
return;
}
log('GraphicJukebox: 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(`GraphicJukebox: Track ID [${trackId}] not found in jukebox tracks.`);
}
});
});
// If no playlists found, fallback to Unassigned
if(Object.keys(data.playlists)
.length === 0)
{
log('GraphicJukebox: 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 img = track.image ?
`<a href="!jb edit ${track.id} image ?{New image URL|${esc(track.image)}}"><div style="${css.imageDiv}; background-image: url('${track.image}')"></div></a>` :
`<a href="!jb edit ${track.id} image ?{New image URL|}"><div style="${css.imagePlaceholder}">click to add image</div></a>`;
const albumOptions = Object.keys(data.albums || {})
.filter(a => !track.albums.includes(a))
.concat('New Album');
const addAlbumQuery = albumOptions.map(a => esc(a)).join('&#124;');
const desc = track.description ?
`<div style="${css.desc}">${renderFormattedText(track.description)} <a href="!jb edit ${track.id} description ?{New description|${esc(track.description)}}" style="${css.descEditLink}">[edit]</a></div>` :
`<div style="${css.desc}"><a href="!jb edit ${track.id} description ?{New description|}" style="${css.descEditLink}">click to add description</a></div>`;
// Get 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 if we're in album or now-playing view
const isAlbumView = data.settings.viewMode === 'albums' && !data.settings.nowPlayingOnly;
const isNowPlaying = data.settings.nowPlayingOnly;
// 📘 Determine a playlist this track belongs to
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 = ` <a href="!jb jump-playlist ${encoded}" style="${css.playlistTag}">${esc(playlistName)}</a>`;
}
}
return `
<div style="${css.track}">
${img}
<div style="margin-left:72px;">
<div>
<div style="${css.trackTitle}">
${esc(track.title)}
<a href="!jb announce ${track.id}" style="${css.announceButton}">➤</a>
</div>
<span style="${css.controls}">
<a href="!jb play ${track.id}" title="Play"><img src="${playImg}" alt="Play" style="${css.controlButtonImg}"></a>
<a href="!jb loop ${track.id}" title="Loop"><img src="${loopImg}" alt="Loop" style="${css.controlButtonImg}"></a>
<a href="!jb isolate ${track.id}" title="Isolate"><img src="${icons.isolate}" alt="Isolate" style="${css.controlButtonImg}"></a>
<a href="!jb stop ${track.id}" title="Stop"><img src="${icons.stop}" alt="Stop" style="${css.controlButtonImg}"></a>
</span>
</div>
${desc}
<div style="${css.tags}">
${
(track.albums || []).map(name =>
`<span style="${css.albumTag}">
<a href="!jb jump album ${encodeURIComponent(name)}" title="Jump to album view" style="text-decoration:none; color:inherit;">${esc(name)}</a>
&nbsp;|&nbsp;
<a href="!jb edit ${track.id} albums remove ${esc(name)}" style="${css.tagRemove}" title="Remove from album">x</a>
</span>`
).join(' ')
}
<a href="!jb add-album-and-assign ${track.id} ?{Choose album|${Object.keys(data.albums).filter(name => name !== 'New Album').join('|')}|New Album}" style="${css.albumTag}">+ Add</a>
${playlistTagHTML}
</div>
</div>
</div>
`;
};
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 = `
<div style="${css.toggleWrap}">
<a href="!jb view albums" style="${css.toggleButton} ${
data.settings.viewMode === 'albums' ? css.toggleActiveAlbums : css.toggleInactive
}">Albums</a>
<a href="!jb view playlists" style="${css.toggleButton} ${
data.settings.viewMode === 'playlists' ? css.toggleActivePlaylists : css.toggleInactive
}">Playlists</a>
</div>
`;
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(`<a href="!jb select album ${encodedName}" style="${style}">${esc(albumName)}</a>`);
});
}
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(`<a href="!jb select playlist ${encodedName}" style="${style}">${esc(playlistName)}</a>`);
});
}
// Highlight Now Playing if active
const nowPlayingStyle = data.settings.nowPlayingOnly ?
(data.settings.viewMode === 'albums' ? css.albumSelectedLink : css.playlistSelectedLink) :
css.sidebarLink;
entries.push(`<a href="!jb view nowplaying" style="${nowPlayingStyle}">Now Playing</a>`);
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 = (() =>
{
let tracks = [];
if (data.settings.nowPlayingOnly) {
tracks = Object.values(data.tracks).filter(t => {
const actual = getAllTracks().find(j => j.id === t.id);
return actual && actual.get('playing');
});
} else if (data.settings.viewMode === 'albums') {
const selected = data.settings.selectedAlbum;
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];
}
} else if (data.settings.viewMode === 'playlists') {
const selected = data.settings.selectedPlaylist;
const trackIds = data.playlists[selected] || [];
tracks = trackIds.map(id => data.tracks[id]).filter(Boolean);
}
return tracks.map(buildTrackRow).join('');
})();
const utilityToggleText = data.settings.utilityExpanded ? 'Settings ▲' : 'Settings ▼';
const utilityToggleButton = `<a href="!jb toggle-settings" style="${css.settingsButton}">
Settings ${data.settings.settingsExpanded ? '▴' : '▾'}
</a>`;
const utilityButtons = data.settings.settingsExpanded ? `
${utilityToggleButton}
<div style="${css.utilityContainer}">
Edit Albums
<a href="!jb add album ?{Album Name}" style="${css.utilitySubButton}">+</a>
<a href="!jb remove-album ?{Choose Album to Delete|${
(() => {
const names = Object.keys(data.albums);
if (names.includes("Found")) {
return ["Found", ...names.filter(n => n !== "Found")].join('|');
} else {
return names.join('|');
}
})()
}}" style="${css.utilitySubButton}">–</a>
<a href="!jb rename-album ?{Album to Rename|${Object.keys(data.albums).join('|')}} ?{New Album Name}" style="${css.utilitySubButton}">✎</a>
</div>
<div style="${css.utilityContainer}">
A–Z
<a href="!jb sort-tracks" style="${css.utilitySubButton}">tracks</a>
<a href="!jb sort-albums" style="${css.utilitySubButton}">albums</a>
</div>
<a href="!jb refresh" style="${css.utilityButton}">↻ Refresh</a>
<div style="${css.utilityContainer}">
Mode
<a href="!jb mode light" style="${css.utilitySubButton}">Light</a>
<a href="!jb mode dark" style="${css.utilitySubButton}">Dark</a>
</div>
<div style="${css.utilityContainer}">
Backup
<a href="!jb backup" style="${css.utilitySubButton}">make</a>
<a href="!jb restore ?{Which backup?|${(
findObjs({ _type: 'handout' })
.map(h => h.get('name'))
.filter(name => /^Jukebox Backup \d{3}$/.test(name))
.sort()
.join('|')
)}}" style="${css.utilitySubButton}">restore</a>
</div>
` : utilityToggleButton;
const html = `
<table style="width:100%; border-collapse:collapse;">
<tr>
<td colspan="2" style="${css.header}">
Jukebox Plus
<a href="!jb ${data.settings.helpVisible ? 'help close' : 'help'}" style="${css.headerButton}">
${data.settings.helpVisible ? 'Return to Player' : 'Help'}
</a>
<a href="!jb find ?{search term}" style="${css.headerButton}">Find Tracks</a>
<a href="!jb unloopall" style="${css.headerButton}">Unloop All</a>
<a href="!jb loopall" style="${css.headerButton}">Loop All</a>
<a href="!jb stopall" style="${css.headerButton}">Stop All Audio</a>
<a href="!jb playall" style="${css.headerButton}">Play All</a>
<span style="${css.trackCount}">${visibleTracks.length} track${visibleTracks.length !== 1 ? 's' : ''}</span>
</td>
</tr>
<tr>
<td style="${css.sidebar}">
${toggleHTML}
${sidebarList}
<hr style="${css.sidebarRule}">
${utilityButtons}
</td>
<td style="${css.tracklist}">${trackList}</td>
</tr>
</table>
`;
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('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 ? `<div style="${css.announceDesc}">${esc(track.description || '')}</div>` : '';
const imageHtml = track.image ?
`<img src="${track.image}" style="width:100%; max-width:100%; height:auto;">` :
'';
const messageHtml = `${imageHtml}<div style="${css.announceTitle}">${esc(track.title)}</div>${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',
`<b>"${esc(track.title)}"</b><br><br>This track is listed in your saved data, but no matching Roll20 jukebox track exists.<br><br>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.<br><br><a href="!jb delete-track ${track.id}" style="${css.headerButton}">Remove this broken track</a><br>`,
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 ? `<div style="${css.announceDesc}">${esc(data.tracks[t.id].description || '')}</div>` : '';
const imageHtml = data.tracks[t.id].image ?
`<img src="${data.tracks[t.id].image}" style="width:100%; max-width:100%; height:auto;">` :
'';
const messageHtml = `${imageHtml}<div style="${css.announceTitle}">${esc(data.tracks[t.id].title)}</div>${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 === 'stopall')
{
getAllTracks()
.forEach(t => t.set('playing', false));
updateInterface(); // So buttons reflect stopped state
}
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', `<pre>${JSON.stringify(backupData, null, 2)}</pre>`);
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>|<\/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 "<b>${esc(track.title)}</b>" 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 imageHtml = track.image
? `<img src="${track.image}" style="width:100%; max-width:100%; height:auto;">`
: '';
let cleanDesc = (track.description || '');
if (flags.includeDesc) {
// Strip flag codes from description (case-insensitive)
cleanDesc = cleanDesc.replace(/\s*!a(nnounce)?\b/gi, '');
cleanDesc = cleanDesc.replace(/\s*!d(esc)?\b/gi, '');
}
const descHtml = flags.includeDesc
? `<div style="${css.announceDesc}">${renderFormattedText(cleanDesc.trim())}</div>`
: '';
const messageHtml = `${imageHtml}<div style="${css.announceTitle}">${esc(track.title)}</div>${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 name = args.slice(1)
.join(' ')
.trim();
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' || field === 'description')
{
track[field] = value;
}
else if(field === 'albums')
{
const [action, ...rest] = value.split(' ');
let target = 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();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment