readstream / theater_template.html
adelevett's picture
Upload 3 files
05e14c4 verified
Raw
History Blame Contribute Delete
24.4 kB
<!DOCTYPE html>
<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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>