Two complementary fixes: 1. Register the keydown handler on the capture phase. This ensures we run before the native HTML <video> element's built-in keyboard handler, so our preventDefault actually blocks the default seek. Without this, when focus was on the video, both handlers ran and the actual jump was the sum of both (±5 s ours + native = >1 min in some browsers). 2. Blur the video element on mouseup. After clicking the seekbar the video keeps keyboard focus, which made the symptom appear right after every scrub. Releasing focus on mouseup avoids that altogether. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
536 lines
23 KiB
HTML
536 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Cupido — barrier-opening picker</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
background: #1a1a1a; color: #e8e8e8; }
|
|
header { padding: 0.6rem 1rem; background: #111; border-bottom: 1px solid #333;
|
|
display: flex; align-items: center; gap: 1.5rem; }
|
|
h1 { margin: 0; font-size: 1rem; font-weight: 500; color: #ccc; }
|
|
#status { font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85rem;
|
|
color: #9aa; }
|
|
#info { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #cce; }
|
|
#meta { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.6rem 0 0.4rem;
|
|
font-size: 0.85rem; color: #aab; max-width: 1400px; width: 100%;
|
|
justify-content: center; }
|
|
#meta .pair { font-family: ui-monospace, monospace; }
|
|
#meta .pair .k { color: #678; }
|
|
#meta .pair .v { color: #def; margin-left: 0.25rem; }
|
|
#meta .role-training { color: #cd6 !important; }
|
|
#meta .role-testing { color: #6cd !important; }
|
|
main { display: flex; flex-direction: column; align-items: center; padding: 1rem; }
|
|
video { width: 100%; max-width: 1400px; height: auto; background: #000;
|
|
border-radius: 4px; }
|
|
#controls { margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;
|
|
justify-content: center; }
|
|
button { padding: 0.7rem 1.4rem; font-size: 0.95rem; border: 1px solid #444;
|
|
background: #2a2a2a; color: #eee; border-radius: 4px; cursor: pointer;
|
|
transition: all 80ms; }
|
|
button:hover { background: #383838; border-color: #666; }
|
|
button:active { transform: translateY(1px); }
|
|
button.primary { background: #2d5; color: #053; border-color: #1a4;
|
|
font-weight: 600; }
|
|
button.primary:hover { background: #3e6; }
|
|
button.warn { background: #d84; color: #311; border-color: #b62; }
|
|
button.warn:hover { background: #ea5; }
|
|
button.muted { background: #2a2a2a; color: #888; }
|
|
.mute-divider { width: 1px; background: #333; margin: 0 0.3rem; }
|
|
#help { margin-top: 1rem; font-size: 0.8rem; color: #889; text-align: center;
|
|
font-family: ui-monospace, monospace; }
|
|
kbd { background: #222; border: 1px solid #444; border-radius: 3px;
|
|
padding: 0.1rem 0.4rem; font-size: 0.8rem; color: #ddd;
|
|
font-family: ui-monospace, monospace; }
|
|
#progress { font-size: 0.85rem; color: #889; margin-left: auto; }
|
|
#flash { position: fixed; top: 0.5rem; right: 0.5rem; padding: 0.5rem 1rem;
|
|
border-radius: 4px; opacity: 0; transition: opacity 200ms;
|
|
font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
|
#flash.show { opacity: 1; }
|
|
#flash.ok { background: #2d5; color: #042; }
|
|
#flash.err { background: #d44; color: white; }
|
|
|
|
/* Welcome modal */
|
|
#help-btn { background: transparent; border: 1px solid #444; color: #aab;
|
|
font-size: 0.85rem; padding: 0.3rem 0.7rem; }
|
|
#help-btn:hover { background: #2a2a2a; }
|
|
#modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
|
display: none; align-items: center; justify-content: center;
|
|
z-index: 100; padding: 1rem; }
|
|
#modal-backdrop.show { display: flex; }
|
|
#modal { background: #222; border: 1px solid #444; border-radius: 6px;
|
|
padding: 1.6rem 2rem; max-width: 640px; width: 100%;
|
|
max-height: 90vh; overflow-y: auto; color: #ddd; line-height: 1.55; }
|
|
#modal h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.2rem; }
|
|
#modal h3 { margin: 1.2rem 0 0.4rem; color: #cce; font-size: 0.95rem;
|
|
font-weight: 600; }
|
|
#modal p { margin: 0.5rem 0; }
|
|
#modal ol { padding-left: 1.4rem; margin: 0.5rem 0; }
|
|
#modal li { margin: 0.6rem 0; }
|
|
#modal .button-tag { display: inline-block; padding: 0.05rem 0.4rem;
|
|
background: #2d5; color: #053; border-radius: 3px;
|
|
font-weight: 600; font-size: 0.85rem; }
|
|
#modal-close { margin-top: 1.4rem; width: 100%; padding: 0.7rem;
|
|
background: #2d5; color: #053; font-weight: 600;
|
|
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
|
|
font-size: 0.95rem; }
|
|
#modal-close:hover { background: #3e6; }
|
|
|
|
/* User badge in header */
|
|
#user-badge { background: #2a3; color: #042; font-weight: 700;
|
|
padding: 0.2rem 0.6rem; border-radius: 12px;
|
|
font-family: ui-monospace, monospace; font-size: 0.85rem;
|
|
cursor: pointer; user-select: none; }
|
|
#user-badge:hover { background: #3b4; }
|
|
#user-badge.empty { background: #d84; color: #311; }
|
|
|
|
/* Login modal — narrower than the welcome modal */
|
|
#login-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
|
display: none; align-items: center; justify-content: center;
|
|
z-index: 200; }
|
|
#login-backdrop.show { display: flex; }
|
|
#login-box { background: #222; border: 1px solid #444; border-radius: 6px;
|
|
padding: 1.6rem 2rem; max-width: 380px; width: 90%;
|
|
color: #ddd; }
|
|
#login-box h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.1rem; }
|
|
#login-box p { margin: 0.4rem 0 1rem; color: #aab; font-size: 0.9rem; }
|
|
#login-input { width: 100%; padding: 0.6rem 0.8rem; font-size: 1.1rem;
|
|
background: #111; color: #fff; border: 1px solid #444;
|
|
border-radius: 4px; text-align: center;
|
|
font-family: ui-monospace, monospace; letter-spacing: 0.2em;
|
|
text-transform: uppercase; }
|
|
#login-input:focus { outline: none; border-color: #6c5; }
|
|
#login-submit { width: 100%; margin-top: 1rem; padding: 0.7rem;
|
|
background: #2d5; color: #053; font-weight: 600;
|
|
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
|
|
font-size: 0.95rem; }
|
|
#login-submit:hover { background: #3e6; }
|
|
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; }
|
|
|
|
/* Annotation bar (existing-mark indicator below the video) */
|
|
#annotation-bar { position: relative; width: 100%; max-width: 1400px;
|
|
height: 18px; background: #555; border: 1px solid #666;
|
|
border-radius: 3px; margin: 0.5rem 0 2rem;
|
|
cursor: pointer; }
|
|
#annotation-bar.empty { background: transparent; border-color: transparent;
|
|
cursor: default; height: 0; margin: 0; }
|
|
#annotation-bar .mark-line { position: absolute; top: -2px; bottom: -2px;
|
|
width: 3px; background: #f44;
|
|
box-shadow: 0 0 4px rgba(255,80,80,0.7); }
|
|
#annotation-bar .mark-line:hover { background: #f88; }
|
|
#annotation-bar .mark-tooltip { position: absolute; top: 100%;
|
|
transform: translateX(-50%);
|
|
margin-top: 4px; background: #f44;
|
|
color: white; padding: 2px 6px;
|
|
font-size: 0.75rem; border-radius: 3px;
|
|
font-family: ui-monospace, monospace;
|
|
white-space: nowrap; pointer-events: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Cupido — barrier picker</h1>
|
|
<span id="info">loading…</span>
|
|
<span id="progress"></span>
|
|
<span id="user-badge" title="click to change initials">…</span>
|
|
<button id="help-btn" title="show the help modal">?</button>
|
|
</header>
|
|
|
|
<div id="login-backdrop">
|
|
<div id="login-box">
|
|
<h2>Who are you?</h2>
|
|
<p>Enter your initials so we can record who annotated each video.
|
|
(Just letters, e.g. <code>GG</code>.)</p>
|
|
<input id="login-input" maxlength="4" autocomplete="off" autofocus />
|
|
<button id="login-submit" disabled>Continue</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal-backdrop">
|
|
<div id="modal">
|
|
<h2>Welcome to the Cupido barrier picker</h2>
|
|
<p>For each tracked video you'll mark the moment the barrier between
|
|
the two halves of the arena is removed and the flies start interacting.
|
|
That timestamp is what every downstream "post-opening" analysis needs.</p>
|
|
|
|
<h3>How to annotate one video</h3>
|
|
<ol>
|
|
<li><strong>Drag the seekbar to the end of the video</strong> and check
|
|
whether <em>all</em> ROIs are open or only the upper or lower half.
|
|
This determines which button you'll click later.</li>
|
|
|
|
<li><strong>Drag back toward the beginning</strong> to roughly find
|
|
the moment the barrier(s) lift. The opening can be anywhere from a
|
|
few seconds to several minutes in.</li>
|
|
|
|
<li><strong>Use the arrow keys</strong> to pin down the exact frame:
|
|
<kbd>←</kbd>/<kbd>→</kbd> jump ±5 s, <kbd>Shift</kbd>+arrow jump ±30 s.
|
|
Pause the player on the opening frame.</li>
|
|
|
|
<li><strong>Click the button</strong> matching what opened — the
|
|
playhead time is recorded, ROI inclusion is set accordingly, and
|
|
the next video loads automatically.</li>
|
|
</ol>
|
|
|
|
<h3>The three buttons</h3>
|
|
<p>
|
|
<span class="button-tag">All barriers open</span> — every ROI usable<br>
|
|
<span class="button-tag">Upper barrier opens</span> — only ROIs 1, 3, 5 (top row); lower row will be excluded from analysis<br>
|
|
<span class="button-tag">Lower barrier opens</span> — only ROIs 2, 4, 6 (bottom row); upper row excluded
|
|
</p>
|
|
|
|
<h3>Other controls</h3>
|
|
<p>
|
|
<kbd>Space</kbd> play/pause ·
|
|
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons ·
|
|
<kbd>s</kbd> skip · <kbd>u</kbd> mark unusable
|
|
</p>
|
|
<p style="font-size: 0.85rem; color: #889;">Each click saves to
|
|
<code>barrier_opening.csv</code> immediately, so closing the tab
|
|
and coming back resumes from where you left off.</p>
|
|
|
|
<button id="modal-close">Got it — let's start</button>
|
|
</div>
|
|
</div>
|
|
|
|
<main>
|
|
<div id="meta"></div>
|
|
<video id="player" controls preload="auto"></video>
|
|
<div id="annotation-bar" class="empty"></div>
|
|
<div id="controls">
|
|
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button>
|
|
<button class="primary" data-mode="upper">Upper barrier opens <kbd>2</kbd></button>
|
|
<button class="primary" data-mode="lower">Lower barrier opens <kbd>3</kbd></button>
|
|
<span class="mute-divider"></span>
|
|
<button class="muted" data-mode="skip">Skip <kbd>s</kbd></button>
|
|
<button class="warn" data-mode="unusable">Unusable <kbd>u</kbd></button>
|
|
<span class="mute-divider"></span>
|
|
<button class="muted" id="prev">◀ Previous</button>
|
|
<button class="muted" id="next">Next ▶</button>
|
|
</div>
|
|
<div id="help">
|
|
<kbd>Space</kbd> play/pause ·
|
|
<kbd>←</kbd> / <kbd>→</kbd> ±5 s ·
|
|
<kbd>Shift</kbd>+arrows ±30 s ·
|
|
drag the seekbar for finer control
|
|
</div>
|
|
<div id="flash"></div>
|
|
</main>
|
|
|
|
<script>
|
|
const player = document.getElementById('player');
|
|
const info = document.getElementById('info');
|
|
const meta = document.getElementById('meta');
|
|
const progress = document.getElementById('progress');
|
|
const flash = document.getElementById('flash');
|
|
const userBadge = document.getElementById('user-badge');
|
|
const loginBackdrop = document.getElementById('login-backdrop');
|
|
const loginInput = document.getElementById('login-input');
|
|
const loginSubmit = document.getElementById('login-submit');
|
|
|
|
// ─── Analyst identity (persisted in localStorage) ───────────────────
|
|
function getAnalyst() {
|
|
try { return localStorage.getItem('cupido.analyst') || ''; }
|
|
catch (e) { return ''; }
|
|
}
|
|
function setAnalyst(s) {
|
|
try { localStorage.setItem('cupido.analyst', s); } catch (e) {}
|
|
renderUserBadge();
|
|
}
|
|
function renderUserBadge() {
|
|
const a = getAnalyst();
|
|
userBadge.textContent = a || 'sign in';
|
|
userBadge.classList.toggle('empty', !a);
|
|
}
|
|
|
|
function showLogin() {
|
|
loginBackdrop.classList.add('show');
|
|
loginInput.value = getAnalyst();
|
|
loginSubmit.disabled = !loginInput.value.trim();
|
|
setTimeout(() => loginInput.focus(), 50);
|
|
}
|
|
function hideLogin() { loginBackdrop.classList.remove('show'); }
|
|
|
|
function submitLogin() {
|
|
const v = loginInput.value.trim().toUpperCase();
|
|
if (!v) return;
|
|
setAnalyst(v);
|
|
hideLogin();
|
|
// After first login, show the welcome modal if not yet seen.
|
|
try {
|
|
if (localStorage.getItem('cupido.welcomed') !== '1') {
|
|
showModal(); // welcome modal — defined below
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
loginInput.addEventListener('input', () => {
|
|
loginSubmit.disabled = !loginInput.value.trim();
|
|
});
|
|
loginInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && loginInput.value.trim()) submitLogin();
|
|
});
|
|
loginSubmit.addEventListener('click', submitLogin);
|
|
userBadge.addEventListener('click', showLogin);
|
|
|
|
let queue = [];
|
|
let cursor = 0;
|
|
|
|
function showFlash(msg, kind = 'ok') {
|
|
flash.textContent = msg;
|
|
flash.className = 'show ' + kind;
|
|
setTimeout(() => { flash.className = ''; }, 1800);
|
|
}
|
|
|
|
function updateProgress() {
|
|
const done = queue.filter(q => q.done).length;
|
|
progress.textContent = `${done}/${queue.length} done`;
|
|
}
|
|
|
|
// Reason: the CSV stores `bad_rois` (those that didn't open). The
|
|
// user-facing label flips this to the positive sense — "only ROIs X"
|
|
// are the usable ones. Empty bad_rois means all ROIs are usable.
|
|
function onlyRoisFromBad(badStr) {
|
|
if (!badStr) return '';
|
|
const bad = new Set(String(badStr).split(',').map(s => parseInt(s.trim())));
|
|
return [1,2,3,4,5,6].filter(r => !bad.has(r)).join(',');
|
|
}
|
|
|
|
function renderAnnotationBar(item) {
|
|
const bar = document.getElementById('annotation-bar');
|
|
bar.innerHTML = '';
|
|
const dur = item.duration_s;
|
|
const ex = item.existing;
|
|
// Always allow click-to-seek if we have a duration; only show as a real
|
|
// bar (with marks) when there's an existing annotation to display.
|
|
if (!dur) {
|
|
bar.classList.add('empty');
|
|
bar.onclick = null;
|
|
return;
|
|
}
|
|
if (!ex || ex.opening_s == null || isNaN(ex.opening_s)) {
|
|
bar.classList.add('empty');
|
|
bar.onclick = null;
|
|
return;
|
|
}
|
|
bar.classList.remove('empty');
|
|
// Click anywhere on the bar = seek to that fractional position.
|
|
bar.onclick = (e) => {
|
|
const rect = bar.getBoundingClientRect();
|
|
const t = (e.clientX - rect.left) / rect.width * dur;
|
|
player.currentTime = Math.max(0, Math.min(dur, t));
|
|
};
|
|
// Mark line at opening_s
|
|
const pct = (ex.opening_s / dur) * 100;
|
|
const line = document.createElement('div');
|
|
line.className = 'mark-line';
|
|
line.style.left = `calc(${pct}% - 1px)`;
|
|
// Click on the line specifically = snap to exact opening_s
|
|
line.onclick = (e) => {
|
|
e.stopPropagation();
|
|
player.currentTime = Math.max(0, Math.min(dur, ex.opening_s));
|
|
};
|
|
const tooltip = document.createElement('div');
|
|
tooltip.className = 'mark-tooltip';
|
|
const m = Math.floor(ex.opening_s / 60);
|
|
const s = (ex.opening_s % 60).toFixed(1);
|
|
const only = onlyRoisFromBad(ex.bad_rois);
|
|
const tag = only ? ` · only ROIs ${only}` : '';
|
|
const who = ex.analyst ? ` · ${ex.analyst}` : '';
|
|
tooltip.textContent = `${m}:${s.padStart(4,'0')}${who}${tag}`;
|
|
// Position tooltip; if mark is near right edge, anchor right.
|
|
tooltip.style.left = pct < 90 ? `${pct}%` : `${pct}%`;
|
|
if (pct < 8) tooltip.style.transform = 'translateX(0)';
|
|
else if (pct > 92) tooltip.style.transform = 'translateX(-100%)';
|
|
bar.appendChild(line);
|
|
bar.appendChild(tooltip);
|
|
}
|
|
|
|
function renderMeta(m) {
|
|
meta.innerHTML = '';
|
|
if (!m) return;
|
|
const fields = [
|
|
['role', m.session_role,
|
|
m.session_role === 'training' ? 'role-training'
|
|
: m.session_role === 'testing' ? 'role-testing' : ''],
|
|
['species', m.species],
|
|
['memory', m.memory],
|
|
['training (hr)', m.training_length_hr],
|
|
['consol. (hr)', m.consolidation_length_hr],
|
|
['age (d)', m.age],
|
|
['flies', (m.n_trained || m.n_naive)
|
|
? `${m.n_trained || 0} trained · ${m.n_naive || 0} naive`
|
|
: null],
|
|
['training time', m.training_date_time],
|
|
['testing time', m.testing_date_time],
|
|
];
|
|
for (const [label, value, cls] of fields) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
const span = document.createElement('span');
|
|
span.className = 'pair';
|
|
span.innerHTML = `<span class="k">${label}:</span><span class="v ${cls||''}">${value}</span>`;
|
|
meta.appendChild(span);
|
|
}
|
|
}
|
|
|
|
function loadCursor() {
|
|
if (queue.length === 0) {
|
|
info.textContent = 'queue empty';
|
|
return;
|
|
}
|
|
cursor = ((cursor % queue.length) + queue.length) % queue.length;
|
|
const item = queue[cursor];
|
|
info.textContent =
|
|
`[${cursor + 1}/${queue.length}] ${item.machine_name} ${item.session_date} ${item.session_time} ` +
|
|
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
|
|
(item.done ? ' — already done' : '');
|
|
renderMeta(item.metadata);
|
|
renderAnnotationBar(item);
|
|
player.src = `/api/video/${item.idx}`;
|
|
player.load();
|
|
}
|
|
|
|
async function fetchQueue() {
|
|
const resp = await fetch('/api/queue');
|
|
queue = await resp.json();
|
|
// Jump to first not-yet-done
|
|
const firstUndone = queue.findIndex(q => !q.done);
|
|
cursor = firstUndone === -1 ? 0 : firstUndone;
|
|
updateProgress();
|
|
loadCursor();
|
|
}
|
|
|
|
async function submit(mode) {
|
|
if (queue.length === 0) return;
|
|
// Require initials before any picking action (skip is OK without).
|
|
if (mode !== 'skip' && !getAnalyst()) {
|
|
showLogin();
|
|
return;
|
|
}
|
|
const item = queue[cursor];
|
|
const payload = {
|
|
idx: item.idx,
|
|
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
|
|
mode: mode,
|
|
analyst: getAnalyst(),
|
|
notes: '',
|
|
};
|
|
try {
|
|
const resp = await fetch('/api/submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
showFlash('error: ' + (err.detail || resp.status), 'err');
|
|
return;
|
|
}
|
|
const result = await resp.json();
|
|
if (result.status === 'saved') {
|
|
item.done = true;
|
|
updateProgress();
|
|
const t = result.row.opening_s;
|
|
const only = onlyRoisFromBad(result.row.bad_rois);
|
|
showFlash(
|
|
`saved ${item.machine_name} ${item.session_date} ${item.session_time}: ` +
|
|
(Number.isNaN(t) || t === null ? 'unusable' :
|
|
`${t.toFixed(1)}s${only ? ' (only ROIs ' + only + ')' : ''}`)
|
|
);
|
|
} else if (result.status === 'skipped') {
|
|
showFlash(`skipped ${item.machine_name} ${item.session_date} ${item.session_time}`);
|
|
}
|
|
cursor = (cursor + 1) % queue.length;
|
|
loadCursor();
|
|
} catch (e) {
|
|
showFlash('network error: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
// Button handlers
|
|
document.querySelectorAll('button[data-mode]').forEach(btn => {
|
|
btn.addEventListener('click', () => submit(btn.dataset.mode));
|
|
});
|
|
document.getElementById('prev').addEventListener('click', () => {
|
|
cursor = (cursor - 1 + queue.length) % queue.length;
|
|
loadCursor();
|
|
});
|
|
document.getElementById('next').addEventListener('click', () => {
|
|
cursor = (cursor + 1) % queue.length;
|
|
loadCursor();
|
|
});
|
|
|
|
// Keyboard shortcuts.
|
|
// Reason: register on the *capture* phase so we run before the native
|
|
// <video> element's built-in handlers, which otherwise also seek by
|
|
// their own amount and double the actual jump.
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
// Don't react to picker shortcuts while either modal is open.
|
|
if (modalBackdrop.classList.contains('show')) return;
|
|
if (loginBackdrop.classList.contains('show')) return;
|
|
// Prevent the browser default (e.g. video focus side effects on space).
|
|
const stop = () => { e.preventDefault(); e.stopPropagation(); };
|
|
switch (e.key) {
|
|
case ' ':
|
|
stop();
|
|
if (player.paused) player.play(); else player.pause();
|
|
break;
|
|
case 'ArrowLeft':
|
|
stop();
|
|
player.currentTime -= e.shiftKey ? 30 : 5;
|
|
break;
|
|
case 'ArrowRight':
|
|
stop();
|
|
player.currentTime += e.shiftKey ? 30 : 5;
|
|
break;
|
|
case '1': stop(); submit('all'); break;
|
|
case '2': stop(); submit('upper'); break;
|
|
case '3': stop(); submit('lower'); break;
|
|
case 's': stop(); submit('skip'); break;
|
|
case 'u': stop(); submit('unusable'); break;
|
|
}
|
|
}, true); // capture phase — beats the native <video> keyboard handler
|
|
|
|
// After clicking the seekbar (or anywhere on the video), the player
|
|
// keeps keyboard focus and intercepts our arrow-key shortcuts. Blur it
|
|
// on mouseup so subsequent arrow presses go to our document-level
|
|
// handler unambiguously.
|
|
player.addEventListener('mouseup', () => player.blur());
|
|
|
|
// Welcome modal — show first time, dismissable; ? button reopens it.
|
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
|
const modalClose = document.getElementById('modal-close');
|
|
const helpBtn = document.getElementById('help-btn');
|
|
|
|
function showModal() { modalBackdrop.classList.add('show'); }
|
|
function hideModal() {
|
|
modalBackdrop.classList.remove('show');
|
|
try { localStorage.setItem('cupido.welcomed', '1'); } catch (e) {}
|
|
}
|
|
modalClose.addEventListener('click', hideModal);
|
|
helpBtn.addEventListener('click', showModal);
|
|
modalBackdrop.addEventListener('click', (e) => {
|
|
if (e.target === modalBackdrop) hideModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
|
|
e.stopPropagation(); hideModal();
|
|
}
|
|
});
|
|
|
|
// On first visit, login first (mandatory) then welcome modal.
|
|
renderUserBadge();
|
|
if (!getAnalyst()) {
|
|
showLogin();
|
|
} else {
|
|
let welcomed = false;
|
|
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
|
|
if (!welcomed) showModal();
|
|
}
|
|
|
|
fetchQueue();
|
|
</script>
|
|
</body>
|
|
</html>
|