mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add twitch chat integration
This commit is contained in:
5
doc/marketplace-scripts/chat-overlay/metadata.json
Normal file
5
doc/marketplace-scripts/chat-overlay/metadata.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Chat Overlay",
|
||||||
|
"description": "Render the last two minutes of Twitch chat messages on the broadcast canvas.",
|
||||||
|
"broadcaster": "System"
|
||||||
|
}
|
||||||
67
doc/marketplace-scripts/chat-overlay/source.js
Normal file
67
doc/marketplace-scripts/chat-overlay/source.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
import { BroadcastRenderer } from "./broadcast/renderer.js";
|
import { BroadcastRenderer } from "./broadcast/renderer.js";
|
||||||
|
import { connectTwitchChat } from "./broadcast/twitchChat.js";
|
||||||
import { setUpElectronWindowResizeListener } from "./electron.js";
|
import { setUpElectronWindowResizeListener } from "./electron.js";
|
||||||
|
|
||||||
const canvas = document.getElementById("broadcast-canvas");
|
const canvas = document.getElementById("broadcast-canvas");
|
||||||
const scriptLayer = document.getElementById("broadcast-script-layer");
|
const scriptLayer = document.getElementById("broadcast-script-layer");
|
||||||
const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast });
|
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);
|
setUpElectronWindowResizeListener(canvas);
|
||||||
renderer.start();
|
renderer.start();
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
disconnectChat();
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class BroadcastRenderer {
|
|||||||
this.scriptWorkerReady = false;
|
this.scriptWorkerReady = false;
|
||||||
this.scriptErrorKeys = new Set();
|
this.scriptErrorKeys = new Set();
|
||||||
this.scriptAttachmentCache = new Map();
|
this.scriptAttachmentCache = new Map();
|
||||||
|
this.chatMessages = [];
|
||||||
|
|
||||||
this.obsBrowser = !!globalThis.obsstudio;
|
this.obsBrowser = !!globalThis.obsstudio;
|
||||||
this.supportsAnimatedDecode =
|
this.supportsAnimatedDecode =
|
||||||
@@ -409,6 +410,7 @@ export class BroadcastRenderer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.scriptWorkerReady = true;
|
this.scriptWorkerReady = true;
|
||||||
|
this.updateScriptWorkerChatMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScriptWorkerCanvas() {
|
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) {
|
extractScriptErrorLocation(stack, scriptId) {
|
||||||
if (!stack || !scriptId) {
|
if (!stack || !scriptId) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const errorKeys = new Set();
|
|||||||
const allowedImportUrls = new Set();
|
const allowedImportUrls = new Set();
|
||||||
const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null;
|
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"];
|
const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"];
|
||||||
|
let chatMessages = [];
|
||||||
|
|
||||||
function normalizeUrl(url) {
|
function normalizeUrl(url) {
|
||||||
try {
|
try {
|
||||||
@@ -137,6 +138,7 @@ function updateScriptContexts() {
|
|||||||
script.context.channelName = channelName;
|
script.context.channelName = channelName;
|
||||||
script.context.width = script.canvas?.width ?? 0;
|
script.context.width = script.canvas?.width ?? 0;
|
||||||
script.context.height = script.canvas?.height ?? 0;
|
script.context.height = script.canvas?.height ?? 0;
|
||||||
|
script.context.chatMessages = chatMessages;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +182,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 } = context;";
|
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages } = context;";
|
||||||
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
||||||
const factory = new Function(
|
const factory = new Function(
|
||||||
"context",
|
"context",
|
||||||
@@ -239,6 +241,7 @@ self.addEventListener("message", (event) => {
|
|||||||
deltaMs: 0,
|
deltaMs: 0,
|
||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
||||||
|
chatMessages,
|
||||||
};
|
};
|
||||||
let handlers = {};
|
let handlers = {};
|
||||||
try {
|
try {
|
||||||
@@ -291,4 +294,9 @@ self.addEventListener("message", (event) => {
|
|||||||
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
|
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
|
||||||
refreshAllowedFetchUrls();
|
refreshAllowedFetchUrls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "chatMessages") {
|
||||||
|
chatMessages = Array.isArray(payload?.messages) ? payload.messages : [];
|
||||||
|
updateScriptContexts();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
121
src/main/resources/static/js/broadcast/twitchChat.js
Normal file
121
src/main/resources/static/js/broadcast/twitchChat.js
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user