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.allowSevenTvEmotesForAssets = true;
|
||||||
this.allowScriptChatAccess = 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.obsBrowser = !!globalThis.obsstudio;
|
||||||
this.supportsAnimatedDecode =
|
this.supportsAnimatedDecode =
|
||||||
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser;
|
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser;
|
||||||
@@ -88,6 +102,18 @@ export class BroadcastRenderer {
|
|||||||
})
|
})
|
||||||
.then((assets) => this.renderAssets(assets))
|
.then((assets) => this.renderAssets(assets))
|
||||||
.catch(() => this.showToast("Unable to load overlay assets. Retrying may help.", "error"));
|
.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);
|
this.applyCanvasSettings(event.payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type && event.type.startsWith("PLAYLIST_")) {
|
||||||
|
this.handlePlaylistEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
if (event.type === "PREVIEW" && event.patch) {
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
this.applyPreviewPatch(assetId, event.patch);
|
this.applyPreviewPatch(assetId, event.patch);
|
||||||
@@ -496,6 +526,7 @@ export class BroadcastRenderer {
|
|||||||
this.scriptWorkerReady = true;
|
this.scriptWorkerReady = true;
|
||||||
this.updateScriptWorkerChatMessages();
|
this.updateScriptWorkerChatMessages();
|
||||||
this.updateScriptWorkerEmoteCatalog();
|
this.updateScriptWorkerEmoteCatalog();
|
||||||
|
this.updateScriptWorkerPlaylist();
|
||||||
}
|
}
|
||||||
|
|
||||||
setScriptSettings(settings) {
|
setScriptSettings(settings) {
|
||||||
@@ -713,6 +744,163 @@ export class BroadcastRenderer {
|
|||||||
return column ? `line ${line}, col ${column}` : `line ${line}`;
|
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) {
|
handleScriptWorkerMessage(event) {
|
||||||
const { type, payload } = event.data || {};
|
const { type, payload } = event.data || {};
|
||||||
if (type === "scriptAudio") {
|
if (type === "scriptAudio") {
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : n
|
|||||||
let activeScriptId = null;
|
let activeScriptId = null;
|
||||||
let chatMessages = [];
|
let chatMessages = [];
|
||||||
let emoteCatalog = [];
|
let emoteCatalog = [];
|
||||||
|
let playlistContext = {
|
||||||
|
active: false,
|
||||||
|
paused: false,
|
||||||
|
playlistName: null,
|
||||||
|
trackName: null,
|
||||||
|
trackIndex: null,
|
||||||
|
trackCount: null,
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeUrl(url) {
|
function normalizeUrl(url) {
|
||||||
try {
|
try {
|
||||||
@@ -270,6 +278,7 @@ function updateScriptContexts() {
|
|||||||
script.context.chatMessages = chatMessages;
|
script.context.chatMessages = chatMessages;
|
||||||
script.context.emoteCatalog = emoteCatalog;
|
script.context.emoteCatalog = emoteCatalog;
|
||||||
script.context.allowedDomains = script.allowedDomains;
|
script.context.allowedDomains = script.allowedDomains;
|
||||||
|
script.context.playlist = playlistContext;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +325,7 @@ function stopTickLoopIfIdle() {
|
|||||||
|
|
||||||
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
||||||
const contextPrelude =
|
const contextPrelude =
|
||||||
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains } = context;";
|
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains, playlist } = context;";
|
||||||
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
||||||
const factory = new Function(
|
const factory = new Function(
|
||||||
"context",
|
"context",
|
||||||
@@ -380,6 +389,7 @@ self.addEventListener("message", (event) => {
|
|||||||
chatMessages,
|
chatMessages,
|
||||||
emoteCatalog,
|
emoteCatalog,
|
||||||
allowedDomains,
|
allowedDomains,
|
||||||
|
playlist: playlistContext,
|
||||||
playAudio: (attachment) => {
|
playAudio: (attachment) => {
|
||||||
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
|
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
|
||||||
if (!attachmentId) {
|
if (!attachmentId) {
|
||||||
@@ -469,4 +479,16 @@ self.addEventListener("message", (event) => {
|
|||||||
refreshAllowedFetchUrls();
|
refreshAllowedFetchUrls();
|
||||||
updateScriptContexts();
|
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