Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>ReadStream Theater</title> | |
| <!-- Google Fonts: Inter --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-color: #0b0c10; | |
| --panel-bg: rgba(22, 26, 35, 0.65); | |
| --border-color: rgba(255, 255, 255, 0.08); | |
| --accent-color: #7f5af0; | |
| --accent-glow: rgba(127, 90, 240, 0.35); | |
| --text-color: #fffffe; | |
| --text-muted: #94a1b2; | |
| --live-color: #ff0055; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: flex-start; | |
| width: 100%; | |
| height: 100vh; | |
| overflow-y: auto; | |
| padding: 10px; | |
| } | |
| .theater-container { | |
| display: grid; | |
| grid-template-columns: 1.8fr 1fr; | |
| width: 100%; | |
| max-width: 1350px; | |
| height: 480px; | |
| gap: 16px; | |
| background: rgba(15, 15, 20, 0.4); | |
| border-radius: 16px; | |
| border: 1px solid var(--border-color); | |
| padding: 16px; | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| } | |
| @media (max-width: 1024px) { | |
| .theater-container { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto 1fr; | |
| height: auto; | |
| max-height: none; | |
| overflow-y: auto; | |
| } | |
| } | |
| /* Video section */ | |
| .video-wrapper { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| background: #000; | |
| border-radius: 12px; | |
| border: 1px solid var(--border-color); | |
| overflow: hidden; | |
| } | |
| .video-container { | |
| position: relative; | |
| width: 100%; | |
| flex-grow: 1; | |
| background: #000; | |
| } | |
| .video-container iframe { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| border: 0; | |
| } | |
| /* Chat section */ | |
| .chat-wrapper { | |
| display: flex; | |
| flex-direction: column; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--border-color); | |
| border-radius: 12px; | |
| height: 100%; | |
| overflow: hidden; | |
| box-shadow: inset 0 0 20px rgba(0,0,0,0.2); | |
| } | |
| .chat-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 14px 18px; | |
| border-bottom: 1px solid var(--border-color); | |
| background: rgba(0,0,0,0.15); | |
| } | |
| .chat-title { | |
| font-size: 14px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .live-dot { | |
| width: 8px; | |
| height: 8px; | |
| background-color: var(--live-color); | |
| border-radius: 50%; | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { box-shadow: 0 0 0 0 rgba(255, 0, 85, 0.7); } | |
| 70% { box-shadow: 0 0 0 6px rgba(255, 0, 85, 0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(255, 0, 85, 0); } | |
| } | |
| .chat-messages { | |
| flex-grow: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| scroll-behavior: smooth; | |
| } | |
| /* Custom Scrollbar */ | |
| .chat-messages::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .chat-messages::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .chat-messages::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 4px; | |
| } | |
| .chat-messages::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |
| /* Chat Messages styling */ | |
| .message-row { | |
| display: flex; | |
| flex-direction: column; | |
| padding: 6px 8px; | |
| background: rgba(255,255,255,0.02); | |
| border-radius: 6px; | |
| border-left: 3px solid transparent; | |
| transition: background 0.2s ease; | |
| animation: fadeIn 0.3s ease-out; | |
| } | |
| .message-row:hover { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-left-color: var(--accent-color); | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(4px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .message-meta { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .message-time { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .message-username { | |
| font-size: 13px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| .badge { | |
| font-size: 9px; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| padding: 1px 4px; | |
| border-radius: 3px; | |
| letter-spacing: 0.5px; | |
| } | |
| .badge-mod { | |
| background: #166534; | |
| color: #fff; | |
| } | |
| .badge-sub { | |
| background: var(--accent-color); | |
| color: #fff; | |
| } | |
| .message-text { | |
| font-size: 13px; | |
| line-height: 1.5; | |
| color: #e2e8f0; | |
| word-break: break-word; | |
| } | |
| /* Styled Emotes */ | |
| .emote { | |
| display: inline-block; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| background: rgba(255,255,255,0.1); | |
| margin: 0 2px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| } | |
| .emote.pogchamp { color: #ffbe0b; background: rgba(255, 190, 11, 0.1); } | |
| .emote.kappa { color: #a2d2ff; background: rgba(162, 210, 255, 0.1); } | |
| .emote.monkas { color: #ff006e; background: rgba(255, 0, 110, 0.1); } | |
| .emote.lul { color: #3a86c8; background: rgba(58, 134, 200, 0.1); } | |
| .emote.biblethump { color: #8338ec; background: rgba(131, 56, 236, 0.1); } | |
| .emote.head5 { color: #06d6a0; background: rgba(6, 214, 160, 0.1); } | |
| .emote.pog { color: #ffd166; background: rgba(255, 209, 102, 0.1); } | |
| .emote.pepega { color: #ef476f; background: rgba(239, 71, 111, 0.1); } | |
| .chat-bottom { | |
| padding: 14px 18px; | |
| border-top: 1px solid var(--border-color); | |
| background: rgba(0,0,0,0.15); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .chat-input-field { | |
| flex-grow: 1; | |
| background: rgba(255,255,255,0.03); | |
| border: 1px solid var(--border-color); | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| font-size: 13px; | |
| color: var(--text-color); | |
| font-family: inherit; | |
| outline: none; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .chat-input-field::placeholder { color: var(--text-muted); } | |
| .chat-input-field:focus { | |
| border-color: var(--accent-color); | |
| box-shadow: 0 0 0 2px var(--accent-glow); | |
| } | |
| .chat-btn { | |
| background: var(--accent-color); | |
| color: #fff; | |
| border: 0; | |
| border-radius: 6px; | |
| padding: 8px 14px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| opacity: 1; | |
| transition: background 0.2s ease, opacity 0.2s ease; | |
| } | |
| .chat-btn:hover:not(:disabled) { background: #9370db; } | |
| .chat-btn:disabled { cursor: not-allowed; opacity: 0.5; } | |
| /* Viewer's own live comment */ | |
| .message-row.self { | |
| background: rgba(127, 90, 240, 0.10); | |
| border-left-color: var(--accent-color); | |
| } | |
| .badge-you { | |
| background: #ffbe0b; | |
| color: #1a1a1a; | |
| } | |
| /* Reply thread context line */ | |
| .reply-context { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| margin-bottom: 3px; | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .reply-context::before { | |
| content: "\21B3"; | |
| color: var(--accent-color); | |
| } | |
| /* @mentions inside message text */ | |
| .mention { | |
| color: var(--accent-color); | |
| font-weight: 600; | |
| } | |
| .mention.mention-you { | |
| color: #ffbe0b; | |
| background: rgba(255, 190, 11, 0.12); | |
| border-radius: 3px; | |
| padding: 0 3px; | |
| } | |
| /* "others are typing" indicator */ | |
| .typing-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 8px; | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| font-style: italic; | |
| } | |
| .typing-dots { display: inline-flex; gap: 3px; } | |
| .typing-dots span { | |
| width: 5px; | |
| height: 5px; | |
| border-radius: 50%; | |
| background: var(--text-muted); | |
| animation: typingBlink 1.2s infinite ease-in-out; | |
| } | |
| .typing-dots span:nth-child(2) { animation-delay: 0.2s; } | |
| .typing-dots span:nth-child(3) { animation-delay: 0.4s; } | |
| @keyframes typingBlink { | |
| 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); } | |
| 30% { opacity: 1; transform: translateY(-2px); } | |
| } | |
| .provenance-container { | |
| width: 100%; | |
| max-width: 1350px; | |
| margin-top: 12px; | |
| padding: 10px 14px; | |
| background: rgba(22, 26, 35, 0.65); | |
| border: 1px solid var(--border-color); | |
| border-radius: 10px; | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| line-height: 1.5; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.25); | |
| } | |
| .provenance-title { | |
| color: var(--text-color); | |
| font-weight: 600; | |
| display: block; | |
| margin-bottom: 4px; | |
| text-transform: uppercase; | |
| font-size: 10px; | |
| letter-spacing: 0.8px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="theater-container"> | |
| <!-- Left: Video Player --> | |
| <div class="video-wrapper"> | |
| <div class="video-container"> | |
| <div id="player"></div> | |
| </div> | |
| </div> | |
| <!-- Right: Chat Window --> | |
| <div class="chat-wrapper"> | |
| <div class="chat-header"> | |
| <div class="chat-title"> | |
| <div class="live-dot"></div> | |
| <span>Live Chat Replay</span> | |
| </div> | |
| <div class="message-time" id="video-time-display">00:00</div> | |
| </div> | |
| <div class="chat-messages" id="chat-messages"> | |
| <!-- Messages stream in here --> | |
| </div> | |
| <div class="chat-bottom"> | |
| <input id="chat-input" class="chat-input-field" type="text" maxlength="200" | |
| placeholder="Join the chat…" autocomplete="off" /> | |
| <button id="chat-send" class="chat-btn">Send</button> | |
| </div> | |
| </div> | |
| </div> | |
| {{PROVENANCE_HTML}} | |
| <script> | |
| // Embedded Chat Data Placeholder (Replaced by Python script) | |
| const chatData = {{CHAT_DATA_JSON}}; | |
| const videoId = "{{VIDEO_ID}}"; | |
| // The single viewer's handle for live comments they type in. | |
| const VIEWER_NAME = "You"; | |
| let player; | |
| let updateInterval; | |
| let lastTime = -1; | |
| let displayedMessageIds = new Set(); | |
| let flattenedMessages = []; | |
| // Live-comment state (viewer-typed messages + LLM reply bursts). | |
| let liveCounter = 0; | |
| let lastReqId = null; | |
| let typingTimeout = null; | |
| // Emote tags matching | |
| const emoteMap = { | |
| "PogChamp": "😲 PogChamp", | |
| "Kappa": "😏 Kappa", | |
| "MonkaS": "😰 MonkaS", | |
| "LUL": "😂 LUL", | |
| "BibleThump": "😭 BibleThump", | |
| "5Head": "🧠 5Head", | |
| "Pog": "🤩 Pog", | |
| "Pepega": "🤪 Pepega" | |
| }; | |
| function parseEmotes(text) { | |
| let html = text; | |
| // Escape HTML | |
| html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| Object.keys(emoteMap).forEach(key => { | |
| const regex = new RegExp(`\\b${key}\\b`, 'g'); | |
| const className = key.toLowerCase(); | |
| html = html.replace(regex, `<span class="emote ${className}">${emoteMap[key]}</span>`); | |
| }); | |
| // Highlight @mentions (runs after escaping, so injecting markup is safe). | |
| html = html.replace(/@([A-Za-z0-9_]{1,40})/g, (m, name) => { | |
| const isYou = name.toLowerCase() === VIEWER_NAME.toLowerCase(); | |
| return `<span class="mention${isYou ? ' mention-you' : ''}">@${name}</span>`; | |
| }); | |
| return html; | |
| } | |
| // Assign randomized usernames color based on hash | |
| function getUsernameColor(username) { | |
| const colors = ['#ff85a1', '#f15bb5', '#c77dff', '#70d6ff', '#00bbf9', '#00f5d4', '#fee440', '#52b788', '#70e000', '#ff4d6d', '#ffb703', '#f4a261', '#2ec4b6', '#a2d2ff', '#ff9ebb']; | |
| let hash = 0; | |
| for (let i = 0; i < username.length; i++) { | |
| hash = username.charCodeAt(i) + ((hash << 5) - hash); | |
| } | |
| const index = Math.abs(hash) % colors.length; | |
| return colors[index]; | |
| } | |
| // chatData is a flat list of messages with precomputed numeric displayTime. | |
| // No segment loops, no timestamp arithmetic, no NaN path. | |
| function prepareMessages() { | |
| flattenedMessages = chatData.map((msg, index) => ({ | |
| id: msg.id || `msg_${index}`, | |
| displayTime: msg.displayTime, // numeric, pre-computed by pipeline | |
| username: msg.username, | |
| text: msg.text, | |
| isMod: (index % 15 === 0), | |
| isSub: (index % 4 === 0) && !(index % 15 === 0), | |
| })); | |
| // Guaranteed numeric sort — displayTime is never a string or NaN. | |
| flattenedMessages.sort((a, b) => a.displayTime - b.displayTime); | |
| } | |
| // Create HTML elements for messages | |
| function createMessageElement(msg) { | |
| const row = document.createElement('div'); | |
| row.className = 'message-row'; | |
| if (msg.isSelf) row.classList.add('self'); | |
| // Thread line for replies aimed at the viewer. | |
| if (msg.replyToUser) { | |
| const ctx = document.createElement('div'); | |
| ctx.className = 'reply-context'; | |
| ctx.innerHTML = `replying to <span class="mention mention-you">@${VIEWER_NAME}</span>`; | |
| row.appendChild(ctx); | |
| } | |
| const meta = document.createElement('div'); | |
| meta.className = 'message-meta'; | |
| const timeSpan = document.createElement('span'); | |
| timeSpan.className = 'message-time'; | |
| const mins = Math.floor(msg.displayTime / 60); | |
| const secs = Math.floor(msg.displayTime % 60); | |
| timeSpan.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; | |
| meta.appendChild(timeSpan); | |
| if (msg.isSelf) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'badge badge-you'; | |
| badge.textContent = 'You'; | |
| meta.appendChild(badge); | |
| } else if (msg.isMod) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'badge badge-mod'; | |
| badge.textContent = 'Mod'; | |
| meta.appendChild(badge); | |
| } else if (msg.isSub) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'badge badge-sub'; | |
| badge.textContent = 'Sub'; | |
| meta.appendChild(badge); | |
| } | |
| const userSpan = document.createElement('span'); | |
| userSpan.className = 'message-username'; | |
| userSpan.style.color = getUsernameColor(msg.username); | |
| userSpan.textContent = msg.username; | |
| meta.appendChild(userSpan); | |
| row.appendChild(meta); | |
| const textDiv = document.createElement('div'); | |
| textDiv.className = 'message-text'; | |
| textDiv.innerHTML = parseEmotes(msg.text); | |
| row.appendChild(textDiv); | |
| return row; | |
| } | |
| // Synchronize chat with video current time | |
| function updateChat() { | |
| if (!player || typeof player.getCurrentTime !== 'function') return; | |
| const currentTime = player.getCurrentTime(); | |
| // Update timer display | |
| const displayMins = Math.floor(currentTime / 60); | |
| const displaySecs = Math.floor(currentTime % 60); | |
| document.getElementById('video-time-display').textContent = | |
| `${displayMins.toString().padStart(2, '0')}:${displaySecs.toString().padStart(2, '0')}`; | |
| // Handle seek backwards or forwards significantly | |
| if (currentTime < lastTime || currentTime - lastTime > 6) { | |
| const chatMessagesDiv = document.getElementById('chat-messages'); | |
| chatMessagesDiv.innerHTML = ''; | |
| displayedMessageIds.clear(); | |
| // Catch up with messages immediately prior to seek point (max 12 messages to prevent overload) | |
| const pastMessages = flattenedMessages.filter(m => m.displayTime <= currentTime); | |
| const catchupCount = 12; | |
| const startIdx = Math.max(0, pastMessages.length - catchupCount); | |
| const toRender = pastMessages.slice(startIdx); | |
| // Mark EVERY past message as displayed — not just the rendered 12 — so the | |
| // newMessages filter below doesn't immediately re-append the older ones | |
| // we deliberately skipped (which would defeat the catch-up cap). | |
| pastMessages.forEach(m => displayedMessageIds.add(m.id)); | |
| toRender.forEach(m => { | |
| chatMessagesDiv.appendChild(createMessageElement(m)); | |
| }); | |
| chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight; | |
| } | |
| lastTime = currentTime; | |
| // Filter new messages to display | |
| const newMessages = flattenedMessages.filter(m => m.displayTime <= currentTime && !displayedMessageIds.has(m.id)); | |
| if (newMessages.length > 0) { | |
| const chatMessagesDiv = document.getElementById('chat-messages'); | |
| newMessages.forEach(m => { | |
| displayedMessageIds.add(m.id); | |
| chatMessagesDiv.appendChild(createMessageElement(m)); | |
| }); | |
| // Keep list to a reasonable limit (e.g. 100 elements max to maintain DOM performance) | |
| while (chatMessagesDiv.children.length > 100) { | |
| chatMessagesDiv.removeChild(chatMessagesDiv.firstChild); | |
| } | |
| chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight; | |
| } | |
| } | |
| // YouTube IFrame Player API initialization | |
| function onYouTubeIframeAPIReady() { | |
| player = new YT.Player('player', { | |
| height: '100%', | |
| width: '100%', | |
| videoId: videoId, | |
| playerVars: { | |
| 'playsinline': 1, | |
| 'modestbranding': 1, | |
| 'rel': 0 | |
| }, | |
| events: { | |
| 'onStateChange': onPlayerStateChange | |
| } | |
| }); | |
| } | |
| function onPlayerStateChange(event) { | |
| if (event.data === YT.PlayerState.PLAYING) { | |
| if (!updateInterval) { | |
| updateInterval = setInterval(updateChat, 250); | |
| } | |
| } else { | |
| if (updateInterval) { | |
| clearInterval(updateInterval); | |
| updateInterval = null; | |
| } | |
| // Run one final update to align timestamps | |
| updateChat(); | |
| } | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Live viewer comments + LLM reply bursts | |
| // ----------------------------------------------------------------------- | |
| // Best-effort current playback time; falls back to the last synced time | |
| // (e.g. while paused or before the player is ready). | |
| function currentPlaybackTime() { | |
| if (player && typeof player.getCurrentTime === 'function') { | |
| const t = player.getCurrentTime(); | |
| if (typeof t === 'number' && !isNaN(t)) return t; | |
| } | |
| return Math.max(0, lastTime); | |
| } | |
| // Insert a live message into the ordered model AND render it now, whether | |
| // the video is playing or paused. Marking it displayed keeps the 250ms | |
| // sync tick from double-rendering it; keeping it in flattenedMessages means | |
| // a seek-rebuild reproduces it at the right point in time. | |
| function injectLiveMessage(msg) { | |
| let i = flattenedMessages.length; | |
| while (i > 0 && flattenedMessages[i - 1].displayTime > msg.displayTime) i--; | |
| flattenedMessages.splice(i, 0, msg); | |
| const chatMessagesDiv = document.getElementById('chat-messages'); | |
| displayedMessageIds.add(msg.id); | |
| const el = createMessageElement(msg); | |
| const typingEl = document.getElementById('typing-row'); | |
| if (typingEl) { | |
| chatMessagesDiv.insertBefore(el, typingEl); | |
| } else { | |
| chatMessagesDiv.appendChild(el); | |
| } | |
| while (chatMessagesDiv.children.length > 100) { | |
| chatMessagesDiv.removeChild(chatMessagesDiv.firstChild); | |
| } | |
| chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight; | |
| } | |
| function showTyping() { | |
| const chatMessagesDiv = document.getElementById('chat-messages'); | |
| if (document.getElementById('typing-row')) return; | |
| const row = document.createElement('div'); | |
| row.className = 'typing-row'; | |
| row.id = 'typing-row'; | |
| row.innerHTML = 'others are typing <span class="typing-dots"><span></span><span></span><span></span></span>'; | |
| chatMessagesDiv.appendChild(row); | |
| chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight; | |
| } | |
| function hideTyping() { | |
| const row = document.getElementById('typing-row'); | |
| if (row) row.remove(); | |
| } | |
| // A few most-recent visible messages, as conversational context for the LLM. | |
| function collectRecentContext(limit) { | |
| const now = currentPlaybackTime(); | |
| return flattenedMessages | |
| .filter(m => m.displayTime <= now) | |
| .slice(-limit) | |
| .map(m => ({ username: m.username, text: m.text })); | |
| } | |
| function sendUserComment() { | |
| const input = document.getElementById('chat-input'); | |
| if (!input) return; | |
| const text = (input.value || '').trim(); | |
| if (!text) return; | |
| input.value = ''; | |
| const now = currentPlaybackTime(); | |
| injectLiveMessage({ | |
| id: `live_user_${++liveCounter}`, | |
| displayTime: now, | |
| username: VIEWER_NAME, | |
| text: text, | |
| isSelf: true, | |
| isMod: false, | |
| isSub: false, | |
| }); | |
| // Ask the parent page (-> Gradio backend) for in-character replies. | |
| const reqId = `req_${Date.now()}_${liveCounter}`; | |
| lastReqId = reqId; | |
| showTyping(); | |
| if (typingTimeout) clearTimeout(typingTimeout); | |
| typingTimeout = setTimeout(hideTyping, 20000); | |
| try { | |
| window.parent.postMessage({ | |
| __rs: 'user_comment', | |
| payload: { reqId, text, username: VIEWER_NAME, recent: collectRecentContext(6) } | |
| }, '*'); | |
| } catch (e) { | |
| hideTyping(); | |
| } | |
| } | |
| // Render an LLM reply burst, staggered so it feels like people typing. | |
| function handleReplies(data) { | |
| hideTyping(); | |
| if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } | |
| const replies = (data && data.replies) || []; | |
| replies.slice(0, 3).forEach((r, idx) => { | |
| setTimeout(() => { | |
| const text = (r && r.text) || ''; | |
| if (!text) return; | |
| const mentionsViewer = text.toLowerCase().includes('@' + VIEWER_NAME.toLowerCase()); | |
| injectLiveMessage({ | |
| id: `live_reply_${++liveCounter}`, | |
| displayTime: currentPlaybackTime(), | |
| username: (r && r.username) || 'chatter', | |
| text: text, | |
| replyToUser: mentionsViewer, | |
| isSelf: false, | |
| isMod: false, | |
| isSub: (idx % 2 === 0), | |
| }); | |
| }, 700 + idx * 1100); | |
| }); | |
| } | |
| // Replies relayed back from the parent page. | |
| window.addEventListener('message', (e) => { | |
| const d = e.data; | |
| if (!d || d.__rs !== 'llm_reply') return; | |
| let parsed; | |
| try { parsed = JSON.parse(d.payload); } catch (err) { return; } | |
| handleReplies(parsed); | |
| }); | |
| // Wire up the input controls. | |
| (function initLiveInput() { | |
| const input = document.getElementById('chat-input'); | |
| const btn = document.getElementById('chat-send'); | |
| if (btn) btn.addEventListener('click', sendUserComment); | |
| if (input) input.addEventListener('keydown', (ev) => { | |
| if (ev.key === 'Enter') { ev.preventDefault(); sendUserComment(); } | |
| }); | |
| })(); | |
| // Initialize | |
| prepareMessages(); | |
| // Asynchronously load the YouTube IFrame Player API | |
| if (window.YT && window.YT.Player) { | |
| onYouTubeIframeAPIReady(); | |
| } else { | |
| var tag = document.createElement('script'); | |
| tag.src = "https://www.youtube.com/iframe_api"; | |
| var firstScriptTag = document.getElementsByTagName('script')[0]; | |
| if (firstScriptTag) { | |
| firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); | |
| } else { | |
| document.head.appendChild(tag); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |