diff --git a/JukeboxPlus/1.0.2/JukeboxPlus.js b/JukeboxPlus/1.0.2/JukeboxPlus.js new file mode 100644 index 000000000..4e94afb8d --- /dev/null +++ b/JukeboxPlus/1.0.2/JukeboxPlus.js @@ -0,0 +1,2449 @@ +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 +on('ready', () => +{ + + const version = '1.0.2'; //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 9e236b15d..4e94afb8d 100644 --- a/JukeboxPlus/JukeboxPlus.js +++ b/JukeboxPlus/JukeboxPlus.js @@ -14,11 +14,12 @@ 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 on('ready', () => { - const version = '1.0.1'; //version number set here + const version = '1.0.2'; //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'; @@ -86,169 +87,216 @@ if (!state[STATE_KEY].mixSession) { }; // Define both style sets - const cssLight = { + + + + + +const cssDark = { // 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;', + 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: 'font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px; color:#222; background:#cc9393; border-bottom:1px solid #ccc;', - gear: 'float:right; cursor:pointer; color:#666;', - trackCount: 'color:#333; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', + 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: '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;', - forceTextColor: 'color:#222', + 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: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; border:1px solid #666; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#333;', - headerButton: 'float:right; font-size:12px; padding:4px 6px; border:1px solid #666; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px; background:#ddd; color:#222;', - headerSubButton: 'font-size:11px; padding:1px 6px; border:1px solid #999; border-radius:3px; text-decoration:none; margin-top:-2px; background:#aaa; color:#333;', - headerSubButtonActive: 'font-size:11px; padding:1px 6px; border:1px solid #333; border-radius:3px; text-decoration:none; margin-top:-2px; background:#C27575; color:#333;', + 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; 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;', + nowPlayingButton: { color: '#444', background: '#eee' }, + refreshButton: { color: '#0066cc' }, //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;', + announceButton: { color: '#888' }, + announceTitle: { color: '#333' }, + announceDesc: { color: '#555' }, // 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;', + sidebarRule: { border: '0', "border-top": '1px solid #ccc' }, + sidebarLink: { color: '#444' }, + albumSelectedLink: { background: '#c22929', color: '#fff' }, + playlistSelectedLink: { background: '#2d5da6', color: '#fff' }, // 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;', + albumTag: { background: '#c22929', color: '#fff' }, + playlistTag: { background: '#2d5da6', color: '#fff' }, + tagRemove: { color: '#fff' }, // 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;', + 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;', + 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; 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;', -volumeControl: 'font-size:10px; color:#888; text-decoration:none; margin-left:8px; margin-top:4px; cursor:pointer;', + 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: 'width:100px; height:100px; background:#eee; text-align:center; font-size:11px; color:#999; border:1px solid #bbb; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', - imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #bbb; margin-right:8px; float:left; display:block;', - imagePlaceholder: 'width:100px; background:#eee; color:#999; text-align:center; font-size:11px; border:1px solid #bbb; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', + 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: '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;' + albumImage: { border: '1px solid #bbb' }, + albumHeaderDesc: { color: '#666' }, + addAlbum: { 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:#542d2d; border-bottom:1px solid #444;', - gear: 'float:right; cursor:pointer; color:#aaa;', - trackCount: 'color:#888; float:right; font-size:12px; display: inline-block; margin-right:15px; margin-top:5px;', - - // Buttons & Controls - button: 'display:block; margin-bottom:4px; width:100%; font-size:11px; background:#333; color:#ccc; border:1px solid #555;', - utilityContainer: 'width:90%; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; margin-top:6px; position:relative;', - utilitySubButton: 'font-size:11px; padding:1px 5px; background:#444; color:#ccc; border:1px solid #444; border-radius:3px; margin:-1px -1px 0px 3px; float:right; text-decoration:none;', - utilityButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-align:center; margin-top:6px; text-decoration:none;', - settingsButton: 'width:90%;display:inline-block; font-size:12px; padding:4px 6px; background:transparent; color:#ddd; text-align:center; margin-top:6px; text-decoration:none;', - headerButtonContainer: 'float:right; display:inline-block; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', - headerButton: 'float:right; font-size:12px; padding:4px 6px; background:#555; color:#ddd; border:1px solid #444; border-radius:4px; text-decoration:none; margin-top:-2px; margin-right:4px;', - headerSubButton: 'font-size:11px; padding:1px 6px; background:#444; color:#ddd; border:1px solid #444; border-radius:2px; text-decoration:none; margin-top:-2px;', - headerSubButtonActive: 'font-size:11px; padding:1px 6px; border:1px solid #333; border-radius:3px; text-decoration:none; margin-top:-2px; background:#C27575; color:#333;', - 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;', - forceTextColor: 'color:#ddd', - - //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;', -volumeControl: 'font-size:10px; color:#888; text-decoration:none; margin: 0px 6px; cursor:pointer;', - - // Images - image: 'width:100px; height:100px; background:#444; text-align:center; font-size:11px; color:#999; border:1px solid #666; float:left; margin-right:8px; object-fit:cover; object-position:center center; display:block;', - imageDiv: 'width:100px; height:100px; background-size:cover; background-position:center; border:1px solid #666; margin-right:8px; float:left; display:block;', - imagePlaceholder: 'width:100px; background:#444; color:#999; text-align:center; font-size:11px; border:1px solid #666; margin-right:8px; float:left; display:block; padding-top:35px; height:65px; line-height:18px;', - - // Album specific - albumImage: 'width:80px; height:80px; object-fit:cover; border:1px solid #666; margin-right:8px;', - albumHeaderDesc: 'font-size:12px; color:#bbb;', - addAlbum: 'font-size:10px; margin-top:8px; display:block; color:#ccc;' - }; +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 @@ -2233,9 +2281,9 @@ data.albumSortOrder = data.albumSortOrder.map(n => n === oldName ? newName : n); // Finds tracks by a search term, marking matches into a special 'Found' or 'Duplicates' album if (command === 'find') { - const searchTerm = args.join(' ').toLowerCase().trim(); + const rawSearchTerm = args.join(' ').trim(); - if (!searchTerm) { + if (!rawSearchTerm) { sendStyledMessage('Find Tracks', 'You must provide a search term.', false); return; } @@ -2252,7 +2300,7 @@ if (command === 'find') { } }); - if (searchTerm === 'd') { + if (rawSearchTerm.toLowerCase() === 'd') { // Special case: Find tracks with duplicate names const nameMap = {}; Object.values(data.tracks).forEach(track => { @@ -2282,14 +2330,11 @@ if (command === 'find') { 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); - - + // 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); @@ -2299,11 +2344,33 @@ data.trackOrder = duplicates // 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); - }); + 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')) { @@ -2312,7 +2379,7 @@ data.trackOrder = duplicates }); if (matches.length === 0) { - sendStyledMessage('Find Tracks', `No tracks matched the search: "${searchTerm}"`, false); + sendStyledMessage('Find Tracks', `No tracks matched the search: "${esc(rawSearchTerm)}"`, false); return; } @@ -2320,7 +2387,6 @@ data.trackOrder = duplicates data.settings.selectedAlbum = 'Found'; updateInterface(); - //sendStyledMessage('Find Tracks', `Found ${matches.length} track${matches.length !== 1 ? 's' : ''} matching "${searchTerm}"`, false); } diff --git a/JukeboxPlus/readme.md b/JukeboxPlus/readme.md index d739443f2..ff9caf45c 100644 --- a/JukeboxPlus/readme.md +++ b/JukeboxPlus/readme.md @@ -35,6 +35,7 @@ At the top right of the interface: **Stop All** — Stops all currently playing tracks. Also use to stop a Mix. **Find** — Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called **Found**. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel. If you input `"d"` as the search term, it will create a temporary playlist of any duplicate tracks, grouped by name. +Find also accepts grep searchs if enclosed by slashes. **Help** — Displays this help page. Click **Return to Player** to return. --- diff --git a/JukeboxPlus/script.json b/JukeboxPlus/script.json index 9ccda23d3..bdc5c0749 100644 --- a/JukeboxPlus/script.json +++ b/JukeboxPlus/script.json @@ -1,8 +1,8 @@ { "name": "Jukebox Plus", "script": "JukeboxPlus.js", - "version": "1.0.1", - "description": "# Director\n\n**Director** is a script for supporting \"theater of the mind\"-style play in Roll20. It provides an interface for managing scenes, images, audio, and game assets — all organized within a persistent handout.\n\n[Here is a video](https://youtu.be/TMYzFNTkiNU?si=yexMBPtz0sXNdx_o) that provides a demo of the script.\n\n---\n\n## Interface Overview\n\nThe interface appears in a Roll20 handout and consists of four main sections:\n\n- **Acts & Scenes** — scene navigation and management \n- **Images** — backdrops, highlights, and associated tracks \n- **Items** — characters, variants, macros, and token-linked objects \n- **Utility Controls** — edit mode, help toggle, settings, backup tools \n\n---\n\n## Acts & Scenes\n\n### Act Controls\n\nActs group together related scenes. Use the `+ Add Act` button to create one.\n\nIn **Edit Mode**, you can:\n- Rename or delete acts\n- Move acts up or down\n\n### Scene Controls\n\nEach scene represents a distinct moment or location. Click a scene name to set it active — this controls what images and items are shown.\n\nIn **Edit Mode**, you can:\n- Rename or delete scenes\n- Move scenes up or down (scenes moved beyond an act will join the next expanded act)\n\n---\n\n## Images\n\n### Backdrop vs. Highlight\n\n- **Backdrop**: Main background image placed on the Map Layer \n- **Highlights**: Visuals layered above the backdrop on the Object Layer (for focus or emphasis) \n\nWhen a scene is set:\n- The backdrop is placed on the map\n- All highlights appear just off the left edge of the page\n\nHighlights can be dragged manually, or previewed using `Shift+Z`.\n\n### Adding Images\n\n1. Drag a graphic to the tabletop (hold `Alt`/`Option` to preserve aspect ratio) \n2. Select the graphic and click `+ Add Image` in the interface\n\n### Image Controls\n\n- **Title**: Click to rename \n- **Bottom-right icons**: \n - `expanding arrows icon` = Set as Backdrop \n - `overlapping rectangles icon` = Set as Highlight \n - `music note icon` = Assign currently playing track. This track will auto play whenever the image becomes a backdrop image.\n- In **Edit Mode**: \n - Move an image up or down. Although the backdrop image always goes to the top\n - Recapture\n - Delete\n\n### Mute Button\n\nToggles automatic track playback. When red, backdrops will no longer auto-start audio.\n\n---\n\n## Items (Characters, Variants, Tracks, Macros, Tables)\n\nItems define what gets placed or triggered when a scene is set. Items are scoped per scene.\n\n### Adding Items\n\nClick a badge to add a new item:\n- `H` = Handout \n- `C` = Character \n- `V` = Variant \n- `T` = Track \n- `M` = Macro \n- `R` = Rollable Table \n\n### Item Behavior\n\n| Badge | Type | Behavior |\n|-------|------------|--------------------------------------------------------------------------|\n| `H` | Handout | Opens the handout |\n| `C` | Character | Opens the sheet if assigned; otherwise prompts for assignment |\n| `V` | Variant | Places token on scene set (does not open a sheet) |\n| `T` | Track | Toggles playback; assigns current track if none assigned |\n| `M` | Macro | Runs macro if assigned; otherwise prompts to choose an existing macro |\n| `R` | Table | Rolls the assigned table; result whispered to GM |\n\n> Variants are token snapshots that share a character sheet. Use them to represent alternate versions of a character or avoid issues with default token behavior.\n\n### Edit Mode Controls\n\nWhile in **Edit Mode**, each item displays:\n- `pencil icon` — Reassign\n- `trash icon` — Delete\n\nYou can also click the `magnifying glass icon` icon to filter items by type.\n\n---\n\n## Header Buttons\n\n### Mix Rate\n\nYou can customize how often non-looping tracks play in **Mix** mode using the **Mix Rate** settings (found in the utility section). \nThese control the interval (in seconds) between each randomly selected track:\n- `Mix Min` — the shortest possible delay between tracks\n- `Mix Max` — the longest delay between tracks\n- `↻` — resets to the default range of 10–60 seconds \nLooping tracks continue to play normally and are not affected by these settings.\n\n### Set the Scene\n\n`Set the Scene` places all scene elements on the tabletop:\n\n- Backdrop (Map Layer)\n- Highlights (Object Layer, off-page)\n- Items (Object Layer, off-page right)\n- Starts assigned track (if any)\n\n> Only works on pages named: `scene`, `stage`, `theater`, or `theatre`\n\n### Wipe the Scene\n\n`Wipe the Scene` removes all images and stops all audio.\n\n> Only works on valid stage pages.\n\n### Edit Mode\n\nToggles editing. When enabled:\n- Rename, delete, and move controls appear for acts, scenes, and images\n- Items display grouped by type with assign/delete icons\n\n### JB+\n\nIf Jukebox Plus is installed, this button appears and provides a chat link to launch its controls.\n\n### Help\n\nDisplays this help interface. While in help mode, this changes to \"Exit Help\".\n\n### Make Help Handout\n\nCreates a handout containing the help documentation. Use it to reference instructions while working in the main interface.\n\n---\n\n## Helpful Macros\n\nThese commands can be used in the chat or bound to macro/action buttons:\n\n`!director --set-scene`\n\n`!director --wipe-scene`\n\n`!director --new-act|Act I`\n\n`!director --new-scene|Act I|Opening Scene`\n\n`!director --capture-image`", + "version": "1.0.2", + "description": "# Jukebox Plus \n\nJukebox Plus lets you organize and control music tracks by **albums** or **playlists**. \nUse the toggle buttons in the sidebar to switch between views. Tracks are displayed on the right, and control buttons appear for each one. \n \n--- \n \n## Getting Started \n\nIssue the command `!jb` to create or refresh the Jukebox Plus handout. This is where all of the Jukebox Plus controls appear. \n \n--- \n \n## Header Buttons \n\nAt the top right of the interface: \n \n`Play All` `Together` `In Order` `Loop` `Mix` \n`Loop All` `Off` `On` \n`Stop All` `Find` `Help` \n \n### Button Descriptions \n \n**Play All** \n **Together** - Plays all visible tracks simultaneously. Limited to the first five visible. \n **In Order** - Plays all visible tracks one after the other. \n **Loop** - Plays all visible tracks one after the other, then starts over. \n **Mix** - Plays all looping tracks continuously, and all other tracks at random intervals. Use to create a custom soundscape. Stopped by `Stop All`. \n \n**Loop All** \n **Off** - Disables loop mode for all visible tracks \n **On** - Enables loop mode for all visible tracks \n \n**Stop All** - Stops all currently playing tracks. Also use to stop a Mix. \n**Find** - Search all track names and descriptions for the keyword. All matching tracks will be assigned to a temporary album called **Found**. You can then switch to the Found album to quickly view the results. To clear the results, simply delete the Found album using the Utility panel. \nIf you input \"d\" as the search term, it will create a temporary playlist of any duplicate tracks, grouped by name. \n**Help** - Displays this help page. Click **Return to Player** to return. \n \n--- \n \n## Sidebar: Navigation & Now Playing \n \n**View Mode Toggle** \n\nThe left sidebar lists all albums or playlists, depending on the current view mode. Clicking a name switches the view. \n \n`Albums` `Playlists` \n\nThese buttons let you switch between organizing by: \n **Albums** you define and tag yourself \n **Playlists** as defined in the Roll20 Jukebox system (not editable here) \n\nAt the bottom of the list: \n`Now Playing` - Filters the list to show only tracks currently playing. \n \n--- \n \n## Track Controls \n\nEach track shows these control buttons (these will be graphic buttons when viewed in the game): \n \n `play` **Play** - Start the track \n `loop` **Loop** - Toggle loop mode for the track \n `isolate` **Isolate** - Stops all others and plays only this one \n `stop` **Stop** - Stops this track \n `announce` **Announce** - Sends the track name and description to the chat window \n \n--- \n \n## Track Info and Management \n \n**Edit** - Click the track description \"edit\" link to create a description. \n\nDescription special characters: \n `---` inserts a line break \n `*italic*` uses single asterisks for italic text \n `**bold**` uses double asterisks for bold text \n `!d` or `!desc` includes the description when you Announce a track \n `!a` or `!announce` makes the track auto-announce on play \n \n**Tags** \nEach track has a Playlist tag and may have one or more Album tags. \n- `Playlist` tags are in blue \n- `Album` tags are in red \n\nClick `+ Add` to add an Album tag. \nClick a tag to jump to that Album or Playlist. \nClick the `x` on an Album tag to remove it: `Album name | x` \n \n**Image Area** \nClick the image area to enter either: \n- a valid image URL \n- a CSS color name (e.g. \"red\") \n- a hexadecimal color code (e.g. `#00ff00`) \n\nIf you provide an image URL, it will display beside the track name and in the chat on announce. \nIf you provide a color code, the square will show that color and use it when Announcing. \n \n--- \n \n## Utility Panel \n\nClick `Settings` to expand the utility tools. Includes: \n \n### Album Controls \n`Edit Albums:` `-` `+` `edit pencil icon` \nRename, add, or delete the currently selected album \n \n### Sorting \nA—Z: `albums` `tracks` \nAlphabetize Albums or the tracks within an Album \n \n### Mix Interval \n`reset` Resets to default mix intervals \n`min` Minimum seconds between tracks \n`max` Maximum seconds between tracks \nMix will choose a random time between these values for each interval. \n \n### Mode \nMode: `dark` `light` \nSwitch between dark and light interface modes \n \n### Refresh \n`Refresh` - Rebuilds the interface if something breaks \n \n### Backup \nBackup: `make` `restore` \n- Create or restore from backup handouts containing your album and playlist data \n- Use this to move data between games via the Roll20 Transmogrifier \n \n**Note:** Tracks are linked by ID, which changes between games. The script tries to match by name during restore, but renames and duplicates may cause mismatches. \n \n--- \n \n## Find \n\nUse the `!jb find keyword` command to search all track names and descriptions. \nMatching tracks are added to a temporary album called **Found**. \nDelete the Found album to clear the search results. \n \n--- \n \n## Useful Macros \n\nHere are some chat commands you can use in macros: \n \n- `!jb` - Show link to open the interface \n- `!jb play TrackName` - Play the named track \n- `!jb stopall` - Stop all currently playing audio \n- `!jb loopall` - Enable loop mode for visible tracks \n- `!jb unloopall` - Disable loop mode on all tracks \n- `!jb jump album AlbumName` - Switch to the given album \n- `!jb help` - Open this help screen \n- `!jb find keyword` - Search for keyword and assign matches to the \"Found\" album \n\nYou can also discover commands by pressing a control button, clicking in the chat window, and pressing the **Up Arrow** to see what was sent. \n", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], @@ -10,5 +10,5 @@ "jukeboxtrack": "read, write" }, "conflicts": [], - "previousversions": ["1.0.0","1.0.1"] + "previousversions": ["1.0.0","1.0.1","1.0.2"] }