mirror of
https://github.com/imgfloat/server.git
synced 2026-06-22 21:01:23 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user