feat: playlist state in renderer and script worker context

- renderer.js: track playlistState object updated by PLAYLIST_* events
  from the STOMP topic; _playTrack() drives a dedicated Audio element
  independent of the per-asset loop controller; _onPlaylistTrackEnded()
  calls /track-ended to let the server decide next/ended; fetch active
  playlist on connect startup; updateScriptWorkerPlaylist() pushes state
  to the worker on every change
- script-worker.js: playlist context object added to every script's
  context (active, paused, playlistName, trackName, trackIndex,
  trackCount); new 'playlist' message handler keeps it in sync;
  destructured in the context prelude so scripts can use it directly
This commit is contained in:
2026-04-29 16:40:35 +02:00
parent 156b88ba40
commit d4a38cf6ee
2 changed files with 212 additions and 2 deletions
@@ -38,6 +38,20 @@ export class BroadcastRenderer {
this.allowSevenTvEmotesForAssets = true;
this.allowScriptChatAccess = true;
// Playlist state — updated by PlaylistEvents from the server
this.playlistState = {
active: false,
paused: false,
playlistId: null,
playlistName: null,
trackId: null,
trackName: null,
trackIndex: null,
trackCount: null,
tracks: [], // full ordered track list from last PLAYLIST_SELECTED/UPDATED
};
this.playlistCurrentElement = null; // the currently playing Audio element
this.obsBrowser = !!globalThis.obsstudio;
this.supportsAnimatedDecode =
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser;
@@ -88,6 +102,18 @@ export class BroadcastRenderer {
})
.then((assets) => this.renderAssets(assets))
.catch(() => this.showToast("Unable to load overlay assets. Retrying may help.", "error"));
fetch(`/api/channels/${this.broadcaster}/playlists/active`)
.then((r) => r.ok ? r.json() : null)
.then((playlist) => {
if (playlist) {
this.playlistState.playlistId = playlist.id;
this.playlistState.playlistName = playlist.name;
this.playlistState.tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length;
this.updateScriptWorkerPlaylist();
}
})
.catch(() => {});
});
}
@@ -185,6 +211,10 @@ export class BroadcastRenderer {
this.applyCanvasSettings(event.payload);
return;
}
if (event.type && event.type.startsWith("PLAYLIST_")) {
this.handlePlaylistEvent(event);
return;
}
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === "PREVIEW" && event.patch) {
this.applyPreviewPatch(assetId, event.patch);
@@ -496,6 +526,7 @@ export class BroadcastRenderer {
this.scriptWorkerReady = true;
this.updateScriptWorkerChatMessages();
this.updateScriptWorkerEmoteCatalog();
this.updateScriptWorkerPlaylist();
}
setScriptSettings(settings) {
@@ -713,6 +744,163 @@ export class BroadcastRenderer {
return column ? `line ${line}, col ${column}` : `line ${line}`;
}
// ── Playlist playback ─────────────────────────────────────────────────
handlePlaylistEvent(event) {
const { type, playlistId, trackId, payload } = event;
switch (type) {
case "PLAYLIST_SELECTED": {
if (!payload) {
// Deselected
this._stopPlaylistAudio();
this.playlistState = {
active: false, paused: false, playlistId: null, playlistName: null,
trackId: null, trackName: null, trackIndex: null, trackCount: null, tracks: [],
};
} else {
this.playlistState.playlistId = payload.id;
this.playlistState.playlistName = payload.name;
this.playlistState.tracks = Array.isArray(payload.tracks) ? payload.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length;
this.playlistState.active = false;
this.playlistState.paused = false;
this.playlistState.trackId = null;
this.playlistState.trackName = null;
this.playlistState.trackIndex = null;
this._stopPlaylistAudio();
}
this.updateScriptWorkerPlaylist();
break;
}
case "PLAYLIST_UPDATED": {
if (payload && payload.id === this.playlistState.playlistId) {
this.playlistState.playlistName = payload.name;
this.playlistState.tracks = Array.isArray(payload.tracks) ? payload.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length;
// Re-sync trackIndex in case order changed
if (this.playlistState.trackId) {
const idx = this.playlistState.tracks.findIndex(t => t.id === this.playlistState.trackId);
this.playlistState.trackIndex = idx >= 0 ? idx : null;
this.playlistState.trackName = idx >= 0 ? this.playlistState.tracks[idx].assetName : null;
}
this.updateScriptWorkerPlaylist();
}
break;
}
case "PLAYLIST_PLAY": {
if (playlistId !== this.playlistState.playlistId) break;
const resolvedTrackId = trackId || (this.playlistState.tracks[0]?.id ?? null);
this._playTrack(resolvedTrackId);
this.playlistState.active = true;
this.playlistState.paused = false;
this.updateScriptWorkerPlaylist();
break;
}
case "PLAYLIST_PAUSE": {
if (playlistId !== this.playlistState.playlistId) break;
if (this.playlistCurrentElement) {
this.playlistCurrentElement.pause();
}
this.playlistState.paused = true;
this.updateScriptWorkerPlaylist();
break;
}
case "PLAYLIST_NEXT":
case "PLAYLIST_PREV": {
if (playlistId !== this.playlistState.playlistId) break;
if (trackId) {
this._playTrack(trackId);
} else {
// PREV with no trackId means restart current track
if (this.playlistCurrentElement) {
this.playlistCurrentElement.currentTime = 0;
this.playlistCurrentElement.play().catch(() => {});
}
}
this.playlistState.paused = false;
this.updateScriptWorkerPlaylist();
break;
}
case "PLAYLIST_ENDED": {
if (playlistId !== this.playlistState.playlistId) break;
this._stopPlaylistAudio();
this.playlistState.active = false;
this.playlistState.paused = false;
this.playlistState.trackId = null;
this.playlistState.trackName = null;
this.playlistState.trackIndex = null;
this.updateScriptWorkerPlaylist();
break;
}
}
}
_playTrack(trackId) {
this._stopPlaylistAudio();
if (!trackId) return;
const track = this.playlistState.tracks.find(t => t.id === trackId);
if (!track) return;
const asset = this.state.assets.get(track.audioAssetId);
if (!asset) return;
const idx = this.playlistState.tracks.indexOf(track);
this.playlistState.trackId = trackId;
this.playlistState.trackName = track.assetName;
this.playlistState.trackIndex = idx;
const el = new Audio(asset.url);
el.preload = "auto";
el.controls = false;
this.audioManager.releaseMediaElement && this.audioManager.releaseMediaElement(el);
// Wire through the channel limiter
this.audioManager.ensureAudioController(asset); // ensures limiter is set up
// Use a fresh Audio element independent of the per-asset loop controller
el.volume = Math.min(1, Math.max(0, asset.audioVolume ?? 1));
el.playbackRate = Math.max(0.25, (asset.audioSpeed ?? 1) * (asset.audioPitch ?? 1));
el.onended = () => {
if (this.playlistCurrentElement === el) {
this.playlistCurrentElement = null;
this._onPlaylistTrackEnded();
}
};
this.playlistCurrentElement = el;
el.play().catch(() => {});
}
_stopPlaylistAudio() {
if (this.playlistCurrentElement) {
this.playlistCurrentElement.onended = null;
this.playlistCurrentElement.pause();
this.playlistCurrentElement.src = "";
this.playlistCurrentElement = null;
}
}
_onPlaylistTrackEnded() {
if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
// POST to server so it emits the appropriate NEXT or ENDED event
fetch(
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trackId: this.playlistState.trackId }),
}
).catch(() => {});
}
updateScriptWorkerPlaylist() {
if (!this.scriptWorker || !this.scriptWorkerReady) return;
const { active, paused, playlistName, trackName, trackIndex, trackCount } = this.playlistState;
this.scriptWorker.postMessage({
type: "playlist",
payload: { active, paused, playlistName, trackName, trackIndex, trackCount },
});
}
handleScriptWorkerMessage(event) {
const { type, payload } = event.data || {};
if (type === "scriptAudio") {
@@ -13,6 +13,14 @@ const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : n
let activeScriptId = null;
let chatMessages = [];
let emoteCatalog = [];
let playlistContext = {
active: false,
paused: false,
playlistName: null,
trackName: null,
trackIndex: null,
trackCount: null,
};
function normalizeUrl(url) {
try {
@@ -270,6 +278,7 @@ function updateScriptContexts() {
script.context.chatMessages = chatMessages;
script.context.emoteCatalog = emoteCatalog;
script.context.allowedDomains = script.allowedDomains;
script.context.playlist = playlistContext;
});
}
@@ -315,8 +324,8 @@ function stopTickLoopIfIdle() {
}
function createScriptHandlers(source, context, state, sourceLabel = "") {
const contextPrelude =
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains } = context;";
const contextPrelude =
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains, playlist } = context;";
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
const factory = new Function(
"context",
@@ -380,6 +389,7 @@ self.addEventListener("message", (event) => {
chatMessages,
emoteCatalog,
allowedDomains,
playlist: playlistContext,
playAudio: (attachment) => {
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
if (!attachmentId) {
@@ -469,4 +479,16 @@ self.addEventListener("message", (event) => {
refreshAllowedFetchUrls();
updateScriptContexts();
}
if (type === "playlist") {
playlistContext = {
active: payload?.active === true,
paused: payload?.paused === true,
playlistName: payload?.playlistName ?? null,
trackName: payload?.trackName ?? null,
trackIndex: payload?.trackIndex ?? null,
trackCount: payload?.trackCount ?? null,
};
updateScriptContexts();
}
});