Files

650 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<base href="/" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>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" />
<link rel="stylesheet" href="/caption-local/theme.css" />
<style>
:root {
color-scheme: dark;
--bg: var(--cc-bg, #0f1420);
--panel: var(--cc-panel, #172338);
--panel-2: var(--cc-panel-2, #0c111c);
--border: var(--cc-border, #2c3c58);
--text: var(--cc-text, #eef3fb);
--muted: var(--cc-muted, #9fb0cb);
--accent: var(--cc-accent, #2498eb);
--good: var(--cc-good, #4fd47f);
--warn: var(--cc-warn, #f2c14e);
--danger: var(--cc-danger, #ff7777);
}
* { 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; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin-bottom: 16px; }
.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-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; }
.arrow { transform: rotate(90deg); text-align: center; width: 1.5em; justify-self: center; }
}
</style>
<script src="/caption-local/caption-status.js"></script><script src="/caption-local/caption-room-menu.js"></script><script src="relay-config.js"></script><script src="private-relay-client.js"></script>
</head>
<body>
<nav class="cc-room-menu" data-current="editor" aria-label="Caption room workflow" hidden></nav>
<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="cc-dot" data-state="warn" aria-label="Connecting"></span><span id="statusText">Connecting…</span></div>
</div>
<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>
<option value="unreviewed">Unreviewed — captions go straight to audience</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>
</main>
<script src="ws-publisher.js"></script>
<script>
(async function () {
'use strict';
var WS_URL = CaptionRelay.url();
var params = new URLSearchParams(window.location.search);
var roomMatch = window.location.pathname.match(/^\/room\/([A-Za-z0-9_-]+)\/edit\/?$/);
var roomMode = !!roomMatch;
var roomConfig = null;
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();
if (roomMode) {
var roomResponse = await fetch('/api/rooms/' + roomMatch[1] + '/config', {credentials: 'same-origin'});
if (!roomResponse.ok) {
document.body.textContent = roomResponse.status === 401 ? 'Open this editor from its room link.' : 'This room is closed or unavailable.';
return;
}
roomConfig = await roomResponse.json();
CaptionRelay.configure(roomConfig.address);
sourceRoom = roomConfig.room;
outputRoom = roomConfig.outputRoom;
WS_URL = CaptionRelay.url();
var audienceBase = new URL(roomConfig.site || window.location.origin, window.location.href);
window.CaptionRoomMenu?.set({roomId: roomConfig.publicId,
audienceUrl: new URL('watch/' + encodeURIComponent(roomConfig.publicId), audienceBase).href});
} else {
var setup = await CaptionRelay.setup({kind: 'editor', room: sourceRoom, output: outputRoom});
sourceRoom = setup.room; outputRoom = setup.output; WS_URL = CaptionRelay.url();
params = new URLSearchParams(window.location.search);
}
var publishMode = params.get('mode') || loadPreference('caption_editor_mode') || 'timed';
var reviewDelaySeconds = parseInt(params.get('delay') || loadPreference('caption_editor_delay') || '5', 10);
if (!roomMode && sourceRoom === outputRoom) {
outputRoom = generateRoomId();
}
if (['live', 'timed', 'manual', 'unreviewed'].indexOf(publishMode) === -1) publishMode = 'timed';
if (publishMode === 'unreviewed' && !params.has('mode')) publishMode = 'timed';
if ([3, 5, 10, 15, 30].indexOf(reviewDelaySeconds) === -1) reviewDelaySeconds = 5;
if (publishMode !== 'unreviewed') storePreference('caption_editor_mode', publishMode);
storePreference('caption_editor_delay', String(reviewDelaySeconds));
if (!roomMode) {
storeRoom('caption_editor_source', sourceRoom);
storeRoom('caption_editor_output', outputRoom);
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 privateSource = null;
var reconnectTimer = null;
var timingInterval = null;
var publisherConnected = false;
var listenerConnected = false;
var publisherError = false;
var listenerError = false;
var previousPublishMode = publishMode;
publishModeSelect.value = publishMode;
reviewDelaySelect.value = String(reviewDelaySeconds);
if (CaptionRelay.custom()) {
document.body.classList.add('private-relay-editor');
document.querySelector('.subtitle').textContent = 'Review automatic captions, then send approved text to your audience overlay.';
clearButton.textContent = 'Clear audience overlay';
var helpLink = document.createElement('a'); helpLink.href = 'https://github.com/steveseguin/caption-local/blob/main/docs/README.md';
helpLink.textContent = 'Setup guides and help'; helpLink.target = '_blank'; helpLink.rel = 'noopener noreferrer';
document.querySelector('.header > div').append(helpLink);
}
if (roomMode) {
document.querySelector('.subtitle').textContent = 'Review automatic captions, then send approved text to your audience overlay.';
clearButton.textContent = 'Clear audience overlay';
}
var sourceJoin = CaptionRelay.join(sourceRoom, 'read', roomMode ? roomConfig.readToken : undefined);
async function refreshRoomConfig() {
var response = await fetch('/api/rooms/' + roomMatch[1] + '/config', {credentials: 'same-origin'});
if (!response.ok) throw new Error('This room link is no longer valid.');
roomConfig = await response.json();
return roomConfig;
}
var publisher = (CaptionRelay.custom() ? CaptionRelay.createPublisher : createWSPublisher)({
url: WS_URL,
room: outputRoom,
relayToken: roomMode ? roomConfig.outputWriteToken : undefined,
refreshToken: roomMode ? async function () { return (await refreshRoomConfig()).outputWriteToken; } : undefined,
onStateChange: function (state) {
publisherConnected = state.startsWith('connected');
updateStatus();
},
onError: function (error) {
console.error('Output WebSocket error:', error);
if (CaptionRelay.custom()) CaptionRelay.showStatus(error, true);
}
});
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() {
if (publishMode === 'unreviewed') params.delete('mode');
else params.set('mode', publishMode);
params.set('delay', String(reviewDelaySeconds));
window.history.replaceState({}, '', window.location.pathname + '?' + params.toString() + window.location.hash);
}
function setConnectionStatus(state, text) {
statusText.textContent = text;
window.CaptionStatus?.set(statusDot, state, text);
}
function updateStatus() {
var connected = publisherConnected && listenerConnected;
if (publisherError || listenerError) {
setConnectionStatus('danger', 'Caption connections not working');
} else if (connected) {
setConnectionStatus('good', 'Input and output connected');
} else if (listenerConnected) {
setConnectionStatus('warn', 'Output connecting…');
} else if (publisherConnected) {
setConnectionStatus('warn', 'Input connecting…');
} else {
setConnectionStatus('warn', 'Connecting…');
}
sourceState.textContent = listenerConnected ? 'Listening' : 'Reconnecting';
sourceState.classList.toggle('warn', !listenerConnected);
}
function connectSource() {
if (CaptionRelay.custom()) {
if (!privateSource) privateSource = CaptionRelay.subscribe({room: sourceRoom,
token: roomMode ? roomConfig.readToken : undefined,
refreshToken: roomMode ? async function () { return (await refreshRoomConfig()).readToken; } : undefined,
onCaption: receiveCaption,
onState: function (state) {
listenerConnected = state.startsWith('connected');
listenerError = state === 'denied';
updateStatus();
if (state === 'denied') CaptionRelay.showStatus('Private relay denied access. Reload with the correct room link.', true);
},
onError: function (error) {
listenerError = true; updateStatus();
CaptionRelay.showStatus(String(error), true);
},
onGap: CaptionRelay.showGap
});
return;
}
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 = !CaptionRelay.custom();
updateStatus();
socket.send(JSON.stringify(sourceJoin));
};
socket.onmessage = function (event) {
if (sourceSocket !== socket || !event.data) return;
try {
var data = JSON.parse(event.data);
if (data.joined === sourceRoom && data.role === 'read') { listenerConnected = true; updateStatus(); }
else receiveCaption(data);
} catch (e) {}
};
socket.onerror = function () {
if (sourceSocket === socket && socket.readyState !== WebSocket.CLOSED) socket.close();
};
socket.onclose = function (event) {
if (sourceSocket !== socket) return;
sourceSocket = null;
listenerConnected = false;
updateStatus();
clearTimeout(reconnectTimer);
if (CaptionRelay.custom() && event.code === 1008) { listenerError = true; updateStatus(); return; }
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 (publishMode === 'unreviewed') {
var direct = text.trim();
latestInterim = isFinal ? null : (direct ? { text: direct, label: data.label || '', ln: data.ln || '' } : null);
if (direct) {
publishCounter += 1;
var payload = { msg: true, id: publishCounter };
payload[isFinal ? 'final' : 'interm'] = direct;
if (data.label) payload.label = data.label;
if (data.ln) payload.ln = data.ln;
publisher.publish(payload);
}
return;
}
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();
if (CaptionRelay.custom()) CaptionRelay.showStatus('The editor queue reached 100 captions. Older waiting captions were dropped; pause capture and catch up before continuing.', true);
}
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' || publishMode === 'unreviewed';
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 if (publishMode === 'manual') {
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.';
} else {
modeDescription.textContent = 'Automatic captions publish immediately without review. The editor remains available for existing queued captions, but new captions bypass it.';
keyboardHelp.textContent = 'Unreviewed mode sends new automatic captions straight to the audience. Switch to another mode to review new captions.';
}
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 = publishMode === 'unreviewed' ? 'Passthrough' : '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 () {
var next = publishModeSelect.value;
if (next === 'unreviewed' && !window.confirm('Publish future automatic captions without review?')) {
publishModeSelect.value = previousPublishMode;
return;
}
publishMode = next;
if (publishMode !== 'unreviewed') 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();
});
window.addEventListener('beforeunload', function () {
clearTimeout(reconnectTimer);
clearInterval(timingInterval);
if (sourceSocket) {
sourceSocket.onclose = null;
sourceSocket.close();
}
publisher.disconnect();
if (privateSource) privateSource.disconnect();
});
connectSource();
applyPublishMode();
updateControls();
timingInterval = setInterval(updateTiming, 250);
})();
</script>
</body>
</html>