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-placeholder { | |
| 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-muted); | |
| user-select: none; | |
| } | |
| .chat-btn { | |
| background: var(--accent-color); | |
| color: #fff; | |
| border: 0; | |
| border-radius: 6px; | |
| padding: 8px 14px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: not-allowed; | |
| opacity: 0.6; | |
| } | |
| .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"> | |
| <div class="chat-input-placeholder">Simulated replay - chat is read-only</div> | |
| <button class="chat-btn" disabled>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}}"; | |
| let player; | |
| let updateInterval; | |
| let lastTime = -1; | |
| let displayedMessageIds = new Set(); | |
| let flattenedMessages = []; | |
| // 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>`); | |
| }); | |
| 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'; | |
| 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.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); | |
| toRender.forEach(m => { | |
| displayedMessageIds.add(m.id); | |
| 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(); | |
| } | |
| } | |
| // 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> | |