Files
captionninja/editor.html
T

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>