diff --git a/doc/marketplace-scripts/chat-overlay/metadata.json b/doc/marketplace-scripts/chat-overlay/metadata.json new file mode 100644 index 0000000..127f60f --- /dev/null +++ b/doc/marketplace-scripts/chat-overlay/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Chat Overlay", + "description": "Render the last two minutes of Twitch chat messages on the broadcast canvas.", + "broadcaster": "System" +} diff --git a/doc/marketplace-scripts/chat-overlay/source.js b/doc/marketplace-scripts/chat-overlay/source.js new file mode 100644 index 0000000..d6faa81 --- /dev/null +++ b/doc/marketplace-scripts/chat-overlay/source.js @@ -0,0 +1,67 @@ +const MAX_LINES = 8; +const PADDING = 16; +const LINE_HEIGHT = 22; +const FONT = "16px 'Helvetica Neue', Arial, sans-serif"; + +function wrapLine(ctx, text, maxWidth) { + if (!text) { + return [""]; + } + const words = text.split(" "); + const lines = []; + let current = ""; + words.forEach((word) => { + const test = current ? `${current} ${word}` : word; + if (ctx.measureText(test).width > maxWidth && current) { + lines.push(current); + current = word; + } else { + current = test; + } + }); + if (current) { + lines.push(current); + } + return lines; +} + +function formatLines(messages, ctx, width) { + const maxWidth = Math.max(width - PADDING * 2, 0); + const lines = []; + messages.forEach((message) => { + const prefix = message.displayName ? `${message.displayName}: ` : ""; + const raw = `${prefix}${message.message || ""}`; + wrapLine(ctx, raw, maxWidth).forEach((line) => lines.push(line)); + }); + return lines.slice(-MAX_LINES); +} + +function tick(context) { + const { ctx, width, height, chatMessages } = context; + if (!ctx) { + return; + } + ctx.clearRect(0, 0, width, height); + ctx.font = FONT; + ctx.textBaseline = "top"; + + const messages = Array.isArray(chatMessages) ? chatMessages : []; + if (messages.length === 0) { + return; + } + + const lines = formatLines(messages, ctx, width); + const boxHeight = lines.length * LINE_HEIGHT + PADDING * 2; + const boxWidth = Math.max( + ...lines.map((line) => ctx.measureText(line).width), + 120, + ); + + ctx.fillStyle = "rgba(0, 0, 0, 0.55)"; + ctx.fillRect(PADDING, height - boxHeight - PADDING, boxWidth + PADDING * 2, boxHeight); + + ctx.fillStyle = "#ffffff"; + lines.forEach((line, index) => { + ctx.fillText(line, PADDING * 2, height - boxHeight - PADDING + PADDING + index * LINE_HEIGHT); + }); +} diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 321ed58..f3c15f9 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -1,9 +1,22 @@ import { BroadcastRenderer } from "./broadcast/renderer.js"; +import { connectTwitchChat } from "./broadcast/twitchChat.js"; import { setUpElectronWindowResizeListener } from "./electron.js"; const canvas = document.getElementById("broadcast-canvas"); const scriptLayer = document.getElementById("broadcast-script-layer"); const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast }); +const disconnectChat = connectTwitchChat(broadcaster, ({ channel, displayName, message }) => { + console.log(`[twitch:${broadcaster}] ${displayName}: ${message}`); + renderer.receiveChatMessage({ + channel, + displayName, + message, + }); +}); setUpElectronWindowResizeListener(canvas); renderer.start(); + +window.addEventListener("beforeunload", () => { + disconnectChat(); +}); diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index ac91260..0292861 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.chatMessages = []; this.obsBrowser = !!globalThis.obsstudio; this.supportsAnimatedDecode = @@ -409,6 +410,7 @@ export class BroadcastRenderer { }, ); this.scriptWorkerReady = true; + this.updateScriptWorkerChatMessages(); } updateScriptWorkerCanvas() { @@ -424,6 +426,29 @@ export class BroadcastRenderer { }); } + updateScriptWorkerChatMessages() { + if (!this.scriptWorker || !this.scriptWorkerReady) { + return; + } + this.scriptWorker.postMessage({ + type: "chatMessages", + payload: { + messages: this.chatMessages, + }, + }); + } + + receiveChatMessage(message) { + if (!message) { + return; + } + const now = Date.now(); + const entry = { ...message, timestamp: now }; + const cutoff = now - 120_000; + this.chatMessages = [...this.chatMessages, entry].filter((item) => item.timestamp >= cutoff); + this.updateScriptWorkerChatMessages(); + } + extractScriptErrorLocation(stack, scriptId) { if (!stack || !scriptId) { return ""; diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js index 2b70cfe..b57d128 100644 --- a/src/main/resources/static/js/broadcast/script-worker.js +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -9,6 +9,7 @@ const errorKeys = new Set(); const allowedImportUrls = new Set(); const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null; const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"]; +let chatMessages = []; function normalizeUrl(url) { try { @@ -137,6 +138,7 @@ function updateScriptContexts() { script.context.channelName = channelName; script.context.width = script.canvas?.width ?? 0; script.context.height = script.canvas?.height ?? 0; + script.context.chatMessages = chatMessages; }); } @@ -180,7 +182,7 @@ function stopTickLoopIfIdle() { function createScriptHandlers(source, context, state, sourceLabel = "") { const contextPrelude = - "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets } = context;"; + "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages } = context;"; const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; const factory = new Function( "context", @@ -239,6 +241,7 @@ self.addEventListener("message", (event) => { deltaMs: 0, elapsedMs: 0, assets: Array.isArray(payload.attachments) ? payload.attachments : [], + chatMessages, }; let handlers = {}; try { @@ -291,4 +294,9 @@ self.addEventListener("message", (event) => { script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : []; refreshAllowedFetchUrls(); } + + if (type === "chatMessages") { + chatMessages = Array.isArray(payload?.messages) ? payload.messages : []; + updateScriptContexts(); + } }); diff --git a/src/main/resources/static/js/broadcast/twitchChat.js b/src/main/resources/static/js/broadcast/twitchChat.js new file mode 100644 index 0000000..a5e9f6e --- /dev/null +++ b/src/main/resources/static/js/broadcast/twitchChat.js @@ -0,0 +1,121 @@ +const TWITCH_IRC_URL = "wss://irc-ws.chat.twitch.tv:443"; +const ANON_PREFIX = "justinfan"; +const ANON_PASSWORD = "SCHMOOPIIE"; + +const buildAnonymousNick = () => { + const suffix = Math.floor(Math.random() * 100000) + .toString() + .padStart(5, "0"); + return `${ANON_PREFIX}${suffix}`; +}; + +const parseTags = (rawTags) => { + if (!rawTags) { + return {}; + } + + return rawTags.split(";").reduce((acc, entry) => { + const [key, value = ""] = entry.split("="); + acc[key] = value; + return acc; + }, {}); +}; + +const parseIrcMessage = (rawLine) => { + let line = rawLine; + let tags = {}; + let prefix = ""; + + if (line.startsWith("@")) { + const spaceIndex = line.indexOf(" "); + tags = parseTags(line.slice(1, spaceIndex)); + line = line.slice(spaceIndex + 1); + } + + if (line.startsWith(":")) { + const spaceIndex = line.indexOf(" "); + prefix = line.slice(1, spaceIndex); + line = line.slice(spaceIndex + 1); + } + + const commandEnd = line.indexOf(" "); + const command = commandEnd === -1 ? line : line.slice(0, commandEnd); + const params = commandEnd === -1 ? "" : line.slice(commandEnd + 1); + + return { + command, + params, + prefix, + tags, + }; +}; + +const extractChatMessage = (ircMessage) => { + if (ircMessage.command !== "PRIVMSG") { + return null; + } + + const messageSplit = ircMessage.params.split(" :"); + const channel = messageSplit[0]?.trim() || ""; + const message = messageSplit.slice(1).join(" :"); + const displayName = ircMessage.tags["display-name"] || ircMessage.prefix.split("!")[0]; + + return { + channel, + displayName, + message, + }; +}; + +export const connectTwitchChat = (channelName, onMessage = console.log) => { + if (!channelName) { + console.warn("Twitch chat connection skipped: missing channel name"); + return () => {}; + } + + const normalizedChannel = channelName.toLowerCase(); + const nick = buildAnonymousNick(); + const socket = new WebSocket(TWITCH_IRC_URL); + + socket.addEventListener("open", () => { + socket.send(`PASS ${ANON_PASSWORD}`); + socket.send(`NICK ${nick}`); + socket.send("CAP REQ :twitch.tv/tags twitch.tv/commands"); + socket.send(`JOIN #${normalizedChannel}`); + }); + + socket.addEventListener("message", (event) => { + const lines = String(event.data).split("\r\n").filter(Boolean); + + lines.forEach((line) => { + if (line.startsWith("PING")) { + const payload = line.split(" ")[1] || ":tmi.twitch.tv"; + socket.send(`PONG ${payload}`); + return; + } + + const parsed = parseIrcMessage(line); + const chatMessage = extractChatMessage(parsed); + + if (chatMessage) { + onMessage({ + channel: chatMessage.channel, + displayName: chatMessage.displayName, + message: chatMessage.message, + }); + } + }); + }); + + socket.addEventListener("close", () => { + console.info(`Twitch chat connection closed for #${normalizedChannel}`); + }); + + socket.addEventListener("error", (event) => { + console.error("Twitch chat connection error", event); + }); + + return () => { + socket.close(); + }; +};