Files
captionninja/overlay_roll.html
2026-01-12 10:17:53 -05:00

180 lines
9.5 KiB
HTML

<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<meta name="copyright" content="© 2020 Steve Seguin" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="shortcut icon" href="favicon.svg" />
<title>Caption Overlay: Credits Roll</title>
<meta name="title" content="CAPTION.Ninja: Credits Roll" />
<meta name="description" content="Credits-style rolling overlay controlled remotely." />
<meta name="author" content="Steve Seguin" />
<style>
@font-face { font-family: 'Cousine'; src: url('fonts/Cousine-Bold.ttf') format('truetype'); }
html, body { margin:0; padding:0; height:100%; background: transparent; }
body { position: relative; overflow: hidden; }
.container { position:absolute; left:0; right:0; top:0; bottom:0; overflow:hidden; padding: 0 10px; display:block; }
.content { position:absolute; left:0; right:0; top:0; transform: translateY(0); will-change: transform; }
.line {
color: var(--fg, white); background: var(--bg, black); display:block; font-family: Cousine, monospace;
font-size: var(--fs, 3.2em); line-height: 1.2em; letter-spacing: 0.0em;
text-shadow: var(--shadow, 0.05em 0.05em 0px rgba(0,0,0,1));
padding: var(--pad-y, 0.15em) var(--pad-x, 0.25em) 0 var(--pad-x, 0.25em);
margin: var(--gap, 0.15em) 0 0 0;
text-transform: var(--transform, none);
}
</style>
</head>
<body>
<div class="container" id="container">
<div class="content" id="content"></div>
</div>
<div id="status" style="display:none;"></div>
<script>
// Minimal URLSearchParams polyfill
(function (w) {
w.URLSearchParams = w.URLSearchParams || function (searchString) {
var self = this; self.searchString = searchString; self.get = function (name) {
var results = new RegExp('[\\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) { return null; } else { return decodeURI(results[1]) || 0; }
}; self.has = function(n){ return new RegExp('[\\?&]' + n + '=').test(self.searchString); };
}; })(window);
const urlParams = new URLSearchParams(window.location.search);
// Room
let roomID = urlParams.get('room') || urlParams.get('ROOM') || 'test';
// Config
const autoMode = urlParams.has('auto') || urlParams.has('autospeed');
const fontSize = parseFloat(urlParams.get('fontsize')) || 3.2; // em
const minPxPerSec = 10; const maxPxPerSec = 160; // scroll speed bounds
const baseSpeedScale = Math.max(0.1, parseFloat(urlParams.get('speed')) || 1.0); // 1.0 = default
const liveDefault = !(urlParams.has('nolive') || urlParams.has('rollonly')) || urlParams.has('live');
// Style customizations
const fg = urlParams.get('fg') || urlParams.get('color');
const bg = urlParams.get('bg') || null;
const pad = parseFloat(urlParams.get('pad'));
const gap = parseFloat(urlParams.get('gap'));
const shadow = urlParams.has('shadow') ? (String(urlParams.get('shadow')||'1') !== '0') : true;
const upper = urlParams.has('upper') || urlParams.has('uppercase');
// DOM
const container = document.getElementById('container');
const content = document.getElementById('content');
document.documentElement.style.setProperty('--fs', fontSize + 'em');
if (fg) document.documentElement.style.setProperty('--fg', fg);
if (bg) document.documentElement.style.setProperty('--bg', bg);
if (!shadow) document.documentElement.style.setProperty('--shadow', 'none');
if (!isNaN(pad)) { document.documentElement.style.setProperty('--pad-x', pad + 'em'); document.documentElement.style.setProperty('--pad-y', Math.max(0, pad*0.6).toFixed(3) + 'em'); }
if (!isNaN(gap)) document.documentElement.style.setProperty('--gap', Math.max(0, gap) + 'em');
if (upper) document.documentElement.style.setProperty('--transform', 'uppercase');
// Roll state
let playing = false; let basePxPerSec = 40 * baseSpeedScale; let pxPerSec = basePxPerSec; let offset = 0; let lastTs = 0;
let words = []; let textRaw = ''; let matchIndex = 0; // for auto align
let controlActive = false; let liveMode = liveDefault; let liveInit = false;
// Render helpers
function renderText(txt){
textRaw = txt || '';
content.innerHTML = '';
// Add spacer so text rolls in from bottom
const spacer = document.createElement('div'); spacer.id = 'topSpacer'; spacer.style.height = container.clientHeight + 'px'; content.appendChild(spacer);
const lines = (txt||'').split(/\n+/);
for (const ln of lines){ if (!ln.trim()) continue; const el = document.createElement('span'); el.className='line'; el.textContent = ln; content.appendChild(el); }
offset = 0; lastTs = 0; matchIndex = 0; // reset
// Prepare words for auto mode
words = (txt||'').toLowerCase().replace(/[^a-z0-9\s]/g,' ').split(/\s+/).filter(Boolean);
}
function ensureLiveInit(){ if (liveInit) return; content.innerHTML=''; const spacer = document.createElement('div'); spacer.id='topSpacer'; spacer.style.height = container.clientHeight + 'px'; content.appendChild(spacer); liveInit = true; }
function appendLine(ln){ if (!ln || !ln.trim()) return; ensureLiveInit(); const el = document.createElement('span'); el.className='line'; el.textContent = ln; content.appendChild(el); if (!playing) { playing = true; } }
function updateSpacer(){ const sp = document.getElementById('topSpacer'); if (sp) sp.style.height = container.clientHeight + 'px'; }
function maxScroll(){ return Math.max(0, content.scrollHeight - container.clientHeight); }
function raf(ts){
if (!playing) { lastTs = ts; requestAnimationFrame(raf); return; }
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000; lastTs = ts;
// Auto mode adjust towards target
if (autoMode && words.length){
// Compute desired offset from matchIndex
const ratio = Math.min(1, matchIndex / Math.max(1, words.length));
const target = maxScroll() * ratio;
const delta = target - offset;
const adjust = Math.max(minPxPerSec, Math.min(maxPxPerSec, basePxPerSec + delta * 0.5));
pxPerSec = adjust;
}
offset += pxPerSec * dt;
if (offset > maxScroll() + 40) { playing = false; }
content.style.transform = 'translateY(' + (-offset) + 'px)';
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
window.addEventListener('resize', updateSpacer);
// WS
let socket;
function connect(){
socket = new WebSocket('wss://api.caption.ninja:443');
socket.onopen = function(){ socket.send(JSON.stringify({join: roomID})); };
socket.onclose = function(){ setTimeout(connect, 1000); };
socket.onerror = function(){ setTimeout(connect, 1500); };
socket.onmessage = function(ev){ try{ const data = JSON.parse(ev.data); handleMessage(data); }catch(e){} };
}
connect();
function normWords(t){ return (t||'').toLowerCase().replace(/[^a-z0-9\s]/g,' ').split(/\s+/).filter(Boolean); }
function handleSTT(data){
if (!autoMode || !words.length) return;
const spoken = normWords(data.final || data.interm || ''); if (!spoken.length) return;
// Try to align by finding a small anchor (first 2-3 words) near current position
const anchorLen = Math.min(3, spoken.length);
const anchor = spoken.slice(0, anchorLen).join(' ');
const startSearch = Math.max(0, matchIndex - 50);
const endSearch = Math.min(words.length, matchIndex + 200);
const hay = words.slice(startSearch, endSearch).join(' ');
const idx = hay.indexOf(anchor);
if (idx >= 0){
// Rough character-based index to word-based estimate
const pre = hay.slice(0, idx).trim();
const preCount = pre ? pre.split(/\s+/).length : 0;
// Update match index forward
matchIndex = Math.max(matchIndex, startSearch + preCount + Math.max(anchorLen, Math.floor(spoken.length*0.6)));
}
}
function handleRollControl(data){
controlActive = true; // switch off live mode on first control message
switch(String(data.action||'').toLowerCase()){
case 'load':
renderText(String(data.text||''));
break;
case 'play':
basePxPerSec = Math.max(minPxPerSec, Math.min(maxPxPerSec, (Number(data.speed)||1) * 40));
pxPerSec = basePxPerSec; playing = true; break;
case 'pause': playing = false; break;
case 'resume': playing = true; break;
case 'stop': playing = false; offset = 0; content.style.transform = 'translateY(0px)'; break;
case 'speed':
basePxPerSec = Math.max(minPxPerSec, Math.min(maxPxPerSec, (Number(data.speed)||1) * 40));
pxPerSec = basePxPerSec; break;
case 'goto':
const ratio = Math.max(0, Math.min(1, Number(data.ratio)||0));
offset = maxScroll() * ratio; break;
}
}
function handleMessage(data){
if (data && data.roll){ handleRollControl(data); return; }
if (data && (data.final || data.interm)){
handleSTT(data);
// In live mode (no external control), append final lines to the roll
if (liveMode && !controlActive && data.final){ appendLine(String(data.final)); }
}
}
</script>
</body>
</html>