The problem
If you GM on Roll20, you know the drill. You've got the Jukebox tab open, you've got twenty tracks in there, half of them named things like "Track_047_final_v2", and the party just walked into a room where you need the tense exploration music to hit right now, not in fifteen seconds of scrolling.
The native Jukebox works fine for basic playback, but it wasn't built for finding a specific track fast in the middle of a session. There's no real tagging system you can search live, switching songs is an abrupt cut (no fade), and there's no easy way to say "play me something boss-fight-ish" and get options.
I run a lot of combat and atmosphere-heavy sessions, so I built a script to fix this.
The solution
MusicManager is a Roll20 Mod API script that sits on top of your existing Jukebox. It doesn't replace it or ask you to re-upload anything. You keep organizing your tracks and native playlists exactly like you already do. The script just adds a smarter layer on top:
Tags. Tag any track (or a whole playlist at once) with whatever words make sense to you: boss , tavern , creepy , combat-final . Then search by tag mid-session and get instant buttons to click. Crossfade. When you play a new track, the old one fades out while the new one fades in at the same time, instead of an abrupt cut. You can turn this on/off with one click if you just want instant switching sometimes. A GM-only menu. Everything happens through a whispered chat menu with clickable buttons, so your players don't see the mechanics, just the mood shift. See what's actually playing. The menu shows every track currently playing in the Jukebox (even ones you started manually) with a stop button next to each. Your own playlists. On top of your native Jukebox playlists, you can also build your own groupings inside the script, useful if you want to organize by scene or mood rather than by however the Jukebox folders are set up. Export/import. Your tags and custom playlists can be exported as a chunk of text and imported again later, or into a different game (it matches tracks by title, so it survives moving between campaigns).
How it actually works, under the hood
Roll20's API exposes the tracks in your Jukebox as objects you can read and control (play, pause, set volume). It also lets you read the structure of your native playlists, but you can't create or edit them through the API, only through the UI, the same way you already do.
So the script reads that structure, and layers its own data on top (stored in the game's persistent state): which tags a track has, what its "default" volume should be, which of your own playlists it belongs to. When you hit play, the script fades the volume down on whatever's currently going, fades the new track up at the same time, and swaps which one is marked as playing once the fade finishes.
None of your actual audio files or native playlists are touched. If you ever remove the script, your Jukebox is exactly how it was.
Basic flow
Upload and organize your tracks in the Jukebox like normal. Run !music sync so the script picks up everything that's there. Tag things, either one at a time or in bulk per playlist. During the game, open !music menu or just search !music find <tag or name> and click what you want.
Commands
There's a full command list in the script's header comment, plus !music help in chat gives you a quick summary any time. The main ones you'll actually use during a session are !music menu , !music find <term> , and !music stop . Everything else (tagging, playlists, fade settings) is more of a setup-time thing you do once and forget about.
Why share it
I built this for my own table, but the amount of time it saves during actual play (finding the right vibe in two seconds instead of scrolling a track list while your players wait) felt worth passing along. If you run heavy combat/atmosphere games, it should save you the same headache it saved me.
Feedback, bug reports, and ideas are welcome. // ==================================================================
// MusicManager v1.3
// Gerenciador de músicas/playlists para Roll20 (Mod API)
// Autor: Daniel Santos (<a href="mailto:danielbase@gmail.com" rel="nofollow">danielbase@gmail.com</a>)
// ==================================================================
//
// COMANDOS PRINCIPAIS
// !music menu -> abre o menu principal (GM)
// !music sync -> importa faixas/playlists nativas da Jukebox
// !music find <termo> -> busca por nome/tag, mostra botões
// !music play <nome|id> -> toca (crossfade ou empilha, depende do layer mode)
// !music playid <id> -> toca por id (usado pelos botões)
// !music layertoggle -> liga/desliga o modo de empilhar músicas
// !music stop -> para TUDO que estiver tocando na Jukebox
// !music stopid <id> -> para uma faixa específica (usado pelos botões)
// !music forcestop -> parada instantânea de emergência, sem fade
// !music showplaylist <nome> -> lista as faixas da playlist pra escolher qual tocar
// !music tag <nome> +tag / -tag -> adiciona/remove tag
// !music tagplaylist <playlist> +tag -> tagueia todas as faixas da playlist de uma vez
// !music rename <nome> <novo nome> -> muda o apelido
// !music volume <nome> <0-100> -> define volume padrão da faixa
// !music remove <nome> -> desassocia a faixa do gerenciador
// !music playlist create <nome>
// !music playlist delete <nome>
// !music playlist add <playlist> <faixa>
// !music playlist removetrack <playlist> <faixa>
// !music playlist play <nome> -> toca a playlist (sequencial, com fade)
// !music list tracks|playlists|tags
// !music fade <segundos> -> configura duração do fade (padrão 3s)
// !music fadetoggle -> liga/desliga o fade
// !music export -> exporta suas playlists/tags próprias em JSON
// !music import <json> -> importa (casa faixas pelo título)
// !music warmup <tag> -> deixa faixas da tag tocando em loop/vol 0 (uso instantâneo depois)
// !music help
// ==================================================================
var MusicManager = MusicManager || (function () {
'use strict';
var VERSION = '1.3';
var DEFAULT_FADE_MS = 3000;
var FADE_STEPS = 20;
// ---------------- estado em memória (não persiste, recriado a cada load) ----------------
var playback = {
currentTrackId: null,
queue: [], // fila de ids restantes quando tocando playlist
queueMode: null // 'playlist' | null
};
// ---------------- state persistente ----------------
function checkState() {
if (!state.MusicManager) {
state.MusicManager = {
version: VERSION,
tracks: {}, // id -> {alias, tags:[], volume}
playlists: {}, // id -> {name, trackIds:[]}
nativePlaylists: {}, // id -> {name, mode, trackIds:[]}
settings: { fadeMs: DEFAULT_FADE_MS }
};
}
}
function db() { return state.MusicManager; }
// ---------------- utilidades ----------------
function uid(prefix) {
return (prefix || 'pl_') + Math.random().toString(36).slice(2, 10);
}
function whisperGM(msg) {
sendChat('MusicManager', '/w gm ' + msg);
}
function sendMenu(html) {
sendChat('MusicManager', '/w gm ' + html);
}
function getJukeboxTrack(id) {
return getObj('jukeboxtrack', id);
}
function allJukeboxTracks() {
return findObjs({ _type: 'jukeboxtrack' });
}
function trackMeta(id) {
return db().tracks[id];
}
// Ordena por alias e devolve os que combinam com o termo (nome ou tag)
function searchTracks(term) {
term = (term || '').toLowerCase().trim();
var results = [];
_.each(db().tracks, function (meta, id) {
var nameMatch = meta.alias && meta.alias.toLowerCase().indexOf(term) !== -1;
var tagMatch = _.some(meta.tags || [], function (t) {
return t.toLowerCase().indexOf(term) !== -1;
});
if (!term || nameMatch || tagMatch) {
results.push(_.extend({ id: id }, meta));
}
});
return _.sortBy(results, function (r) { return r.alias.toLowerCase(); });
}
// Busca "melhor palpite" para comandos digitados (!music play <nome>)
function resolveTrack(query) {
if (db().tracks[query]) return query; // já é um id
query = (query || '').toLowerCase().trim();
var exact = _.find(_.keys(db().tracks), function (id) {
return db().tracks[id].alias.toLowerCase() === query;
});
if (exact) return exact;
var partial = searchTracks(query);
if (partial.length === 1) return partial[0].id;
return null; // ambíguo ou não encontrado -> quem chamou decide o que fazer
}
// ---------------- sync com a Jukebox nativa ----------------
function syncNative() {
checkState();
var imported = 0;
_.each(allJukeboxTracks(), function (track) {
var id = track.id;
if (!db().tracks[id]) {
db().tracks[id] = {
alias: track.get('title') || 'Sem título',
tags: [],
volume: track.get('volume') || 70
};
imported++;
}
});
// playlists nativas (somente leitura)
var folders = [];
try {
folders = JSON.parse(Campaign().get('_jukeboxfolder')) || [];
} catch (e) {
folders = [];
}
db().nativePlaylists = {};
_.each(folders, function (f) {
if (f && _.isObject(f) && f.n) {
db().nativePlaylists[f.id] = {
name: f.n,
mode: f.s === 's' ? 'shuffle' : 'sequencial',
trackIds: f.i || []
};
}
});
whisperGM('🔄 Sync completo: ' + imported + ' faixa(s) nova(s) importada(s), ' +
_.keys(db().nativePlaylists).length + ' playlist(s) nativa(s) detectada(s).');
}
// ---------------- fade / playback ----------------
function isFadeEnabled() {
return db().settings.fadeEnabled !== false;
}
function isLayerMode() {
return db().settings.layerMode === true;
}
function getPlayingTracks() {
return _.filter(allJukeboxTracks(), function (t) { return t.get('playing'); });
}
// Cada faixa tem seu PRÓPRIO fade rastreado por id, então cancelar o fade
// de uma faixa nunca interrompe/perde o fade de outra faixa por engano.
var activeFades = {};
function cancelFade(trackId) {
if (activeFades[trackId]) {
clearInterval(activeFades[trackId]);
delete activeFades[trackId];
}
}
function genericFade(track, toVol, durationMs, onDone) {
cancelFade(track.id); // se já havia um fade rodando NESSA faixa, substitui
var startVol = track.get('volume') || 0;
var steps = FADE_STEPS;
var stepTime = Math.max(50, durationMs / steps);
var stepAmount = (toVol - startVol) / steps;
var count = 0;
var intervalId = setInterval(function () {
count++;
var newVol = startVol + stepAmount * count;
newVol = Math.max(0, Math.min(100, newVol));
track.set('volume', Math.round(newVol));
if (count >= steps) {
clearInterval(intervalId);
delete activeFades[track.id];
track.set('volume', Math.round(toVol));
if (toVol <= 0) track.set('playing', false);
if (onDone) onDone();
}
}, stepTime);
activeFades[track.id] = intervalId;
return intervalId;
}
// fade de uma faixa arbitrária (usado por stop individual / parar tudo)
function fadeOutArbitrary(track, durationMs, onDone) {
if (!isFadeEnabled()) {
cancelFade(track.id);
track.set('volume', 0);
track.set('playing', false);
if (onDone) onDone();
return;
}
genericFade(track, 0, durationMs, onDone);
}
function fadeOutTrack(id, durationMs, onDone) {
var track = getJukeboxTrack(id);
if (!track) { if (onDone) onDone(); return; }
fadeOutArbitrary(track, durationMs, onDone);
}
function fadeInTrack(id, targetVol, durationMs) {
var track = getJukeboxTrack(id);
if (!track) return;
var startPlaying = function () {
// softstop travado em "true" (de uma interação nativa antiga, ou de
// outro script) faz o Roll20 ignorar/atrasar o playing:true. O Simple
// Sound sempre reseta isso antes de tocar, e nós também passamos a fazer.
track.set('softstop', false);
if (!isFadeEnabled()) {
cancelFade(track.id);
track.set('volume', targetVol);
track.set('playing', true);
return;
}
track.set('playing', true);
genericFade(track, targetVol, durationMs);
};
// Se o Roll20 já "acha" que essa faixa está tocando (por exemplo, ficou
// travada de um teste anterior ou de uma troca rápida demais), só marcar
// "true" de novo não dispara o áudio pro Roll20, porque pra ele não houve
// mudança de estado nenhuma. Por segurança, forçamos um "false" primeiro
// sempre que detectamos esse caso, e só então tocamos de verdade.
if (track.get('playing')) {
cancelFade(track.id);
track.set('playing', false);
track.set('softstop', false);
setTimeout(startPlaying, 100);
} else {
startPlaying();
}
}
function playTrackId(id, opts) {
opts = opts || {};
checkState();
var meta = trackMeta(id);
if (!meta) { whisperGM('⚠️ Faixa não encontrada no gerenciador. Rode !music sync.'); return; }
var targetVol = opts.volume != null ? opts.volume : (meta.volume || 70);
var fadeMs = db().settings.fadeMs || DEFAULT_FADE_MS;
var tagsStr = meta.tags.length ? ' [' + meta.tags.join(', ') + ']' : '';
if (isLayerMode()) {
// Layer mode: só acrescenta, nunca mexe no que já está tocando.
fadeInTrack(id, targetVol, fadeMs);
whisperGM('🎛 Adicionado por cima: **' + meta.alias + '**' + tagsStr);
return;
}
var previous = playback.currentTrackId;
playback.currentTrackId = id;
// fade-out da faixa anterior e fade-in da nova rodam ao mesmo tempo (crossfade)
// cada uma cancela só o PRÓPRIO fade anterior, nunca o da outra faixa
if (previous && previous !== id) {
fadeOutTrack(previous, fadeMs); // sem callback: não espera terminar
}
fadeInTrack(id, targetVol, fadeMs);
whisperGM('▶️ Tocando: **' + meta.alias + '**' + tagsStr);
}
// Para TODAS as faixas que estiverem tocando na Jukebox, mesmo as que não
// foram iniciadas pelo script (ex: tocadas direto pela aba nativa).
function stopEverything(callback) {
var fadeMs = db().settings.fadeMs || DEFAULT_FADE_MS;
playback.queue = [];
playback.queueMode = null;
playback.currentTrackId = null;
var playing = getPlayingTracks();
if (!playing.length) { if (callback) callback(); return; }
var remaining = playing.length;
_.each(playing, function (t) {
fadeOutArbitrary(t, fadeMs, function () {
// Restaura o volume da faixa pro padrão dela (se ela for
// gerenciada) em vez de deixar gravado em 0 permanentemente -
// senão a próxima vez que alguém tocar ela manualmente (fora
// do MusicManager) toca mudo.
t.set('softstop', false);
var meta = trackMeta(t.id);
if (meta) t.set('volume', meta.volume || 70);
remaining--;
if (remaining <= 0 && callback) callback();
});
});
}
// Para uma faixa específica pelo id (usado pelo botão ⏸ no "tocando agora")
function stopSingle(id, callback) {
var track = getJukeboxTrack(id);
if (!track) { if (callback) callback(); return; }
var fadeMs = db().settings.fadeMs || DEFAULT_FADE_MS;
if (playback.currentTrackId === id) {
playback.currentTrackId = null;
}
fadeOutArbitrary(track, fadeMs, callback);
}
// toca uma lista de ids em sequência (playlist)
function playQueue(ids) {
playback.queue = ids.slice();
playback.queueMode = 'playlist';
advanceQueue();
}
function advanceQueue() {
if (playback.queueMode !== 'playlist' || !playback.queue.length) {
playback.queueMode = null;
return;
}
var nextId = playback.queue.shift();
playTrackId(nextId);
}
// Detecta o fim natural de uma faixa (quando o Roll20 marca playing=false sozinho,
// por não ter loop) para avançar a fila da playlist.
on('change:jukeboxtrack', function (obj) {
if (playback.queueMode === 'playlist' &&
obj.id === playback.currentTrackId &&
obj.get('playing') === false &&
!activeFades[obj.id]) {
// a faixa terminou sozinha (não foi um stop manual nosso)
playback.currentTrackId = null;
advanceQueue();
}
});
// ---------------- export / import (playlists e tags próprias do script) ----------------
function exportData() {
checkState();
var tracksArr = [];
_.each(db().tracks, function (meta, id) {
var native = getJukeboxTrack(id);
var title = native ? native.get('title') : meta.alias;
tracksArr.push({ oldId: id, title: title, alias: meta.alias, tags: meta.tags, volume: meta.volume });
});
var payload = { tracks: tracksArr, playlists: db().playlists };
var json = JSON.stringify(payload);
whisperGM(
'<b>📤 Export</b><br>Copie todo o texto da caixa abaixo e guarde num arquivo. ' +
'Pra trazer de volta (nesta ou em outra campanha), use <code>!music import &lt;cole aqui&gt;</code>.<br>' +
'<textarea style="width:95%;height:140px;font-size:11px;">' + json + '</textarea>'
);
}
function importData(jsonStr) {
checkState();
var payload;
try {
payload = JSON.parse(jsonStr);
} catch (e) {
whisperGM('⚠️ JSON inválido — confira se copiou o texto completo da caixa de export.');
return;
}
var oldToNew = {};
var matched = 0;
var unmatched = [];
_.each(payload.tracks || [], function (t) {
var native = _.find(allJukeboxTracks(), function (j) {
return (j.get('title') || '').toLowerCase() === (t.title || '').toLowerCase();
});
if (native) {
db().tracks[native.id] = {
alias: t.alias || native.get('title'),
tags: t.tags || [],
volume: t.volume != null ? t.volume : 70
};
oldToNew[t.oldId] = native.id;
matched++;
} else {
unmatched.push(t.title);
}
});
var plImported = 0;
_.each(payload.playlists || {}, function (pl) {
var newIds = _.compact(_.map(pl.trackIds, function (oid) { return oldToNew[oid]; }));
var existingId = findPlaylistByName(pl.name);
if (existingId) {
db().playlists[existingId].trackIds = _.uniq(db().playlists[existingId].trackIds.concat(newIds));
} else {
var newPlId = createPlaylist(pl.name);
db().playlists[newPlId].trackIds = newIds;
}
plImported++;
});
whisperGM('📥 Import concluído: ' + matched + ' faixa(s) associada(s), ' + plImported + ' playlist(s) processada(s).' +
(unmatched.length ? '<br>⚠️ Não encontradas nesta campanha (o título precisa bater exatamente): ' + unmatched.join(', ') : ''));
}
// ---------------- playlists virtuais ----------------
function createPlaylist(name) {
checkState();
var id = uid('pl_');
db().playlists[id] = { name: name, trackIds: [] };
return id;
}
function findPlaylistByName(name) {
name = (name || '').toLowerCase().trim();
return _.find(_.keys(db().playlists), function (id) {
return db().playlists[id].name.toLowerCase() === name;
});
}
// ---------------- menus (HTML em chat) ----------------
function btn(label, command) {
return '<a style="display:inline-block;margin:2px;padding:3px 8px;background:#3a3a3a;' +
'color:#fff;border-radius:4px;text-decoration:none;font-size:12px;" href="' +
command + '">' + label + '</a>';
}
function nowPlayingHtml() {
var playing = getPlayingTracks();
if (!playing.length) return '⏹ Nada tocando<br>';
var html = '<b>🎧 Tocando agora</b><br>';
_.each(playing, function (t) {
var meta = trackMeta(t.id);
var label = meta ? meta.alias : (t.get('title') || 'Sem título');
html += '▶ ' + label + ' ' + btn('⏸ parar', '!music stopid|' + t.id) + '<br>';
});
return html;
}
function mainMenu() {
checkState();
var html = '<div style="border:1px solid #666;padding:6px;border-radius:6px;">';
html += '<b>🎵 Music Manager</b><br>';
html += nowPlayingHtml();
html += btn('⏹ Parar tudo', '!music stop') + btn('🔄 Sync', '!music sync');
html += btn('🎚 Fade: ' + (isFadeEnabled() ? 'ON' : 'OFF'), '!music fadetoggle');
html += btn('🎛 Layer: ' + (isLayerMode() ? 'ON' : 'OFF'), '!music layertoggle') + '<br><br>';
html += '<b>Playlists</b><br>';
_.each(db().playlists, function (pl, id) {
html += btn('📂 ' + pl.name, '!music showplaylist|' + pl.name);
html += btn('🏷', '!music tagplaylist ?{Nome da playlist|' + pl.name + '} ?{Tag (+tag ou -tag)|+}') + '<br>';
});
_.each(db().nativePlaylists, function (pl, id) {
html += btn('📂 ' + pl.name + ' (nativa)', '!music showplaylist|' + pl.name);
html += btn('🏷', '!music tagplaylist ?{Nome da playlist|' + pl.name + '} ?{Tag (+tag ou -tag)|+}') + '<br>';
});
html += '<br><br><b>Tags</b><br>';
var tagSet = {};
_.each(db().tracks, function (meta) { _.each(meta.tags || [], function (t) { tagSet[t] = true; }); });
_.each(_.keys(tagSet), function (t) {
html += btn('#' + t, '!music find|' + t);
});
html += '<br><br>' + btn('📤 Exportar playlists/tags', '!music export');
html += '</div>';
sendMenu(html);
}
// Mostra as faixas de uma playlist (nativa ou própria) pra escolher qual tocar
function showPlaylistMenu(query) {
var ids = null, displayName = query, playAllHref = null;
var virtId = findPlaylistByName(query);
if (virtId) {
ids = db().playlists[virtId].trackIds;
displayName = db().playlists[virtId].name;
playAllHref = '!music playlist play|' + displayName;
} else {
var nativePair = _.find(_.pairs(db().nativePlaylists), function (p) {
return p[1].name.toLowerCase() === query.toLowerCase();
});
if (nativePair) {
ids = nativePair[1].trackIds;
displayName = nativePair[1].name;
playAllHref = '!music playlist playnative|' + nativePair[0];
}
}
if (!ids) { whisperGM('⚠️ Playlist "' + query + '" não encontrada.'); return; }
if (!ids.length) { whisperGM('Playlist "' + displayName + '" está vazia.'); return; }
var html = '<div><b>📂 ' + displayName + '</b><br>';
_.each(ids, function (id) {
var meta = trackMeta(id);
var label = meta ? meta.alias : '(faixa não sincronizada — rode !music sync)';
var tagsStr = meta && meta.tags && meta.tags.length ? ' [' + meta.tags.join(',') + ']' : '';
html += btn('▶ ' + label + tagsStr, '!music playid|' + id) + '<br>';
});
html += '<br>' + btn('⏩ Tocar tudo em sequência', playAllHref);
html += '</div>';
sendMenu(html);
}
function findMenu(term) {
var results = searchTracks(term);
if (!results.length) { whisperGM('Nenhuma faixa encontrada para "' + term + '".'); return; }
var html = '<div><b>🔍 Resultados para "' + term + '"</b><br>';
_.each(results, function (r) {
html += btn('▶ ' + r.alias + (r.tags.length ? ' [' + r.tags.join(',') + ']' : ''), '!music playid|' + r.id);
});
html += '</div>';
sendMenu(html);
}
// ---------------- parsing de comandos ----------------
function splitArgs(str) {
return (str || '').trim().length ? str.trim().split(/\s+/) : [];
}
function handleInput(msg) {
if (msg.type !== 'api') return;
if (!playerIsGM(msg.playerid)) return; // ferramenta é só pro GM
if (msg.content.indexOf('!music') !== 0) return;
checkState();
if (msg.content.indexOf('!music export') === 0) { exportData(); return; }
if (msg.content.indexOf('!music import ') === 0) {
importData(msg.content.slice('!music import '.length));
return;
}
// usamos "|" como separador nos botões pra suportar nomes com espaço
var rest = msg.content.replace('!music', '').trim();
var pipeParts = rest.split('|');
var cmd = splitArgs(pipeParts[0])[0] || 'menu';
var restArgs = pipeParts.length > 1 ? pipeParts.slice(1).join('|') : splitArgs(rest).slice(1).join(' ');
switch (cmd) {
case 'menu': mainMenu(); break;
case 'sync': syncNative(); break;
case 'find': findMenu(restArgs); break;
case 'playid': playTrackId(restArgs); break;
case 'play': {
var id = resolveTrack(restArgs);
if (!id) {
var matches = searchTracks(restArgs);
if (matches.length > 1) {
findMenu(restArgs);
} else {
whisperGM('⚠️ Faixa "' + restArgs + '" não encontrada.');
}
} else {
playTrackId(id);
}
break;
}
case 'stop': stopEverything(function () { whisperGM('⏹ Tudo parado.'); }); break;
case 'stopid': stopSingle(restArgs); break;
case 'forcestop': {
_.each(allJukeboxTracks(), function (t) {
cancelFade(t.id);
t.set('playing', false);
t.set('softstop', false);
});
playback.currentTrackId = null;
playback.queue = [];
playback.queueMode = null;
whisperGM('🛑 Todas as faixas paradas instantaneamente (sem fade), estado limpo.');
break;
}
case 'showplaylist': showPlaylistMenu(restArgs); break;
case 'tag': {
var tArgs = splitArgs(restArgs);
var op = tArgs.pop();
var name = tArgs.join(' ');
var tid = resolveTrack(name);
if (!tid) { whisperGM('⚠️ Faixa "' + name + '" não encontrada.'); break; }
var meta = trackMeta(tid);
if (op[0] === '+') {
var tag = op.slice(1);
if (meta.tags.indexOf(tag) === -1) meta.tags.push(tag);
whisperGM('🏷 Tag "' + tag + '" adicionada a ' + meta.alias + '.');
} else if (op[0] === '-') {
var tag2 = op.slice(1);
meta.tags = _.without(meta.tags, tag2);
whisperGM('🏷 Tag "' + tag2 + '" removida de ' + meta.alias + '.');
}
break;
}
case 'tagplaylist': {
var tpArgs = splitArgs(restArgs);
var tpOp = tpArgs.pop();
var tpPlName = tpArgs.join(' ');
if (!tpOp || (tpOp[0] !== '+' && tpOp[0] !== '-')) {
whisperGM('Uso: !music tagplaylist <nome da playlist> +tag (ou -tag para remover)');
break;
}
var tpTag = tpOp.slice(1);
// procura primeiro nas playlists virtuais, depois nas nativas (por nome)
var tpIds = null;
var tpVirtId = findPlaylistByName(tpPlName);
if (tpVirtId) {
tpIds = db().playlists[tpVirtId].trackIds;
} else {
var tpNative = _.find(db().nativePlaylists, function (p) {
return p.name.toLowerCase() === tpPlName.toLowerCase();
});
if (tpNative) tpIds = tpNative.trackIds;
}
if (!tpIds) {
whisperGM('⚠️ Playlist "' + tpPlName + '" não encontrada. Rode !music sync se for nativa.');
break;
}
var tpCount = 0;
_.each(tpIds, function (tid) {
var meta = trackMeta(tid);
if (!meta) return; // faixa ainda não importada, roda !music sync
if (tpOp[0] === '+') {
if (meta.tags.indexOf(tpTag) === -1) { meta.tags.push(tpTag); tpCount++; }
} else {
if (meta.tags.indexOf(tpTag) !== -1) { meta.tags = _.without(meta.tags, tpTag); tpCount++; }
}
});
whisperGM('🏷 Tag "' + tpTag + '" ' + (tpOp[0] === '+' ? 'adicionada a ' : 'removida de ') +
tpCount + ' faixa(s) da playlist "' + tpPlName + '".');
break;
}
case 'rename': {
var rArgs = splitArgs(restArgs);
var oldName = rArgs.shift(); // primeira palavra = nome atual (sem espaço) — recomenda-se usar botão/id
var newName = rArgs.join(' ');
var rid = resolveTrack(oldName);
if (!rid || !newName) { whisperGM('Uso: !music rename <nome-atual-sem-espaco> <novo nome>'); break; }
trackMeta(rid).alias = newName;
whisperGM('✏️ Renomeado para "' + newName + '".');
break;
}
case 'volume': {
var vArgs = splitArgs(restArgs);
var vol = parseInt(vArgs.pop(), 10);
var vname = vArgs.join(' ');
var vid = resolveTrack(vname);
if (!vid || isNaN(vol)) { whisperGM('Uso: !music volume <nome> <0-100>'); break; }
trackMeta(vid).volume = Math.max(0, Math.min(100, vol));
whisperGM('🔊 Volume padrão de "' + trackMeta(vid).alias + '" definido para ' + vol + '.');
break;
}
case 'remove': {
var remId = resolveTrack(restArgs);
if (!remId) { whisperGM('⚠️ Faixa não encontrada.'); break; }
var remName = trackMeta(remId).alias;
delete db().tracks[remId];
whisperGM('🗑 "' + remName + '" removida do gerenciador (o áudio continua na Jukebox).');
break;
}
case 'fadetoggle': {
db().settings.fadeEnabled = !isFadeEnabled();
whisperGM('🎚 Fade in/out ' + (isFadeEnabled() ? 'ATIVADO' : 'DESATIVADO') + '.');
break;
}
case 'layertoggle': {
db().settings.layerMode = !isLayerMode();
whisperGM('🎛 Layer mode ' + (isLayerMode() ? 'ATIVADO — novas músicas vão se somar às que já estão tocando.' : 'DESATIVADO — tocar uma música volta a substituir a anterior.'));
break;
}
case 'fade': {
var secs = parseFloat(restArgs);
if (isNaN(secs) || secs <= 0) { whisperGM('Uso: !music fade <segundos>'); break; }
db().settings.fadeMs = secs * 1000;
whisperGM('⏱ Duração do fade definida para ' + secs + 's.');
break;
}
case 'list': {
var what = restArgs.trim();
if (what === 'tracks') {
var lines = _.map(db().tracks, function (m) { return '- ' + m.alias + (m.tags.length ? ' [' + m.tags.join(',') + ']' : ''); });
whisperGM('<b>Faixas:</b><br>' + lines.join('<br>'));
} else if (what === 'playlists') {
var pls = _.map(db().playlists, function (p) { return '- ' + p.name + ' (' + p.trackIds.length + ' faixas)'; });
var natives = _.map(db().nativePlaylists, function (p) { return '- ' + p.name + ' [nativa] (' + p.trackIds.length + ' faixas)'; });
whisperGM('<b>Playlists:</b><br>' + pls.concat(natives).join('<br>'));
} else if (what === 'tags') {
var tagSet2 = {};
_.each(db().tracks, function (m) { _.each(m.tags, function (t) { tagSet2[t] = true; }); });
whisperGM('<b>Tags:</b> ' + _.keys(tagSet2).join(', '));
} else {
whisperGM('Uso: !music list tracks|playlists|tags');
}
break;
}
case 'playlist': {
var plParts = splitArgs(restArgs);
var subcmd = plParts[0];
var plRest = plParts.slice(1).join(' ');
if (subcmd === 'create') {
createPlaylist(plRest);
whisperGM('📁 Playlist "' + plRest + '" criada.');
} else if (subcmd === 'delete') {
var delId = findPlaylistByName(plRest);
if (delId) { delete db().playlists[delId]; whisperGM('🗑 Playlist removida.'); }
else whisperGM('⚠️ Playlist não encontrada.');
} else if (subcmd === 'add') {
var addArr = splitArgs(plRest);
var trackName = addArr.pop();
var plName = addArr.join(' ');
var plId = findPlaylistByName(plName);
var trId = resolveTrack(trackName);
if (!plId || !trId) { whisperGM('⚠️ Playlist ou faixa não encontrada.'); break; }
db().playlists[plId].trackIds.push(trId);
whisperGM('➕ "' + trackMeta(trId).alias + '" adicionada a "' + db().playlists[plId].name + '".');
} else if (subcmd === 'removetrack') {
var rmArr = splitArgs(plRest);
var rmTrack = rmArr.pop();
var rmPl = rmArr.join(' ');
var rmPlId = findPlaylistByName(rmPl);
var rmTrId = resolveTrack(rmTrack);
if (!rmPlId || !rmTrId) { whisperGM('⚠️ Playlist ou faixa não encontrada.'); break; }
db().playlists[rmPlId].trackIds = _.without(db().playlists[rmPlId].trackIds, rmTrId);
whisperGM('➖ Faixa removida da playlist.');
} else if (subcmd === 'play') {
var playPlId = findPlaylistByName(plRest);
if (!playPlId) { whisperGM('⚠️ Playlist "' + plRest + '" não encontrada.'); break; }
playQueue(db().playlists[playPlId].trackIds);
} else if (subcmd === 'playnative') {
var nativeId = plRest;
var nat = db().nativePlaylists[nativeId];
if (!nat) { whisperGM('⚠️ Playlist nativa não encontrada. Rode !music sync.'); break; }
playQueue(nat.trackIds);
} else {
whisperGM('Uso: !music playlist create|delete|add|removetrack|play|playnative ...');
}
break;
}
case 'warmup': {
var warmupTag = restArgs.join(' ').trim();
if (!warmupTag) { whisperGM('Uso: !music warmup <tag>'); break; }
warmUpTag(warmupTag);
break;
}
case 'help':
default:
whisperGM(
'<b>MusicManager — comandos</b><br>' +
'!music menu, !music sync, !music find &lt;termo&gt;<br>' +
'!music play &lt;nome&gt;, !music stop (para tudo), !music stopid &lt;id&gt;<br>' +
'!music forcestop — para tudo instantâneo, sem fade (recuperação de emergência)<br>' +
'!music showplaylist &lt;nome&gt; — lista faixas da playlist pra escolher<br>' +
'!music tag &lt;nome&gt; +tag / -tag<br>' +
'!music tagplaylist &lt;nome da playlist&gt; +tag / -tag<br>' +
'!music rename &lt;nome&gt; &lt;novo&gt;, !music volume &lt;nome&gt; &lt;0-100&gt;<br>' +
'!music remove &lt;nome&gt;, !music fade &lt;segundos&gt;, !music fadetoggle<br>' +
'!music layertoggle — liga/desliga o modo de empilhar músicas<br>' +
'!music list tracks|playlists|tags<br>' +
'!music warmup &lt;tag&gt; — deixa as faixas da tag tocando em loop/volume 0, prontas pra uso instantâneo<br>' +
'!music playlist create|delete|add|removetrack|play &lt;nome&gt;<br>' +
'!music export, !music import &lt;json&gt;'
);
break;
}
}
// Devolve os IDs de todas as faixas que tenham a tag informada
// (usado por outros scripts, ex: RandomTarget, pra tocar algo aleatório de um grupo).
function getTrackIdsByTag(tag) {
checkState();
tag = (tag || '').toLowerCase().trim();
return _.filter(_.keys(db().tracks), function (id) {
return _.some(db().tracks[id].tags || [], function (t) {
return t.toLowerCase() === tag;
});
});
}
// Toca uma faixa NA HORA, com volume cheio, sem rampa de fade nenhuma -
// feito pra efeitos sonoros curtos (stingers). Segue o mesmo padrão do
// script "Simple Sound" (comprovadamente confiável pra isso): sempre
// reseta playing e softstop antes de tocar de novo.
function playTrackIdInstant(id, opts) {
opts = opts || {};
checkState();
var meta = trackMeta(id);
if (!meta) { whisperGM('⚠️ Faixa não encontrada no gerenciador. Rode !music sync.'); return; }
var targetVol = opts.volume != null ? opts.volume : (meta.volume || 70);
var track = getJukeboxTrack(id);
if (!track) return;
cancelFade(track.id);
track.set('volume', targetVol);
track.set('playing', false);
track.set('softstop', false);
track.set('playing', true);
whisperGM('🔊 Efeito tocado: **' + meta.alias + '**');
}
// Deixa todas as faixas de uma tag tocando em loop, com volume 0, em segundo
// plano. Isso mantém o áudio "quente" (já carregado/bufferizado) no navegador,
// pra evitar os 3-4s de atraso que o Roll20 leva pra buscar um arquivo que não
// estava tocando. Rode isso uma vez no início da sessão (ex: !music warmup sfx-joke).
function warmUpTag(tag) {
checkState();
var ids = getTrackIdsByTag(tag);
_.each(ids, function (id) {
var track = getJukeboxTrack(id);
if (!track) return;
cancelFade(track.id);
track.set('loop', true);
track.set('volume', 0);
if (!track.get('playing')) {
track.set('softstop', false);
track.set('playing', true);
}
});
whisperGM('🔥 ' + ids.length + ' faixa(s) da tag "' + tag + '" deixada(s) tocando em loop (volume 0), prontas pra uso instantâneo.');
return ids.length;
}
// "Pulsa" o volume de uma faixa que já está tocando em loop (via warmUpTag):
// sobe pro volume alvo na hora e abaixa de novo pra 0 depois de durationMs.
// Não mexe em "playing" nem em "loop", então não depende de buffering nenhum -
// ideal pra efeitos sonoros curtos disparados por outro script.
function pulseTrackVolume(id, peakVolume, durationMs) {
checkState();
var track = getJukeboxTrack(id);
if (!track) return;
durationMs = durationMs || 3000;
cancelFade(track.id);
// Segurança: se por algum motivo a faixa não estiver tocando ainda
// (ex: esqueceram de rodar o warmup), liga ela agora mesmo, mas o
// primeiro disparo pode ter o mesmo atraso de buffering.
if (!track.get('playing')) {
track.set('loop', true);
track.set('playing', true);
}
track.set('volume', peakVolume);
setTimeout(function () {
track.set('volume', 0);
}, durationMs);
}
on('ready', function () {
checkState();
log('MusicManager v' + VERSION + ' carregado.');
});
on('chat:message', handleInput);
return {
CheckState: checkState,
// API pública pra outros scripts (ex: RandomTarget) tocarem música
// sem mexer direto no objeto jukeboxtrack e sem brigar com o estado do manager.
PlayTrackId: playTrackId,
PlayTrackIdInstant: playTrackIdInstant,
GetTrackIdsByTag: getTrackIdsByTag,
WarmUpTag: warmUpTag,
PulseTrackVolume: pulseTrackVolume
};
})();