From d4a38cf6eef07422e941fd6ba329f8e2f2c2073b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 29 Apr 2026 16:40:35 +0200 Subject: [PATCH] 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 --- .../resources/static/js/broadcast/renderer.js | 188 ++++++++++++++++++ .../static/js/broadcast/script-worker.js | 26 ++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 30e8080..ea04aee 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -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") { diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js index f37429c..9d7c2a7 100644 --- a/src/main/resources/static/js/broadcast/script-worker.js +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -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(); + } });