From 185e10ea11c34dd46eee07b666955b564efc431e Mon Sep 17 00:00:00 2001 From: Andrej Valek Date: Sun, 5 Jul 2026 08:43:22 +0200 Subject: [PATCH] feat(livechat): add option to display live chat messages --- mods/config.js | 2 + mods/features/liveChat.js | 381 ++++++++++++++++++++++++++++++++++++++ mods/resolveCommand.js | 8 + mods/ui/customUI.js | 28 +++ mods/ui/liveChatUI.js | 109 +++++++++++ mods/userScript.js | 3 +- 6 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 mods/features/liveChat.js create mode 100644 mods/ui/liveChatUI.js diff --git a/mods/config.js b/mods/config.js index 2e86da6b..9b958277 100644 --- a/mods/config.js +++ b/mods/config.js @@ -34,6 +34,8 @@ const defaultConfig = { enablePreviousNextButtons: true, enableSuperThanksButton: false, enableSpeedControlsButton: true, + liveChatEnabled: true, + enableChatToggleButton: true, enablePatchingVideoPlayer: true, enableMPButton: true, enableSwapMPWithPIP: false, diff --git a/mods/features/liveChat.js b/mods/features/liveChat.js new file mode 100644 index 00000000..ac0101cd --- /dev/null +++ b/mods/features/liveChat.js @@ -0,0 +1,381 @@ +/** + * liveChat.js — Live Chat overlay for TizenTube + */ + +import { configRead } from '../config.js'; +import { + OVERLAY_ID, + createOverlay, + removeOverlay, + setOverlayVisible, + appendMessage, +} from '../ui/liveChatUI.js'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const QUEUE_AHEAD_MS = 300; + +// WEB client context — this is what desktop YouTube uses. +// It always receives liveChatRenderer in v1/next responses. +const WEB_CLIENT = { + clientName: 'WEB', + clientVersion: '2.20240101.00.00', + hl: 'en', +}; + +// ─── State ─────────────────────────────────────────────────────────────────── + +let replayQueue = []; +let isLive = false; +let rafId = null; +let pollTimeout = null; +let lastVideoId = null; +let chatVisible = false; +let fetchGeneration = 0; +let listenersAttached = false; + +// ─── API ────────────────────────────────────────────────────────────────────── +// Use the page's own Innertube API key (embedded by YouTube TV in window.ytcfg). +// The official YouTube Data API v3 is not used here because it does not support +// replay chat — only live streams. The Innertube approach works for both. +function getApiKey() { + try { + return window.ytcfg?.data_?.INNERTUBE_API_KEY + || window.yt?.config_?.INNERTUBE_API_KEY + || null; + } catch (_) { + return null; + } +} + +async function apiPost(endpoint, body) { + const key = getApiKey(); + if (!key) throw new Error('No Innertube API key available'); + const res = await fetch( + `https://www.youtube.com/youtubei/v1/${endpoint}?key=${key}&prettyPrint=false`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + context: { client: WEB_CLIENT }, + ...body, + }), + } + ); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); +} + +// ─── Step 1: get continuation token via v1/next ─────────────────────────────── + +async function startForVideo(videoId, startSecs) { + startSecs = startSecs || 0; + // Cancel any in-flight fetch before starting a new one. + // This prevents duplicate fetches when startForVideo is called twice + // in quick succession (e.g. init race with pushState hook). + fetchGeneration++; + if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } + const gen = fetchGeneration; + console.log('[LiveChat] fetching v1/next for', videoId, 'startSecs=' + startSecs); + try { + const json = await apiPost('next', { videoId }); + const token = findLiveChatContinuation(json); + if (!token) { + console.log('[LiveChat] no liveChatRenderer in v1/next — video has no chat'); + return; + } + const badge = json?.videoPrimaryInfoRenderer?.viewCount?.videoViewCountRenderer; + isLive = badge?.isLive === true; + fetchStartSecs = startSecs; + console.log('[LiveChat] continuation found, isLive=' + isLive + ' startSecs=' + startSecs); + fetchChat(token, gen); + } catch (e) { + console.warn('[LiveChat] v1/next error:', e.message); + } +} + +// Recursively find the liveChatRenderer continuation token anywhere in the JSON +function findLiveChatContinuation(obj, depth) { + depth = depth || 0; + if (depth > 15 || !obj || typeof obj !== 'object') return null; + if (obj.liveChatRenderer) { + const conts = obj.liveChatRenderer.continuations; + if (Array.isArray(conts) && conts.length) { + return conts[0].reloadContinuationData?.continuation + || conts[0].timedContinuationData?.continuation + || conts[0].invalidationContinuationData?.continuation + || null; + } + } + for (const val of Object.values(obj)) { + if (Array.isArray(val)) { + for (const item of val) { + const found = findLiveChatContinuation(item, depth + 1); + if (found) return found; + } + } else if (val && typeof val === 'object') { + const found = findLiveChatContinuation(val, depth + 1); + if (found) return found; + } + } + return null; +} + +// ─── Step 2: fetch chat in a loop ───────────────────────────────────────────── + +let fetchStartSecs = 0; + +async function fetchChat(continuation, gen) { + if (gen !== fetchGeneration) return; + const endpoint = isLive ? 'live_chat/get_live_chat' : 'live_chat/get_live_chat_replay'; + try { + const body = { continuation }; + if (!isLive && fetchStartSecs > 0) { + body.videoOffsetTimeMsec = String(fetchStartSecs * 1000); + } + const json = await apiPost(endpoint, body); + if (gen !== fetchGeneration) return; + const nextToken = processChat(json); + + if (!nextToken) { + console.log('[LiveChat] no next token — chat ended'); + return; + } + + if (isLive) { + const cont = json?.continuationContents?.liveChatContinuation?.continuations?.[0]; + const intervalMs = cont?.timedContinuationData?.timeoutMs + || cont?.invalidationContinuationData?.timeoutMs + || 5000; + pollTimeout = setTimeout(() => fetchChat(nextToken, gen), intervalMs); + } else { + // Fetch all replay batches quickly, small yield to avoid blocking UI + pollTimeout = setTimeout(() => fetchChat(nextToken, gen), 50); + } + } catch (e) { + console.warn('[LiveChat] fetchChat error:', e.message, '— retrying in 5s'); + pollTimeout = setTimeout(() => fetchChat(continuation, gen), 5000); + } +} + +// ─── Chat response parsing ──────────────────────────────────────────────────── + +function processChat(json) { + const continuation = json?.continuationContents?.liveChatContinuation; + if (!continuation) return null; + + const actions = continuation.actions || []; + let count = 0; + + for (const action of actions) { + const item = action?.addChatItemAction?.item + || action?.replayChatItemAction?.actions?.[0]?.addChatItemAction?.item; + if (!item) continue; + + const renderer = item.liveChatTextMessageRenderer + || item.liveChatPaidMessageRenderer; + if (!renderer) continue; + + const authorName = extractText(renderer.authorName); + const messageText = extractRuns(renderer.message?.runs); + if (!authorName || !messageText) continue; + + const badgeColor = extractBadgeColor(renderer.authorBadges); + + if (isLive) { + appendMessage(authorName, messageText, badgeColor); + } else { + const offsetMs = parseInt( + action?.replayChatItemAction?.videoOffsetTimeMsec || '0', 10 + ); + replayQueue.push({ offsetMs, authorName, messageText, badgeColor }); + } + count++; + } + + if (count > 0) { + if (!isLive) { + replayQueue.sort((a, b) => a.offsetMs - b.offsetMs); + console.log('[LiveChat] replay queue:', replayQueue.length, 'messages, firstOffsetMs=' + (replayQueue[0]?.offsetMs ?? '?')); + } else { + console.log('[LiveChat] live: displayed', count, 'messages'); + } + } + + const conts = continuation.continuations || []; + return conts[0]?.timedContinuationData?.continuation + || conts[0]?.invalidationContinuationData?.continuation + || conts[0]?.liveChatReplayContinuationData?.continuation + || conts[0]?.reloadContinuationData?.continuation + || null; +} + +function extractText(obj) { + if (!obj) return ''; + return obj.simpleText || extractRuns(obj.runs) || ''; +} + +function extractRuns(runs) { + if (!runs) return ''; + return runs.map(r => { + if (r.text) return r.text; + if (!r.emoji) return ''; + if (!r.emoji.isCustomEmoji) return r.emoji.emojiId || r.emoji.shortcuts?.[0] || ''; + // Custom channel emotes: emojiId is an internal ID string, use shortcut text instead + return r.emoji.shortcuts?.[0] || ''; + }).join(''); +} + +function extractBadgeColor(badges) { + if (!badges || !badges.length) return ''; + const type = badges[0]?.liveChatAuthorBadgeRenderer?.icon?.iconType || ''; + if (type === 'MODERATOR') return 'ffd600'; + if (type === 'OWNER') return 'ff4444'; + if (type.startsWith('MEMBER')) return '22bb66'; + return ''; +} + +// ─── Replay sync loop ───────────────────────────────────────────────────────── + +function startReplayLoop() { + console.log('[LiveChat] replay loop started'); + const intervalId = setInterval(() => { + const video = document.querySelector('video'); + if (!video) return; + const nowMs = video.currentTime * 1000; + if (Math.floor(nowMs / 10000) !== Math.floor(Math.max(0, nowMs - 250) / 10000)) { console.log('[LiveChat] nowMs=' + nowMs.toFixed(0) + + ' queueLen=' + replayQueue.length + + ' firstOffsetMs=' + (replayQueue[0]?.offsetMs ?? 'empty')); + } + while (replayQueue.length && replayQueue[0].offsetMs <= nowMs + QUEUE_AHEAD_MS) { + const { authorName, messageText, badgeColor } = replayQueue.shift(); + appendMessage(authorName, messageText, badgeColor); + } + }, 250); + // Store so we can cancel on navigation + rafId = intervalId; +} + +// ─── Video seek handling ────────────────────────────────────────────────────── + +function attachVideoListeners() { + if (listenersAttached) return; + listenersAttached = true; + const video = document.querySelector('video'); + if (!video) { listenersAttached = false; setTimeout(attachVideoListeners, 500); return; } + + // Handle seek — trim forward or re-fetch on backward seek + let lastKnownMs = 0; + const onSeek = () => { + const nowMs = video.currentTime * 1000; + if (nowMs < lastKnownMs - 5000) { + console.log('[LiveChat] backward seek to ' + nowMs.toFixed(0) + ' — re-fetching chat'); + stopAll(); + replayQueue = []; + const seekSecs = Math.max(0, Math.floor(nowMs / 1000) - 5); + startForVideo(lastVideoId, seekSecs); + } else { + replayQueue = replayQueue.filter(m => m.offsetMs > nowMs); + console.log('[LiveChat] forward seek — queue trimmed to', replayQueue.length); + } + lastKnownMs = nowMs; + }; + + const onPlay = () => { + console.log('[LiveChat] video playing, currentTime=' + video.currentTime); + if (!rafId) startReplayLoop(); + }; + + video.addEventListener('seeked', onSeek); + video.addEventListener('play', onPlay); + video.addEventListener('playing', onPlay); + + if (!video.paused) onPlay(); + console.log('[LiveChat] video listeners attached'); +} + +// ─── Stop everything ───────────────────────────────────────────────────────── + +function stopAll() { + fetchGeneration++; + if (rafId) { clearInterval(rafId); rafId = null; } + if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } +} + +// ─── SPA navigation detection ───────────────────────────────────────────────── +// YouTube TV updates the URL via hashchange (not pushState) + +function getCurrentVideoId() { + // Strip any extra params like ?t=30 that may be appended to the video ID + const m = location.href.match(/[?&]v=([^&?#]+)/); + return m ? m[1] : null; +} + +function onVideoChange(videoId) { + if (!videoId || videoId === lastVideoId) return; + lastVideoId = videoId; + console.log('[LiveChat] video changed to', videoId); + stopAll(); + replayQueue = []; + listenersAttached = false; + createOverlay(chatVisible); + startForVideo(videoId); + attachVideoListeners(); +} + +function observeNavigation() { + // YouTube TV uses hashchange for navigation + window.addEventListener('hashchange', () => { + onVideoChange(getCurrentVideoId()); + }); + // Also hook pushState as fallback + const orig = history.pushState.bind(history); + history.pushState = function(...args) { + orig(...args); + onVideoChange(getCurrentVideoId()); + }; +} + +// ─── Init ───────────────────────────────────────────────────────────────────── + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function setLiveChatVisible(visible) { + chatVisible = visible; + if (visible) { + createOverlay(true); + if (!pollTimeout && !rafId) { + replayQueue = []; + listenersAttached = false; + const vid = getCurrentVideoId(); + if (vid) { + lastVideoId = vid; + const video = document.querySelector('video'); + const seekSecs = video ? Math.max(0, Math.floor(video.currentTime) - 5) : 0; + startForVideo(vid, seekSecs); + attachVideoListeners(); + } + } + } + setOverlayVisible(visible); + if (!visible) stopAll(); +} + +export function isLiveChatVisible() { + return chatVisible; +} + +// ─── Init ───────────────────────────────────────────────────────────────────── + +if (!configRead('liveChatEnabled')) { + console.log('[LiveChat] disabled in config'); +} else { + chatVisible = true; // auto-start means panel should be visible + createOverlay(true); + observeNavigation(); + attachVideoListeners(); + const vid = getCurrentVideoId(); + lastVideoId = vid; + if (vid) startForVideo(vid); + console.log('[LiveChat] initialised, current video:', vid); +} diff --git a/mods/resolveCommand.js b/mods/resolveCommand.js index fbda05c1..041150e9 100644 --- a/mods/resolveCommand.js +++ b/mods/resolveCommand.js @@ -4,6 +4,7 @@ import modernUI, { optionShow } from './ui/settings.js'; import { speedSettings } from './ui/speedUI.js'; import { showToast, buttonItem } from './ui/ytUI.js'; import checkForUpdates from './features/updater.js'; +import { setLiveChatVisible, isLiveChatVisible } from './features/liveChat.js'; export default function resolveCommand(cmd, _) { // resolveCommand function is pretty OP, it can do from opening modals, changing client settings and way more. @@ -220,5 +221,12 @@ function customAction(action, parameters) { case 'CHECK_FOR_UPDATES': checkForUpdates(true); break; + case 'TT_TOGGLE_CHAT': { + const nowVisible = !isLiveChatVisible(); + setLiveChatVisible(nowVisible); + const btn = document.querySelector('yt-button-container[aria-label="Toggle Chat"]'); + if (btn) btn.setAttribute('aria-pressed', String(nowVisible)); + break; + } } } \ No newline at end of file diff --git a/mods/ui/customUI.js b/mods/ui/customUI.js index 7dd05093..fc6e2fff 100644 --- a/mods/ui/customUI.js +++ b/mods/ui/customUI.js @@ -122,6 +122,34 @@ function applyPatches() { } } + if (engagementActionButton && configRead('enableChatToggleButton')) { + const origEngagementActionButton = inst[engagementActionButton]; + inst[engagementActionButton] = function () { + const res = origEngagementActionButton.apply(this, arguments); + // Find the comments button index to insert after it + const commentsIndex = res.findIndex(item => item.type === 'TRANSPORT_CONTROLS_BUTTON_TYPE_COMMENTS'); + if (commentsIndex !== -1 && !res.find(item => item.type === 'TRANSPORT_CONTROLS_BUTTON_TYPE_CHAT_TOGGLE')) { + res.splice(commentsIndex + 1, 0, { + type: 'TRANSPORT_CONTROLS_BUTTON_TYPE_CHAT_TOGGLE', + button: { + buttonRenderer: ButtonRenderer( + false, + "Toggle Chat", + 'COMMENT', + { + customAction: + { + action: 'TT_TOGGLE_CHAT', + } + } + ) + } + }); + } + return res; + } + } + if (!configRead('enableSuperThanksButton')) { const origEngagementActionButton = inst[engagementActionButton]; inst[engagementActionButton] = function () { diff --git a/mods/ui/liveChatUI.js b/mods/ui/liveChatUI.js new file mode 100644 index 00000000..001b9576 --- /dev/null +++ b/mods/ui/liveChatUI.js @@ -0,0 +1,109 @@ +// Live chat overlay UI — DOM creation and message rendering. +// Data fetching and state live in features/liveChat.js. + +export const OVERLAY_ID = 'tt-live-chat-overlay'; + +const MAX_MESSAGES = 50; +const FADE_AFTER_MS = 20000; + +export function createOverlay(visible) { + let el = document.getElementById(OVERLAY_ID); + if (el) return el; + + el = document.createElement('div'); + el.id = OVERLAY_ID; + Object.assign(el.style, { + position: 'fixed', + right: '2vw', + bottom: '7vh', + width: '28vw', + height: '50vh', + overflowY: 'hidden', + display: visible ? 'flex' : 'none', + flexDirection: 'column', + justifyContent: 'flex-end', + gap: '4px', + zIndex: '99999', + pointerEvents: 'none', + fontFamily: 'sans-serif', + boxSizing: 'border-box', + }); + + document.body.appendChild(el); + return el; +} + +export function removeOverlay() { + const el = document.getElementById(OVERLAY_ID); + if (el) el.remove(); +} + +export function setOverlayVisible(visible) { + const el = document.getElementById(OVERLAY_ID); + if (!el) return; + el.style.display = visible ? 'flex' : 'none'; + if (!visible) { + while (el.firstChild) el.removeChild(el.firstChild); + } +} + +export function appendMessage(authorName, messageText, badgeColor) { + const overlay = document.getElementById(OVERLAY_ID); + if (!overlay) return; + + const row = document.createElement('div'); + Object.assign(row.style, { + background: 'rgba(0,0,0,0.65)', + borderRadius: '4px', + padding: '0.5vh 0.8vw', + color: '#fff', + fontSize: '2.0vw', + lineHeight: '1.4', + opacity: '1', + transition: 'opacity 1s ease', + wordBreak: 'break-word', + width: '100%', + boxSizing: 'border-box', + flexShrink: '0', + }); + + // Build DOM nodes manually — no innerHTML (blocked by Trusted Types CSP) + const authorColor = badgeColor || 'aaaaaa'; + + const authorSpan = document.createElement('span'); + Object.assign(authorSpan.style, { + fontWeight: 'bold', + color: '#' + authorColor, + marginRight: '4px', + }); + + if (badgeColor) { + const badge = document.createElement('span'); + Object.assign(badge.style, { + display: 'inline-block', + width: '0.7vw', + height: '0.7vw', + borderRadius: '50%', + background: '#' + badgeColor, + marginRight: '5px', + verticalAlign: 'middle', + }); + authorSpan.appendChild(badge); + } + + authorSpan.appendChild(document.createTextNode(authorName + ':')); + + const messageSpan = document.createElement('span'); + messageSpan.textContent = ' ' + messageText; + + row.appendChild(authorSpan); + row.appendChild(messageSpan); + + overlay.appendChild(row); + while (overlay.children.length > MAX_MESSAGES) overlay.removeChild(overlay.firstChild); + + setTimeout(() => { + row.style.opacity = '0'; + setTimeout(() => row.remove(), 1000); + }, FADE_AFTER_MS); +} diff --git a/mods/userScript.js b/mods/userScript.js index 52bf8c66..b6b891ca 100644 --- a/mods/userScript.js +++ b/mods/userScript.js @@ -20,4 +20,5 @@ import "./features/enableFeatures.js"; import "./ui/customUI.js"; import "./ui/customGuideAction.js"; import "./features/autoFrameRate.js"; -import "./ui/clock.js"; \ No newline at end of file +import "./ui/clock.js"; +import "./features/liveChat.js";