diff --git a/JukeboxPlus/1.0.3/JukeboxPlus.js b/JukeboxPlus/1.0.3/JukeboxPlus.js new file mode 100644 index 000000000..ad915c270 --- /dev/null +++ b/JukeboxPlus/1.0.3/JukeboxPlus.js @@ -0,0 +1,2591 @@ +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) +// Changelong +// 1.0.0 Original +// 1.0.1 Added min/Max intervals, bug fixes, and simple Director integration. +// 1.0.2 Internal css restructuring for easier updates, Find function now accepts Regex +// 1.0.3 Added Shuffle and Shuffle loop modes. + +on('ready', () => +{ + + const version = '1.0.3'; //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) +// Is separate from initial state declaration to protect users from breaking changes. +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 cssDark = { + // Layout Containers + sidebar: 'background:#222; border-right:1px solid #444; width:200px; padding:6px; vertical-align:top; font-family: Nunito, Arial, sans-serif;', + tracklist: 'background:#1e1e1e; width:100%; padding:8px; vertical-align:top; font-family: Nunito, Arial, sans-serif;', + toggleWrap: 'margin-bottom:8px;width:160px; display:block;', + + // Header and Title + header: 'color:#ddd; background:#542d2d; border-bottom:1px solid #444; padding:4px; text-align:left; font-size:20px; font-weight:bold; font-family: Nunito, Arial, sans-serif;', + gear: 'color:#aaa; float:right; cursor:pointer;', + trackCount: 'color:#888; margin-right:15px; margin-top:5px; font-size:12px; float:right; display: inline-block;', + + // Buttons & Controls + button: 'background:#333; color:#ccc; border:1px solid #555; width:100%; margin-bottom:4px; display:block; font-size:11px;', + utilityContainer: 'background:#555; color:#ddd; border:1px solid #444; border-radius:4px; width:90%; padding:4px 6px; margin-top:6px; position:relative; font-size:12px;', + utilitySubButton: 'background:#444; color:#ccc; border:1px solid #444; border-radius:3px; padding:1px 5px; margin:-1px -1px 0px 3px; float:right; font-size:11px; text-decoration:none;', + utilityButton: 'background:#555; color:#ddd; border:1px solid #444; border-radius:4px; width:90%; margin-top:6px; display:inline-block; padding:4px 6px; font-size:12px; text-align:center; text-decoration:none;', + settingsButton: 'background:transparent; color:#ddd; width:90%; margin-top:6px; display:inline-block; padding:4px 6px; font-size:12px; text-align:center; text-decoration:none;', + headerButtonContainer: 'background:#1a2833; color:#ddd; border:1px solid #888; border-radius:4px; margin-top:-2px; margin-right:6px; position:relative; top:3px; float:right; display:inline-block; padding:4px 6px; font-size:12px; text-decoration:none;', + headerButton: 'color:#ddd!important; background:#1a2833; border:1px solid #888; border-radius:4px; margin-top:-2px; margin-right:6px; padding:4px 6px; font-size:12px; float:right; text-decoration:none; position:relative; top:3px;', + headerSubButton: 'background:#0e161c; color:#ddd; border:1px solid #444; border-radius:2px; margin-left:2px; margin-top:-2px; padding:1px 6px; font-size:11px; text-decoration:none;', + headerSubButtonActive: 'background:#C27575; color:#333; border:1px solid #333; border-radius:3px; margin-top:-2px; padding:1px 6px; font-size:11px; text-decoration:none;', + nowPlayingButton: 'background:#444; color:#ccc; border-radius:4px; margin-top:6px; padding:2px 4px; display:block; text-decoration:none;', + refreshButton: 'color:#66aaff; margin-top:8px; display:block; font-size:10px; text-decoration:underline; cursor:pointer;', + forceTextColor: 'color:#ddd', + + //announce styles + announceButton: 'color:#888; padding:0px 4px; margin-top:4px; font-size:10px; display:inline-block; text-decoration:none;', + announceTitle: 'color:#ccc; margin-top:4px; font-size:16px; font-weight:bold; display:inline-block;', + announceDesc: 'color:#aaa; margin-top:4px; font-size:11px; 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; border-radius:4px; padding:2px 4px; display:block; text-decoration:none;', + playlistSelectedLink: 'background:#334477; color:#eee; border-radius:4px; padding:2px 4px; display:block; text-decoration:none;', + + // Album/Playlist Tags + tags: 'margin-top:4px; margin-left:38px; display:block;', + albumTag: 'background:#993333; color:#eee; border-radius:4px; padding:2px 6px; margin-right:2px; font-size:10px; display:inline-block; vertical-align:middle;', + playlistTag: 'background:#334477; color:#eee; border-radius:4px; padding:2px 6px; margin-right:2px; font-size:10px; display:inline-block; vertical-align:middle;', + tagRemove: 'color:#eee; margin-left:2px; cursor:pointer;', + + // Toggle Buttons + toggleButton: 'border:1px solid #555; border-radius:4px; width:45%; margin-right:4px; padding:6px 0; font-weight:bold; display:inline-block; text-align:center;', + toggleActiveAlbums: 'background:#993333; color:#eee;', + toggleActivePlaylists: 'background:#334477; color:#eee;', + toggleInactive: 'background:#444; color:#aaa;', + + //Chat message Styles + messageContainer: 'background-color:#222; color:#ccc; Border: solid 1px #444; border-radius:5px; padding:10px; position:relative; top:-15px; left:-5px; font-family: Nunito, Arial, sans-serif;', + messageTitle: 'color:#ddd; margin-bottom:13px; font-size:16px; text-transform: capitalize; text-align:center;', + messageButton: 'background:#444; color:#ccc; border-radius:4px; padding:2px 6px; margin-right:2px; display:inline-block; vertical-align:middle', + descHelp: 'color:#eee; margin-top:4px; font-size:15px;', + + // Track Item Styles + track: 'color:#ccc; border-bottom:1px solid #444; padding:6px 0; display:table; width:100%;', + trackTitle: 'color:#ccc;margin-top:2px; font-size:18px; font-weight:bold; display:inline-block;', + controls: 'float:right; margin-top:-2px;', + controlButtonImg: 'width:16px; height:16px; margin: 4px 2px; vertical-align:middle; cursor:pointer;', + desc: 'color:#aaa; margin-top:4px; margin-left:38px; font-size:13px;', + vol: 'color:#999; margin-top:4px; margin-left:108px; font-size:11px;', + albumEditLink: 'color:#aaa; margin-left:4px; font-size:10px; vertical-align:middle;', + descEditLink: 'color:#888; margin-left:6px; font-size:10px; font-style:italic; cursor:pointer;', + code: 'color:eee; background-color:#444; border-radius:3px; padding:1px 4px 0px 4px; margin-left:4px; display:inline-block; font-size:0.75em; font-family:monospace; font-weight:bold; user-select:none;', + volumeControl: 'color:#888; margin: 0px 6px; font-size:10px; text-decoration:none; cursor:pointer;', + + // Images + image: 'background:#444; color:#999; border:1px solid #666; width:100px; height:100px; margin-right:8px; text-align:center; font-size:11px; float:left; object-fit:cover; object-position:center center; display:block;', + imageDiv: 'border:1px solid #666; width:100px; height:100px; margin-right:8px; background-size:cover; background-position:center; float:left; display:block;', + imagePlaceholder: 'background:#444; color:#999; border:1px solid #666; width:100px; margin-right:8px; text-align:center; font-size:11px; float:left; padding-top:35px; height:65px; line-height:18px; display:block;', + + // Album specific + albumImage: 'border:1px solid #666; width:80px; height:80px; margin-right:8px; object-fit:cover;', + albumHeaderDesc: 'color:#bbb; font-size:12px;', + addAlbum: 'color:#ccc; margin-top:8px; display:block; font-size:10px;' +}; + + +const lightModeOverrides = { + // Layout Containers + sidebar: { background: '#f5f5f5', "border-right": '1px solid #ccc' }, + tracklist: { background: '#ffffff' }, + + // Header and Title + header: { color: '#222', background: '#cc9393', "border-bottom": '1px solid #ccc' }, + gear: { color: '#666' }, + trackCount: { color: '#333' }, + + // Buttons & Controls + button: { background: '#e0e0e0', color: '#333', border: '1px solid #bbb' }, + utilityContainer: { background: '#ddd', color: '#333', border: '1px solid #bbb' }, + utilitySubButton: { background: '#aaa', color: '#333', border: '1px solid #999' }, + utilityButton: { background: '#ddd', color: '#222', border: '1px solid #bbb' }, + settingsButton: { color: '#333' }, + forceTextColor: { color: '#222' }, + + // *** Updated header buttons to match cssDark measurements but cssLight colors from utility buttons *** + headerButtonContainer: { border: '1px solid #666', background: '#ddd', color: '#333' }, + headerButton: { border: '1px solid #666', background: '#ddd', color: '#222' }, + headerSubButton: { border: '1px solid #999', background: '#aaa', color: '#333' }, + headerSubButtonActive: { border: '1px solid #333', background: '#C27575', color: '#333' }, + + nowPlayingButton: { color: '#444', background: '#eee' }, + refreshButton: { color: '#0066cc' }, + + //announce styles + announceButton: { color: '#888' }, + announceTitle: { color: '#333' }, + announceDesc: { color: '#555' }, + + // Sidebar Links & Rules + sidebarRule: { border: '0', "border-top": '1px solid #ccc' }, + sidebarLink: { color: '#444' }, + albumSelectedLink: { background: '#c22929', color: '#fff' }, + playlistSelectedLink: { background: '#2d5da6', color: '#fff' }, + + // Album/Playlist Tags + albumTag: { background: '#c22929', color: '#fff' }, + playlistTag: { background: '#2d5da6', color: '#fff' }, + tagRemove: { color: '#fff' }, + + // Toggle Buttons + toggleActiveAlbums: { background: '#c22929', color: '#fff' }, + toggleActivePlaylists: { background: '#2d5da6', color: '#fff' }, + toggleInactive: { background: '#bbb', color: '#666' }, + + // Message styles + messageContainer: { backgroundColor: '#ccc', color: '#111', border: 'solid 1px #555' }, + messageTitle: { backgroundColor: '#444', color: '#ddd' }, + messageButton: { background: '#aaa', color: '#111', border: 'solid 1px #666' }, + + // Track Item Styles + track: { "border-bottom": '1px solid #ccc', color: '#333' }, + trackTitle: { color: '#333' }, + desc: { color: '#666' }, + vol: { color: '#999' }, + albumEditLink: { color: '#666' }, + descEditLink: { color: '#888' }, + code: { color: '222', backgroundColor: '#ddd' }, + volumeControl: { color: '#888' }, + + // Images + image: { background: '#eee', color: '#999', border: '1px solid #bbb' }, + imageDiv: { border: '1px solid #bbb' }, + imagePlaceholder: { background: '#eee', color: '#999', border: '1px solid #bbb' }, + + // Album specific + albumImage: { border: '1px solid #bbb' }, + albumHeaderDesc: { color: '#666' }, + addAlbum: { color: '#666' } +}; + + + + +const generateCssLightFromDark = (cssDark, overrides) => { + const result = {}; + + const replaceColors = (styleStr, override) => { + const props = styleStr.split(';').map(p => p.trim()).filter(Boolean); + const mapped = {}; + + // Convert dark mode CSS string into key-value pairs + props.forEach(p => { + const [key, value] = p.split(':').map(s => s.trim()); + mapped[key] = value; + }); + + // Apply color/background/border overrides + if (override) { + if (override.color) mapped.color = override.color; + if (override.background) mapped.background = override.background; + if (override.border) { + // Override just the relevant border (most are single sides) + const sides = ['border', 'border-top', 'border-right', 'border-bottom', 'border-left']; + const borderKey = sides.find(k => Object.keys(mapped).includes(k)) || 'border'; + mapped[borderKey] = override.border; + } + } + + // Rebuild into CSS string + return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';'; + }; + + // Handle all style keys (excluding badgeColors) + for (const key in cssDark) { + if (key === 'badgeColors') continue; + const override = overrides[key]; + result[key] = replaceColors(cssDark[key], override); + } + + // Copy and override badgeColors + result.badgeColors = { + ...(cssDark.badgeColors || {}), + ...(overrides.badgeColors || {}) + }; + + return result; +}; + +const cssLight = generateCssLightFromDark(cssDark, lightModeOverrides); + + + + + + + // 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; + + +// Initiatlizes the ID of the currently scheduled timeout used for managing the Mix playback mode. +let mixTimeoutId = null; + +const getDirectorHandoutLink = () => { + if (typeof API_Meta !== 'undefined' && + API_Meta.Director && + typeof API_Meta.Director.offset === 'number') { + + const handout = findObjs({ type: 'handout', name: 'Director' })[0]; + if (handout) { + const url = `http://journal.roll20.net/handout/${handout.id}`; + return `Direct`; + } + } + return ''; +}; + + + +// Renders the help documentation view in the Jukebox Plus handout, styled according to current theme mode. + const renderHelpView = () => + { + const handout = findObjs( + { + _type: 'handout', + name: HANDOUT_NAME + })[0]; + if(!handout) return; + + const css = data.settings.mode === 'light' ? cssLight : cssDark; + +//HTML that displays the help documentation +const helpHTML = ` +
!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.
+ !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$1') // `code`
+ .replace(/!a/gi, `announce`) // announce codes
+ .replace(/!d/gi, `desc`);
+};
+
+
+// Escapes special characters for safe use in Roll20 query prompts (e.g. `?{}` and pipe-delimited lists).
+const escapeForRoll20Query = (str) => {
+ if (!str) return '';
+ return str
+ .replace(/\\/g, '\\\\')
+ .replace(/\|/g, '\\|')
+ .replace(/\?/g, '\\?')
+ .replace(/\{/g, '\\{')
+ .replace(/\}/g, '\\}');
+};
+
+
+
+
+
+
+// Escapes HTML special characters and replaces double slashes with | + Jukebox Plus + + + ${data.settings.helpVisible ? 'Return to Player' : 'Help'} + + + + Find + + ${getDirectorHandoutLink()} + + + Stop All + + + + + + + + + + + + + + ${visibleTracks.length} track${visibleTracks.length !== 1 ? 's' : ''} + + | +|
|
+ ${toggleHTML}
+ ${sidebarList}
+ + ${utilityButtons} + |
+ ${trackList} | +
${JSON.stringify(backupData, null, 2)}`);
+ sendStyledMessage('Backup created', `[${name}](http://journal.roll20.net/handout/${handout.id})`);
+ }
+
+
+ // Restores track, album, and playlist data from a named backup handout
+ 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.`);
+ });
+ }
+
+ // Removes a track from saved data by ID
+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);
+ }
+}
+
+ // Announces a track with formatted message including image/color/description based on flags
+if (command === 'announce') {
+ const idOrName = args.join(' ').trim();
+ const track = findInternalTrack(idOrName);
+
+ if (!track) {
+ sendStyledMessage('Warning', 'Track not found.');
+ return;
+ }
+
+ const actual = findLiveTrack(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);
+}
+
+
+
+
+
+ // Switches view mode between albums and playlists
+ if(command === 'view')
+ {
+ const mode = args[0];
+ if(['albums', 'playlists'].includes(mode))
+ {
+ data.settings.viewMode = mode;
+ updateInterface();
+ }
+ }
+
+
+
+if (command === 'volume' && args.length === 2) {
+ const trackId = args[0];
+ const sliderPercent = parseInt(args[1], 10);
+ const t = findLiveTrack(trackId);
+
+ if (t && !isNaN(sliderPercent) && sliderPercent >= 0 && sliderPercent <= 100) {
+ const volume = sliderPercentToStoredVolume(sliderPercent);
+ t.set('volume', volume);
+ updateInterface();
+ }
+ return;
+}
+
+
+
+
+ // Sets view to show only currently playing tracks
+ if(command === 'view' && args[0] === 'nowplaying')
+ {
+ data.settings.nowPlayingOnly = true;
+ updateInterface();
+ }
+
+
+ // Resets view to show all tracks
+ if(command === 'view' && args[0] === 'all')
+ {
+ data.settings.nowPlayingOnly = false;
+ updateInterface();
+ }
+
+ // Changes selected album in album view, given URL encoded album name
+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}`);
+ }
+}
+
+ // Changes selected playlist in playlist view, given URL encoded playlist 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();
+ }
+
+ // Sorts albums alphabetically and updates the album order
+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();
+}
+
+ // Sorts tracks alphabetically and updates track order
+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();
+}
+
+
+ // Edits a field (image, description, albums) of a specified track
+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();
+}
+
+
+
+ // Adds a new album to the album list and selects it
+ 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();
+ }
+ }
+
+ // Adds a new album and assigns the specified track to it
+ 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();
+ }
+
+ // Removes an album and cleans up all tracks that reference it
+ 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);
+ }
+ }
+
+ // Renames an album and updates all references to it in tracks and sorting
+ 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();
+ }
+
+ // Finds tracks by a search term, marking matches into a special 'Found' or 'Duplicates' album
+if (command === 'find') {
+ const rawSearchTerm = args.join(' ').trim();
+
+ if (!rawSearchTerm) {
+ 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 (rawSearchTerm.toLowerCase() === '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;
+
+ let matches;
+ let isRegex = rawSearchTerm.startsWith('/') && rawSearchTerm.lastIndexOf('/') > 0;
+
+ if (isRegex) {
+ try {
+ const lastSlash = rawSearchTerm.lastIndexOf('/');
+ const pattern = rawSearchTerm.slice(1, lastSlash);
+ const flags = rawSearchTerm.slice(lastSlash + 1);
+ const regex = new RegExp(pattern, flags);
+
+ matches = Object.values(data.tracks).filter(track => {
+ const title = track.title || '';
+ const desc = track.description || '';
+ return regex.test(title) || regex.test(desc);
+ });
+ } catch (e) {
+ sendStyledMessage('Find Tracks', `Invalid regular expression: ${esc(rawSearchTerm)}`, false);
+ return;
+ }
+ } else {
+ const term = rawSearchTerm.toLowerCase();
+ matches = Object.values(data.tracks).filter(track => {
+ const title = track.title?.toLowerCase() || '';
+ const desc = track.description?.toLowerCase() || '';
+ return title.includes(term) || desc.includes(term);
+ });
+ }
+
+ matches.forEach(track => {
+ if (!track.albums.includes('Found')) {
+ track.albums.push('Found');
+ }
+ });
+
+ if (matches.length === 0) {
+ sendStyledMessage('Find Tracks', `No tracks matched the search: "${esc(rawSearchTerm)}"`, false);
+ return;
+ }
+
+ data.settings.viewMode = 'albums';
+ data.settings.selectedAlbum = 'Found';
+
+ updateInterface();
+}
+
+
+ // Toggles the visibility of the settings pane
+ if(command === 'toggle-settings')
+ {
+ data.settings.settingsExpanded = !data.settings.settingsExpanded;
+ updateInterface();
+ }
+
+
+ // Changes the interface mode between 'light' and 'dark'
+ 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);
+ }
+ }
+
+
+ // Selects an album or playlist, updating the current view
+ 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); } }
diff --git a/JukeboxPlus/JukeboxPlus.js b/JukeboxPlus/JukeboxPlus.js
index 4e94afb8d..ad915c270 100644
--- a/JukeboxPlus/JukeboxPlus.js
+++ b/JukeboxPlus/JukeboxPlus.js
@@ -14,12 +14,14 @@ API_Meta.JukeboxPlus = {
// Jukebox Plus Plus (Fully Enhanced UI with Album/Playlist Toggle, Track Tagging, and Layout Fixes)
// Changelong
// 1.0.0 Original
-// 1.0.1 Added Min/Max intervals, bug fixes, and simple Director integration.
+// 1.0.1 Added min/Max intervals, bug fixes, and simple Director integration.
// 1.0.2 Internal css restructuring for easier updates, Find function now accepts Regex
+// 1.0.3 Added Shuffle and Shuffle loop modes.
+
on('ready', () =>
{
- const version = '1.0.2'; //version number set here
+ const version = '1.0.3'; //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';
@@ -357,10 +359,12 @@ const helpHTML = `
10 tracks
@@ -378,8 +382,10 @@ const helpHTML = `
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.
+ Sequential — Plays all visible tracks one after the other.
+ ↻ — Plays all visible tracks one after the other, then starts over.
+ Shuffle — Plays all visible tracks in a random order, once through.
+ ↻ — Plays all visible tracks in a random order, reshuffling and looping after each full playthrough.
Mix — Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by StopAll
@@ -1204,28 +1210,45 @@ const trackList = getVisibleTrackList().map(buildTrackRow).join('');
-
+
+
+
@@ -1295,6 +1318,16 @@ on('change:jukeboxtrack', (obj, prev) => {
// If we've reached the end of the list
if (sequentialPlayState.currentIndex >= sequentialPlayState.trackIds.length) {
if (sequentialPlayState.loop) {
+ // 🔁 If in random-loop mode, reshuffle the order for the next pass
+ if (data.settings.playAllMode === 'random-loop') {
+ const shuffled = [...sequentialPlayState.trackIds];
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ sequentialPlayState.trackIds = shuffled;
+ }
+
sequentialPlayState.currentIndex = 0;
} else {
sequentialPlayState.active = false;
@@ -1328,7 +1361,6 @@ on('change:jukeboxtrack', (obj, prev) => {
if (prev.softstop === false && obj.get('softstop') === true && !obj.get('loop')) {
obj.set('playing', false); // Clear playing state so icon updates correctly
updateInterface();
- log("Track finished naturally, UI updated");
}
});
@@ -1472,6 +1504,116 @@ data.settings.playAllMode = 'together'; // ✅ Add this line
updateInterface();
}
+
+
+
+
+
+
+
+
+// Play all visible tracks once, in a random order (no repeats)
+if (command === 'playall-rand') {
+ const visibleTracks = getVisibleTrackList();
+ const max = 20;
+
+ const actualTracks = visibleTracks
+ .map(t => findLiveTrack(t.id))
+ .filter(Boolean)
+ .slice(0, max);
+
+ if (actualTracks.length === 0) {
+ sendStyledMessage('No tracks', 'No tracks found to play.');
+ return;
+ }
+
+ // Fisher–Yates shuffle for random order
+ const shuffledTracks = [...actualTracks];
+ for (let i = shuffledTracks.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]];
+ }
+
+ // Store track IDs for sequential handler to process
+ sequentialPlayState.trackIds = shuffledTracks.map(t => t.id);
+ sequentialPlayState.currentIndex = 0;
+ sequentialPlayState.active = true;
+ sequentialPlayState.loop = false;
+
+ // Stop any currently playing tracks
+ getAllTracks().forEach(t => t.set('playing', false));
+
+ // Start first randomized track
+ const firstTrack = shuffledTracks[0];
+ firstTrack.set('softstop', false);
+ firstTrack.set('playing', true);
+
+ data.settings.playAllMode = 'random'; // ✅ distinct mode label
+ updateInterface();
+}
+
+
+// Play all visible tracks in random order, then reshuffle and loop forever
+if (command === 'playall-rand-loop') {
+ const visibleTracks = getVisibleTrackList();
+ const max = 20;
+
+ const actualTracks = visibleTracks
+ .map(t => findLiveTrack(t.id))
+ .filter(Boolean)
+ .slice(0, max);
+
+ if (actualTracks.length === 0) {
+ sendStyledMessage('No tracks', 'No tracks found to play.');
+ return;
+ }
+
+ // Helper: Fisher–Yates shuffle
+ const shuffleTracks = (tracks) => {
+ const shuffled = [...tracks];
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+ };
+
+ // Shuffle for first play
+ const shuffledTracks = shuffleTracks(actualTracks);
+
+ // Store state and reshuffle behavior for future loops
+ sequentialPlayState.trackIds = shuffledTracks.map(t => t.id);
+ sequentialPlayState.currentIndex = 0;
+ sequentialPlayState.active = true;
+ sequentialPlayState.loop = true;
+ sequentialPlayState.reshuffleOnLoop = true; // 👈 new flag
+ sequentialPlayState.shuffleFn = () => {
+ const refreshedVisible = getVisibleTrackList()
+ .map(t => findLiveTrack(t.id))
+ .filter(Boolean)
+ .slice(0, max);
+ const reshuffled = shuffleTracks(refreshedVisible);
+ sequentialPlayState.trackIds = reshuffled.map(t => t.id);
+ };
+
+ // Stop any currently playing tracks
+ getAllTracks().forEach(t => t.set('playing', false));
+
+ // Start first randomized track
+ const firstTrack = shuffledTracks[0];
+ firstTrack.set('softstop', false);
+ firstTrack.set('playing', true);
+
+ data.settings.playAllMode = 'random-loop';
+ updateInterface();
+}
+
+
+
+
+
+
+
// Plays all visible tracks sequentially, starting from the first one
if (command === 'playall-seq') {
const visibleTracks = getVisibleTrackList();