diff --git a/.gitattributes b/.gitattributes index 55d82ca..d5458b2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,3 +21,7 @@ doc/marketplace-scripts/color-bars/logo.png filter=lfs diff=lfs merge=lfs -text doc/marketplace-scripts/mobius-strip/logo.png filter=lfs diff=lfs merge=lfs -text doc/marketplace-scripts/rotating-logo/attachments/rotate.png filter=lfs diff=lfs merge=lfs -text doc/marketplace-scripts/rotating-logo/logo.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/chat-overlay/logo.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/kirov-reporting/logo.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/teapot-viewer/logo.png filter=lfs diff=lfs merge=lfs -text +doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 filter=lfs diff=lfs merge=lfs -text diff --git a/doc/marketplace-scripts/chat-overlay/logo.png b/doc/marketplace-scripts/chat-overlay/logo.png new file mode 100644 index 0000000..21e1eb1 --- /dev/null +++ b/doc/marketplace-scripts/chat-overlay/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20397f48ecc627f170f473a46b029eb17062c1d285d6172756e0bd4f1b6c1a7d +size 20054 diff --git a/doc/marketplace-scripts/chat-overlay/metadata.json b/doc/marketplace-scripts/chat-overlay/metadata.json index 127f60f..e113288 100644 --- a/doc/marketplace-scripts/chat-overlay/metadata.json +++ b/doc/marketplace-scripts/chat-overlay/metadata.json @@ -1,5 +1,4 @@ { "name": "Chat Overlay", - "description": "Render the last two minutes of Twitch chat messages on the broadcast canvas.", - "broadcaster": "System" + "description": "Render the last two minutes of Twitch chat messages on the broadcast canvas." } diff --git a/doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 b/doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 new file mode 100644 index 0000000..cae37da --- /dev/null +++ b/doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93050ac834db9585aa767433d0d16702434ea9381d01d0eb7559fde192ea8432 +size 38869 diff --git a/doc/marketplace-scripts/kirov-reporting/logo.png b/doc/marketplace-scripts/kirov-reporting/logo.png new file mode 100644 index 0000000..ef710c3 --- /dev/null +++ b/doc/marketplace-scripts/kirov-reporting/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0dbbb99c100c8538e70f9bdb7b1737635062b7f50dc57171e8b2fc1017475ba +size 2845 diff --git a/doc/marketplace-scripts/kirov-reporting/metadata.json b/doc/marketplace-scripts/kirov-reporting/metadata.json new file mode 100644 index 0000000..dc8a895 --- /dev/null +++ b/doc/marketplace-scripts/kirov-reporting/metadata.json @@ -0,0 +1,4 @@ +{ + "name": "Kirov Reporting!", + "description": "Plays kirov_reporting.mp3 whenever !kirov is typed in chat." +} diff --git a/doc/marketplace-scripts/kirov-reporting/source.js b/doc/marketplace-scripts/kirov-reporting/source.js new file mode 100644 index 0000000..0527b6c --- /dev/null +++ b/doc/marketplace-scripts/kirov-reporting/source.js @@ -0,0 +1,73 @@ +const COMMAND = "!kirov"; +const COOLDOWN_MS = 5000; +const ATTACHMENT_NAME = "kirov_reporting.mp3"; + +function normalizeMessage(message) { + return (message || "").trim().toLowerCase(); +} + +function isCommandMatch(message) { + const normalized = normalizeMessage(message); + if (!normalized.startsWith(COMMAND)) { + return false; + } + return normalized.length === COMMAND.length || normalized.startsWith(`${COMMAND} `); +} + +function resolveAttachmentId(assets, state) { + if (state?.attachmentId) { + return state.attachmentId; + } + if (!Array.isArray(assets)) { + return null; + } + const match = assets.find((asset) => asset?.name?.toLowerCase() === ATTACHMENT_NAME); + if (!match?.id) { + return null; + } + state.attachmentId = match.id; + return match.id; +} + +function tick(context, state) { + const { chatMessages, assets, now, playAudio } = context; + if (!Array.isArray(chatMessages) || chatMessages.length === 0) { + return; + } + if (typeof playAudio !== "function") { + return; + } + const lastSeen = state.lastChatTimestamp ?? 0; + let latestSeen = lastSeen; + const currentTime = Number.isFinite(now) ? now : 0; + const lastTriggerAt = state.lastTriggerAt ?? -Infinity; + let nextTriggerAt = lastTriggerAt; + let shouldTrigger = false; + + chatMessages.forEach((message) => { + const timestamp = message?.timestamp ?? 0; + if (timestamp <= lastSeen) { + return; + } + latestSeen = Math.max(latestSeen, timestamp); + if (!shouldTrigger && isCommandMatch(message?.message)) { + if (currentTime - lastTriggerAt >= COOLDOWN_MS) { + shouldTrigger = true; + } + } + }); + + state.lastChatTimestamp = latestSeen; + + if (!shouldTrigger) { + return; + } + + const attachmentId = resolveAttachmentId(assets, state); + if (!attachmentId) { + return; + } + + playAudio(attachmentId); + state.lastTriggerAt = currentTime; +} diff --git a/doc/marketplace-scripts/teapot-viewer/logo.png b/doc/marketplace-scripts/teapot-viewer/logo.png new file mode 100644 index 0000000..859ac9b --- /dev/null +++ b/doc/marketplace-scripts/teapot-viewer/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:843d4e166b36c6cf661236bffe4bb2f4ac4fd143c7cbdf2f243f695904698aee +size 9511 diff --git a/doc/marketplace-scripts/teapot-viewer/metadata.json b/doc/marketplace-scripts/teapot-viewer/metadata.json index 7d413f2..08ecb4d 100644 --- a/doc/marketplace-scripts/teapot-viewer/metadata.json +++ b/doc/marketplace-scripts/teapot-viewer/metadata.json @@ -1,5 +1,4 @@ { "name": "Teapot 3D Viewer", - "description": "Renders the classic Utah teapot using a 3D model attachment.", - "broadcaster": "System" + "description": "Renders the classic Utah teapot using a 3D model attachment." } diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 0292861..19b89ac 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -24,6 +24,7 @@ export class BroadcastRenderer { this.scriptWorkerReady = false; this.scriptErrorKeys = new Set(); this.scriptAttachmentCache = new Map(); + this.scriptAttachmentsByAssetId = new Map(); this.chatMessages = []; this.obsBrowser = !!globalThis.obsstudio; @@ -470,6 +471,10 @@ export class BroadcastRenderer { handleScriptWorkerMessage(event) { const { type, payload } = event.data || {}; + if (type === "scriptAudio") { + this.playScriptAudio(payload); + return; + } if (type !== "scriptError" || !payload?.id) { return; } @@ -519,6 +524,8 @@ export class BroadcastRenderer { console.error(`Unable to fetch asset ${asset.id} from ${asset.url}`, error); return; } + const attachments = await this.resolveScriptAttachments(asset.scriptAttachments); + this.scriptAttachmentsByAssetId.set(asset.id, attachments); this.scriptWorker.postMessage({ type: "addScript", payload: { @@ -527,7 +534,7 @@ export class BroadcastRenderer { canvas: offscreen, width: scriptCanvas.width, height: scriptCanvas.height, - attachments: await this.resolveScriptAttachments(asset.scriptAttachments), + attachments, }, }, [offscreen]); } @@ -536,11 +543,13 @@ export class BroadcastRenderer { if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) { return; } + const attachments = await this.resolveScriptAttachments(asset.scriptAttachments); + this.scriptAttachmentsByAssetId.set(asset.id, attachments); this.scriptWorker.postMessage({ type: "updateAttachments", payload: { id: asset.id, - attachments: await this.resolveScriptAttachments(asset.scriptAttachments), + attachments, }, }); } @@ -553,9 +562,29 @@ export class BroadcastRenderer { type: "removeScript", payload: { id: assetId }, }); + this.scriptAttachmentsByAssetId.delete(assetId); this.removeScriptCanvas(assetId); } + playScriptAudio(payload) { + if (!payload?.scriptId || !payload?.attachmentId) { + return; + } + const attachments = this.scriptAttachmentsByAssetId.get(payload.scriptId); + if (!Array.isArray(attachments)) { + return; + } + const attachment = attachments.find((item) => item?.id === payload.attachmentId); + if (!attachment?.url || !attachment?.mediaType?.startsWith("audio/")) { + return; + } + const audioAsset = { + id: `script-${payload.scriptId}-${attachment.id}`, + url: attachment.url, + }; + this.audioManager.playAudioImmediately(audioAsset); + } + ensureScriptCanvas(assetId) { if (!assetId || !this.scriptLayer) { return null; diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js index b57d128..22dd067 100644 --- a/src/main/resources/static/js/broadcast/script-worker.js +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -182,7 +182,7 @@ function stopTickLoopIfIdle() { function createScriptHandlers(source, context, state, sourceLabel = "") { const contextPrelude = - "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages } = context;"; + "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, playAudio } = context;"; const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; const factory = new Function( "context", @@ -242,6 +242,19 @@ self.addEventListener("message", (event) => { elapsedMs: 0, assets: Array.isArray(payload.attachments) ? payload.attachments : [], chatMessages, + playAudio: (attachment) => { + const attachmentId = typeof attachment === "string" ? attachment : attachment?.id; + if (!attachmentId) { + return; + } + self.postMessage({ + type: "scriptAudio", + payload: { + scriptId: payload.id, + attachmentId, + }, + }); + }, }; let handlers = {}; try {