mirror of
https://github.com/steveseguin/captionninja.git
synced 2026-10-09 17:49:30 +00:00
604 lines
24 KiB
HTML
604 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>CAPTION.Ninja - Live Caption Editor</title>
|
|
<meta name="description" content="Review and correct automatic captions before publishing them to a CAPTION.Ninja overlay." />
|
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #101217;
|
|
--panel: #1a1e27;
|
|
--panel-2: #222733;
|
|
--border: #343b4c;
|
|
--text: #f4f6fb;
|
|
--muted: #aeb6c7;
|
|
--accent: #68b5ff;
|
|
--good: #6bd89b;
|
|
--warn: #ffcc66;
|
|
--danger: #ff8585;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 16px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
main { width: min(1100px, 100%); margin: 0 auto; padding: 24px; }
|
|
h1 { margin: 0; font-size: clamp(1.7rem, 4vw, 2.5rem); }
|
|
h2 { margin: 0 0 10px; font-size: 1rem; }
|
|
p { margin: 6px 0; }
|
|
a { color: var(--accent); }
|
|
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
|
|
.subtitle { color: var(--muted); max-width: 720px; }
|
|
.status { display: flex; align-items: center; gap: 8px; color: var(--muted); white-space: nowrap; }
|
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--warn); }
|
|
.status-dot.connected { background: var(--good); }
|
|
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin-bottom: 16px; }
|
|
.workflow { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 10px; align-items: center; }
|
|
.workflow-item { background: var(--panel-2); border-radius: 8px; padding: 12px; min-width: 0; }
|
|
.workflow-item strong, .workflow-item a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.arrow { color: var(--muted); font-size: 1.4rem; }
|
|
.grid { display: grid; grid-template-columns: 1fr 1.25fr; gap: 16px; }
|
|
.incoming { min-height: 146px; padding: 14px; background: var(--panel-2); border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 1.15rem; }
|
|
.incoming.empty { color: var(--muted); }
|
|
.meta { color: var(--muted); font-size: .88rem; margin-top: 8px; }
|
|
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
|
.panel-heading h2 { margin: 0; }
|
|
.timing { color: var(--good); font-size: .86rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
.timing.warn { color: var(--warn); }
|
|
.timing.danger { color: var(--danger); }
|
|
textarea {
|
|
width: 100%; min-height: 146px; resize: vertical; padding: 14px;
|
|
border: 2px solid var(--border); border-radius: 8px; outline: none;
|
|
background: #0e1015; color: var(--text); font: inherit; font-size: 1.2rem;
|
|
}
|
|
textarea:focus { border-color: var(--accent); }
|
|
textarea::placeholder { color: #929caf; opacity: 1; }
|
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 12px; }
|
|
button, .button, select {
|
|
border: 1px solid var(--border); border-radius: 7px; padding: 10px 14px;
|
|
background: var(--panel-2); color: var(--text); font: inherit; cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
button:hover, .button:hover, select:hover { border-color: var(--accent); }
|
|
button:focus-visible, .button:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
|
|
outline: 3px solid #9dcfff;
|
|
outline-offset: 2px;
|
|
}
|
|
button.primary { background: #1769aa; border-color: #318bd1; font-weight: 650; }
|
|
button:disabled { background: #1d222c; color: #98a1b2; border-color: #303747; cursor: not-allowed; }
|
|
.queue { color: var(--muted); margin-left: auto; }
|
|
.settings {
|
|
display: grid;
|
|
grid-template-columns: minmax(220px, .7fr) minmax(220px, .7fr) minmax(220px, 1fr);
|
|
grid-template-areas: "mode delay metrics" "description description metrics";
|
|
gap: 10px 18px;
|
|
align-items: end;
|
|
}
|
|
.setting label { display: block; font-weight: 650; margin-bottom: 6px; }
|
|
.setting select { width: 100%; }
|
|
.mode-setting { grid-area: mode; }
|
|
.delay-setting { grid-area: delay; }
|
|
.mode-description { grid-area: description; color: var(--muted); margin: 0; }
|
|
.delay-setting[hidden] { display: none; }
|
|
.metrics { grid-area: metrics; display: flex; gap: 18px; justify-content: flex-end; align-items: center; color: var(--muted); font-size: .88rem; }
|
|
.metric { min-width: 92px; }
|
|
.metric strong { display: block; color: var(--text); font-size: 1rem; font-variant-numeric: tabular-nums; }
|
|
.room-details { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.room-row { min-width: 0; }
|
|
.room-row code { display: block; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
|
.help { color: var(--muted); font-size: .92rem; }
|
|
|
|
@media (max-width: 780px) {
|
|
main { padding: 16px; }
|
|
.header, .grid, .settings { display: block; }
|
|
.status { margin-top: 10px; }
|
|
.grid .card { margin-bottom: 16px; }
|
|
.settings > * + * { margin-top: 14px; }
|
|
.mode-description { margin-top: 10px; }
|
|
.metrics { justify-content: flex-start; }
|
|
.workflow { grid-template-columns: 1fr; }
|
|
.arrow { transform: rotate(90deg); text-align: center; }
|
|
.room-details { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="header">
|
|
<div>
|
|
<h1>Live Caption Editor</h1>
|
|
<p class="subtitle">Automatic captions arrive here first. Correct each caption, then send it to the public overlay.</p>
|
|
</div>
|
|
<div class="status"><span id="statusDot" class="status-dot"></span><span id="statusText">Connecting…</span></div>
|
|
</div>
|
|
|
|
<section class="card workflow" aria-label="Caption workflow">
|
|
<div class="workflow-item">
|
|
<small>1. Automatic captions</small>
|
|
<a id="captureLink" target="_blank" rel="noopener">Open capture page</a>
|
|
</div>
|
|
<div class="arrow" aria-hidden="true">→</div>
|
|
<div class="workflow-item">
|
|
<small>2. Human review</small>
|
|
<strong>This editor</strong>
|
|
</div>
|
|
<div class="arrow" aria-hidden="true">→</div>
|
|
<div class="workflow-item">
|
|
<small>3. Corrected captions</small>
|
|
<a id="overlayLink" target="_blank" rel="noopener">Open public overlay</a>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card settings" aria-label="Editor preferences">
|
|
<div class="setting mode-setting">
|
|
<label for="publishMode">Publishing mode</label>
|
|
<select id="publishMode">
|
|
<option value="live">Live corrections</option>
|
|
<option value="timed">Timed review</option>
|
|
<option value="manual">Manual approval</option>
|
|
</select>
|
|
</div>
|
|
<p id="modeDescription" class="mode-description" aria-live="polite"></p>
|
|
<div id="delaySetting" class="setting delay-setting">
|
|
<label for="reviewDelay">Review time</label>
|
|
<select id="reviewDelay">
|
|
<option value="3">3 seconds</option>
|
|
<option value="5">5 seconds</option>
|
|
<option value="10">10 seconds</option>
|
|
<option value="15">15 seconds</option>
|
|
<option value="30">30 seconds</option>
|
|
</select>
|
|
</div>
|
|
<div class="metrics" aria-label="Editor timing">
|
|
<div class="metric"><span>Last review</span><strong id="lastDelay">—</strong></div>
|
|
<div class="metric"><span>Oldest waiting</span><strong id="oldestWaiting">—</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<div class="panel-heading">
|
|
<h2>Incoming automatic caption</h2>
|
|
<span id="sourceState" class="timing">Listening</span>
|
|
</div>
|
|
<div id="incoming" class="incoming empty" aria-live="polite">Waiting for captions…</div>
|
|
<div id="incomingMeta" class="meta">Final captions will be placed in the editing queue.</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="panel-heading">
|
|
<h2>Caption being edited</h2>
|
|
<span id="currentTiming" class="timing">Waiting</span>
|
|
</div>
|
|
<textarea id="editor" placeholder="A finalized automatic caption will appear here…" aria-label="Caption being edited"></textarea>
|
|
<div class="actions">
|
|
<button id="sendButton" class="primary" type="button" disabled>Send now</button>
|
|
<button id="skipButton" type="button" disabled>Skip</button>
|
|
<button id="clearButton" type="button">Clear public overlay</button>
|
|
<span id="queueCount" class="queue">Queue: 0</span>
|
|
</div>
|
|
<p id="keyboardHelp" class="help">Press Enter to send. Use Shift+Enter for a line break. Incoming captions wait in the queue while you edit.</p>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="card">
|
|
<h2>Room links</h2>
|
|
<div class="room-details">
|
|
<div class="room-row">
|
|
<strong>Private automatic-caption room</strong>
|
|
<code id="sourceRoom"></code>
|
|
<button id="copyCapture" type="button">Copy capture URL</button>
|
|
</div>
|
|
<div class="room-row">
|
|
<strong>Public corrected-caption room</strong>
|
|
<code id="outputRoom"></code>
|
|
<button id="copyOverlay" type="button">Copy overlay URL</button>
|
|
</div>
|
|
</div>
|
|
<p class="help">Use the public overlay URL for venue projection, OBS browser sources, and iframes. Keep the automatic-caption room private so viewers only see the editor's output.</p>
|
|
</section>
|
|
</main>
|
|
|
|
<script src="ws-publisher.js"></script>
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var WS_URL = 'wss://api.caption.ninja:443';
|
|
var params = new URLSearchParams(window.location.search);
|
|
var sourceRoom = params.get('room') || params.get('source') || params.get('input') || loadRoom('caption_editor_source') || generateRoomId();
|
|
var outputRoom = params.get('output') || params.get('out') || loadRoom('caption_editor_output') || generateRoomId();
|
|
var publishMode = params.get('mode') || loadPreference('caption_editor_mode') || 'timed';
|
|
var reviewDelaySeconds = parseInt(params.get('delay') || loadPreference('caption_editor_delay') || '5', 10);
|
|
|
|
if (sourceRoom === outputRoom) {
|
|
outputRoom = generateRoomId();
|
|
}
|
|
if (['live', 'timed', 'manual'].indexOf(publishMode) === -1) publishMode = 'timed';
|
|
if ([3, 5, 10, 15, 30].indexOf(reviewDelaySeconds) === -1) reviewDelaySeconds = 5;
|
|
|
|
storeRoom('caption_editor_source', sourceRoom);
|
|
storeRoom('caption_editor_output', outputRoom);
|
|
storePreference('caption_editor_mode', publishMode);
|
|
storePreference('caption_editor_delay', String(reviewDelaySeconds));
|
|
params.set('room', sourceRoom);
|
|
params.set('output', outputRoom);
|
|
params.set('mode', publishMode);
|
|
params.set('delay', String(reviewDelaySeconds));
|
|
window.history.replaceState({}, '', window.location.pathname + '?' + params.toString() + window.location.hash);
|
|
|
|
var editor = document.getElementById('editor');
|
|
var incoming = document.getElementById('incoming');
|
|
var incomingMeta = document.getElementById('incomingMeta');
|
|
var sendButton = document.getElementById('sendButton');
|
|
var skipButton = document.getElementById('skipButton');
|
|
var clearButton = document.getElementById('clearButton');
|
|
var publishModeSelect = document.getElementById('publishMode');
|
|
var reviewDelaySelect = document.getElementById('reviewDelay');
|
|
var delaySetting = document.getElementById('delaySetting');
|
|
var modeDescription = document.getElementById('modeDescription');
|
|
var keyboardHelp = document.getElementById('keyboardHelp');
|
|
var queueCount = document.getElementById('queueCount');
|
|
var currentTiming = document.getElementById('currentTiming');
|
|
var oldestWaiting = document.getElementById('oldestWaiting');
|
|
var lastDelay = document.getElementById('lastDelay');
|
|
var sourceState = document.getElementById('sourceState');
|
|
var statusDot = document.getElementById('statusDot');
|
|
var statusText = document.getElementById('statusText');
|
|
var queue = [];
|
|
var current = null;
|
|
var latestInterim = null;
|
|
var lastIncomingAt = 0;
|
|
var lastIncomingWasFinal = false;
|
|
var publishCounter = 0;
|
|
var sourceSocket = null;
|
|
var reconnectTimer = null;
|
|
var timingInterval = null;
|
|
var publisherConnected = false;
|
|
var listenerConnected = false;
|
|
var previousPublishMode = publishMode;
|
|
|
|
publishModeSelect.value = publishMode;
|
|
reviewDelaySelect.value = String(reviewDelaySeconds);
|
|
|
|
var captureUrl = new URL('index.html', window.location.href);
|
|
captureUrl.search = '?room=' + encodeURIComponent(sourceRoom);
|
|
captureUrl = captureUrl.toString();
|
|
var overlayUrl = new URL('overlay.html', window.location.href);
|
|
overlayUrl.search = '?room=' + encodeURIComponent(outputRoom) + '&maxlines=3';
|
|
overlayUrl = overlayUrl.toString();
|
|
document.getElementById('captureLink').href = captureUrl;
|
|
document.getElementById('overlayLink').href = overlayUrl;
|
|
document.getElementById('sourceRoom').textContent = sourceRoom;
|
|
document.getElementById('outputRoom').textContent = outputRoom;
|
|
|
|
var publisher = createWSPublisher({
|
|
url: WS_URL,
|
|
room: outputRoom,
|
|
onStateChange: function (state) {
|
|
publisherConnected = state === 'connected';
|
|
updateStatus();
|
|
},
|
|
onError: function (error) {
|
|
console.error('Output WebSocket error:', error);
|
|
}
|
|
});
|
|
publisher.connect();
|
|
|
|
function generateRoomId() {
|
|
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
var values = new Uint32Array(16);
|
|
if (window.crypto && window.crypto.getRandomValues) {
|
|
window.crypto.getRandomValues(values);
|
|
} else {
|
|
for (var i = 0; i < values.length; i++) values[i] = Math.floor(Math.random() * chars.length);
|
|
}
|
|
var room = '';
|
|
for (var j = 0; j < values.length; j++) room += chars.charAt(values[j] % chars.length);
|
|
return room;
|
|
}
|
|
|
|
function loadRoom(key) {
|
|
try {
|
|
var value = localStorage.getItem(key) || '';
|
|
return /^[A-Za-z0-9]{12,}$/.test(value) ? value : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function storeRoom(key, value) {
|
|
try { localStorage.setItem(key, value); } catch (e) {}
|
|
}
|
|
|
|
function loadPreference(key) {
|
|
try { return localStorage.getItem(key) || ''; } catch (e) { return ''; }
|
|
}
|
|
|
|
function storePreference(key, value) {
|
|
try { localStorage.setItem(key, value); } catch (e) {}
|
|
}
|
|
|
|
function savePreferencesToUrl() {
|
|
params.set('mode', publishMode);
|
|
params.set('delay', String(reviewDelaySeconds));
|
|
window.history.replaceState({}, '', window.location.pathname + '?' + params.toString() + window.location.hash);
|
|
}
|
|
|
|
function updateStatus() {
|
|
var connected = publisherConnected && listenerConnected;
|
|
statusDot.classList.toggle('connected', connected);
|
|
if (connected) {
|
|
statusText.textContent = 'Input and output connected';
|
|
} else if (listenerConnected) {
|
|
statusText.textContent = 'Output connecting…';
|
|
} else if (publisherConnected) {
|
|
statusText.textContent = 'Input connecting…';
|
|
} else {
|
|
statusText.textContent = 'Connecting…';
|
|
}
|
|
sourceState.textContent = listenerConnected ? 'Listening' : 'Reconnecting';
|
|
sourceState.classList.toggle('warn', !listenerConnected);
|
|
}
|
|
|
|
function connectSource() {
|
|
if (sourceSocket && (sourceSocket.readyState === WebSocket.OPEN || sourceSocket.readyState === WebSocket.CONNECTING)) return;
|
|
var socket = new WebSocket(WS_URL);
|
|
sourceSocket = socket;
|
|
socket.onopen = function () {
|
|
if (sourceSocket !== socket) return;
|
|
listenerConnected = true;
|
|
updateStatus();
|
|
socket.send(JSON.stringify({ join: sourceRoom }));
|
|
};
|
|
socket.onmessage = function (event) {
|
|
if (sourceSocket !== socket || !event.data) return;
|
|
try { receiveCaption(JSON.parse(event.data)); } catch (e) {}
|
|
};
|
|
socket.onerror = function () {
|
|
if (sourceSocket === socket && socket.readyState !== WebSocket.CLOSED) socket.close();
|
|
};
|
|
socket.onclose = function () {
|
|
if (sourceSocket !== socket) return;
|
|
sourceSocket = null;
|
|
listenerConnected = false;
|
|
updateStatus();
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = setTimeout(connectSource, 1500);
|
|
};
|
|
}
|
|
|
|
function receiveCaption(data) {
|
|
if (!data || (!Object.prototype.hasOwnProperty.call(data, 'interm') && !Object.prototype.hasOwnProperty.call(data, 'final'))) return;
|
|
var isFinal = Object.prototype.hasOwnProperty.call(data, 'final');
|
|
var text = String(isFinal ? data.final : data.interm || '').slice(0, 1000);
|
|
lastIncomingAt = Date.now();
|
|
lastIncomingWasFinal = isFinal;
|
|
incoming.textContent = text || (isFinal ? 'The source cleared its caption.' : 'Waiting for captions…');
|
|
incoming.classList.toggle('empty', !text);
|
|
incomingMeta.textContent = isFinal ? 'Completed caption received just now' : 'Live automatic preview';
|
|
|
|
if (!isFinal) {
|
|
latestInterim = text.trim() ? { text: text.trim(), label: data.label || '', ln: data.ln || '' } : null;
|
|
if (publishMode === 'live' && !current && !queue.length) {
|
|
publishItem('interm', text.trim(), latestInterim);
|
|
}
|
|
return;
|
|
}
|
|
latestInterim = null;
|
|
if (!text.trim()) return;
|
|
|
|
queue.push({ text: text.trim(), label: data.label || '', ln: data.ln || '', receivedAt: Date.now() });
|
|
if (queue.length > 100) queue.shift();
|
|
loadNext();
|
|
updateTiming();
|
|
}
|
|
|
|
function loadNext() {
|
|
if (current || !queue.length) {
|
|
updateControls();
|
|
return;
|
|
}
|
|
current = queue.shift();
|
|
editor.value = current.text;
|
|
editor.focus();
|
|
editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
if (publishMode === 'live') publishInterim();
|
|
updateControls();
|
|
updateTiming();
|
|
}
|
|
|
|
function updateControls() {
|
|
var hasCurrent = !!current;
|
|
sendButton.disabled = !hasCurrent || !editor.value.trim();
|
|
skipButton.disabled = !hasCurrent;
|
|
queueCount.textContent = 'Queue: ' + queue.length;
|
|
}
|
|
|
|
function createPayload(type, text, item) {
|
|
publishCounter += 1;
|
|
var payload = { msg: true, id: publishCounter };
|
|
payload[type] = text;
|
|
item = item || current;
|
|
if (item && item.label) payload.label = item.label;
|
|
if (item && item.ln) payload.ln = item.ln;
|
|
return payload;
|
|
}
|
|
|
|
function publishItem(type, text, item) {
|
|
publisher.publish(createPayload(type, text, item));
|
|
}
|
|
|
|
function publishInterim() {
|
|
if (!current || publishMode !== 'live') return;
|
|
publishItem('interm', editor.value.trim(), current);
|
|
}
|
|
|
|
function clearPublicInterim() {
|
|
publishCounter += 1;
|
|
publisher.publish({ msg: true, interm: '', id: publishCounter });
|
|
}
|
|
|
|
function sendCurrent() {
|
|
var text = editor.value.trim();
|
|
if (!current || !text) return;
|
|
var completed = current;
|
|
publishItem('final', text, completed);
|
|
lastDelay.textContent = formatDuration(Date.now() - completed.receivedAt);
|
|
current = null;
|
|
editor.value = '';
|
|
loadNext();
|
|
updateTiming();
|
|
}
|
|
|
|
function skipCurrent() {
|
|
if (!current) return;
|
|
if (publishMode === 'live') clearPublicInterim();
|
|
current = null;
|
|
editor.value = '';
|
|
loadNext();
|
|
updateTiming();
|
|
}
|
|
|
|
function formatDuration(milliseconds) {
|
|
var seconds = Math.max(0, milliseconds) / 1000;
|
|
if (seconds < 10) return seconds.toFixed(1) + 's';
|
|
return Math.round(seconds) + 's';
|
|
}
|
|
|
|
function setTimingTone(element, tone) {
|
|
element.classList.toggle('warn', tone === 'warn');
|
|
element.classList.toggle('danger', tone === 'danger');
|
|
}
|
|
|
|
function applyPublishMode() {
|
|
var leavingLiveMode = previousPublishMode === 'live' && publishMode !== 'live';
|
|
if (leavingLiveMode) clearPublicInterim();
|
|
|
|
delaySetting.hidden = publishMode === 'manual';
|
|
if (publishMode === 'live') {
|
|
modeDescription.textContent = 'Partial automatic captions and your edits appear immediately. Each completed caption is finalized after the review time.';
|
|
keyboardHelp.textContent = 'Press Enter to finalize early. Use Shift+Enter for a line break. New captions wait in the queue while you edit.';
|
|
if (current) {
|
|
publishInterim();
|
|
} else if (latestInterim) {
|
|
publishItem('interm', latestInterim.text, latestInterim);
|
|
}
|
|
} else if (publishMode === 'timed') {
|
|
modeDescription.textContent = 'Completed captions stay private for a fixed review window, then publish automatically unless you send them sooner.';
|
|
keyboardHelp.textContent = 'Press Enter to send early. Use Shift+Enter for a line break. The countdown continues while you edit.';
|
|
} else {
|
|
modeDescription.textContent = 'Completed captions remain private until you approve each one. This maximizes control, but delay grows if the queue backs up.';
|
|
keyboardHelp.textContent = 'Press Enter to approve and send. Use Shift+Enter for a line break. Watch the queue age to manage delay.';
|
|
}
|
|
|
|
previousPublishMode = publishMode;
|
|
updateTiming();
|
|
}
|
|
|
|
function updateTiming() {
|
|
var now = Date.now();
|
|
if (lastIncomingAt) {
|
|
incomingMeta.textContent = (lastIncomingWasFinal ? 'Completed caption received ' : 'Live automatic preview updated ') + formatDuration(now - lastIncomingAt) + ' ago';
|
|
}
|
|
|
|
if (!current) {
|
|
currentTiming.textContent = 'Waiting';
|
|
setTimingTone(currentTiming, 'normal');
|
|
} else {
|
|
var age = now - current.receivedAt;
|
|
var autoMode = publishMode === 'live' || publishMode === 'timed';
|
|
if (autoMode) {
|
|
var totalDelay = reviewDelaySeconds * 1000;
|
|
var remaining = Math.max(0, totalDelay - age);
|
|
currentTiming.textContent = 'Auto-send in ' + formatDuration(remaining) + ' · age ' + formatDuration(age);
|
|
setTimingTone(currentTiming, remaining <= totalDelay * 0.2 ? 'danger' : (remaining <= totalDelay * 0.5 ? 'warn' : 'normal'));
|
|
if (remaining <= 0 && !current.autoSendQueued) {
|
|
var itemToSend = current;
|
|
current.autoSendQueued = true;
|
|
setTimeout(function () {
|
|
if (current === itemToSend) sendCurrent();
|
|
}, 0);
|
|
}
|
|
} else {
|
|
currentTiming.textContent = 'Waiting ' + formatDuration(age);
|
|
setTimingTone(currentTiming, age >= 10000 ? 'danger' : (age >= 5000 ? 'warn' : 'normal'));
|
|
}
|
|
}
|
|
|
|
if (queue.length) {
|
|
var oldestAge = now - queue[0].receivedAt;
|
|
oldestWaiting.textContent = formatDuration(oldestAge);
|
|
queueCount.textContent = 'Queue: ' + queue.length + ' · oldest ' + formatDuration(oldestAge);
|
|
} else {
|
|
oldestWaiting.textContent = '—';
|
|
queueCount.textContent = 'Queue: 0';
|
|
}
|
|
}
|
|
|
|
editor.addEventListener('input', function () {
|
|
publishInterim();
|
|
updateControls();
|
|
});
|
|
editor.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
sendCurrent();
|
|
}
|
|
});
|
|
sendButton.addEventListener('click', sendCurrent);
|
|
skipButton.addEventListener('click', skipCurrent);
|
|
clearButton.addEventListener('click', function () {
|
|
publishCounter += 1;
|
|
publisher.publish({ msg: true, final: '', id: publishCounter, c: true });
|
|
});
|
|
publishModeSelect.addEventListener('change', function () {
|
|
publishMode = publishModeSelect.value;
|
|
storePreference('caption_editor_mode', publishMode);
|
|
savePreferencesToUrl();
|
|
applyPublishMode();
|
|
});
|
|
reviewDelaySelect.addEventListener('change', function () {
|
|
reviewDelaySeconds = parseInt(reviewDelaySelect.value, 10) || 5;
|
|
storePreference('caption_editor_delay', String(reviewDelaySeconds));
|
|
savePreferencesToUrl();
|
|
updateTiming();
|
|
});
|
|
document.getElementById('copyCapture').addEventListener('click', function () { copyText(captureUrl, this); });
|
|
document.getElementById('copyOverlay').addEventListener('click', function () { copyText(overlayUrl, this); });
|
|
|
|
function copyText(value, button) {
|
|
navigator.clipboard.writeText(value).then(function () {
|
|
var oldText = button.textContent;
|
|
button.textContent = 'Copied';
|
|
setTimeout(function () { button.textContent = oldText; }, 1200);
|
|
}).catch(function () {
|
|
window.prompt('Copy this URL:', value);
|
|
});
|
|
}
|
|
|
|
window.addEventListener('beforeunload', function () {
|
|
clearTimeout(reconnectTimer);
|
|
clearInterval(timingInterval);
|
|
if (sourceSocket) {
|
|
sourceSocket.onclose = null;
|
|
sourceSocket.close();
|
|
}
|
|
publisher.disconnect();
|
|
});
|
|
|
|
connectSource();
|
|
applyPublishMode();
|
|
updateControls();
|
|
timingInterval = setInterval(updateTiming, 250);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|