readstream / theater_template.html
adelevett's picture
provenance
fe40bed
Raw
History Blame Contribute Delete
15.7 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-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, "&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>`);
});
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>