// Section navigation from the keyboard (J/K/U/I/O) and from media keys — // headset buttons, AirPods, the lock screen — via the Media Session API. // // J/K = previous/next section in time order, U/I = up/down the loudest-first // ranking, O = open the current clip in the full file. Holding Shift jumps to // the extreme in that direction: Shift+J/K = first/last section in time, // Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue // armed, the keys step the playhead of the open full-file player instead. import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js'; import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js'; import {activePlayerIdx, setCutFields} from './player.js'; import {sectionMap, fileByIdx} from './store.js'; import {getPreroll} from './settings.js'; export function navigate(dir, byScore, jump) { if (hasQueue()) { stepClip(dir, byScore, jump); return; } // Per-file in-player navigation (full-file listening, no clip queue) const idx = activePlayerIdx; if (idx === null) return; const all = sectionMap.get(idx) || []; const audio = document.getElementById('aud-' + idx); if (!all.length || !audio) return; const preroll = getPreroll(); const cur = audio.currentTime; const epoch = fileStartEpoch(fileByIdx(idx)?.date); const jumpTo = (s, i, n, word) => { audio.currentTime = Math.max(0, s.start - preroll); setCutFields(idx, s.start, s.end); const when = epoch != null ? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}` : `${fmtDur(s.start)} to ${fmtDur(s.end)}`; announce(`${word} ${i + 1} of ${n}: ${when}`); }; if (byScore) { // U/I walk the ranking; the anchor is the section the playhead sits in // (incl. its preroll lead-in). Anywhere else, I starts at the loudest. // Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I). const order = scoreOrder(all); const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5); const pos = curIdx === -1 ? -1 : order.indexOf(curIdx); const next = nextInOrder(order, pos, dir, jump); if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; } jumpTo(all[order[next]], next, order.length, 'Highlight'); return; } // Shift+J/K jump to the first/last section regardless of the playhead. if (jump) { const i = dir < 0 ? 0 : all.length - 1; jumpTo(all[i], i, all.length, 'Section'); return; } if (dir < 0) { for (let i = all.length - 1; i >= 0; i--) if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; } announce('Beginning of sections'); } else { for (let i = 0; i < all.length; i++) if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; } announce('End of sections'); } } export function initNav() { document.addEventListener('keydown', e => { const t = e.target; if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return; if (e.ctrlKey || e.metaKey || e.altKey) return; const key = e.key.toLowerCase(); if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return; e.preventDefault(); if (key === 'o') { openClipInFile(); return; } navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey); }); // Previous/next track = J/K. Works from AirPods, the iOS lock screen and // Control Center, and the media keys on a Windows keyboard. if ('mediaSession' in navigator) { for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) { try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); } catch { /* action not supported here */ } } } }