import { computed, markRaw, nextTick, onMounted, onUnmounted, shallowRef, watch, type Component, type Ref } from 'vue'; import { setDashcamOpen } from '@/journey/dashcam-feed'; import { isEditableTarget } from '@/composables/useKeyboardNavigation'; const LIVE_STAGE = ".reading-stage[data-reading-stage='live']"; const RISE_MS = 400; /** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */ const NARROW = '(max-width: 767px)'; /** One retry for a failed frame chunk, then the open is abandoned. */ export const DASHCAM_RETRY_MS = 350; /** A frame that has not mounted in this long is abandoned (nothing changed). */ export const DASHCAM_LOAD_TIMEOUT_MS = 4_000; type FrameModule = { default: Component }; const importFrame = (): Promise => import('@/components/DashcamFrame.vue'); /** * DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key, * html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus * trap, capture, the shutter) loads on first open or button hover. Starts * after mount, so the server HTML carries none of it. * * Opening is two-phase so the page can never fold away without a frame on * top of it: show() asks for the frame (rendered once the chunk is in), and * only the frame's `ready` (its mount) folds the page, closes the lens and * hands it the title. A chunk that fails twice or never mounts abandons the * open with nothing changed. Anything that would leave the frame stranded * (motion withdrawn, the scene dropping out of `running`, a resize to phone * width) closes it. */ export function useDashcam(sources: { /** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */ allowed: Readonly>; sceneRunning: Readonly>; /** Fine pointer on high/medium: the lens drifts and the bars ease. */ drift: () => boolean; /** Test seam: the frame chunk loader. */ load?: () => Promise; }) { const load = sources.load ?? importFrame; /** The page is folded and the frame is mounted. */ const open = shallowRef(false); /** The frame should render (requested and loaded); `open` follows on its mount. */ const engaged = shallowRef(false); const frame = shallowRef(null); const available = computed(() => sources.allowed.value && sources.sceneRunning.value); let loading: Promise | null = null; let requested = false; let riseTimer = 0; let loadTimer = 0; const root = (): HTMLElement => document.documentElement; const narrow = (): boolean => window.matchMedia?.(NARROW).matches ?? false; const attempt = (): Promise => load().then( (module) => { frame.value = markRaw(module.default); return true; }, () => false, ); const prefetch = (): Promise => { if (frame.value) return Promise.resolve(true); return (loading ??= attempt() .then((ok) => ok || new Promise((resolve) => { window.setTimeout(() => { void attempt().then(resolve); }, DASHCAM_RETRY_MS); })) .then((ok) => { if (!ok) loading = null; return ok; })); }; /** Drops a pending or mounting open; nothing on the page has changed yet. */ const abandon = (): void => { requested = false; engaged.value = false; window.clearTimeout(loadTimer); }; const show = (): void => { if (requested || open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return; if (narrow()) return; requested = true; window.clearTimeout(loadTimer); loadTimer = window.setTimeout(() => { if (!open.value) abandon(); }, DASHCAM_LOAD_TIMEOUT_MS); void prefetch().then((ok) => { if (!requested) return; if (!ok || !available.value || narrow()) abandon(); else engaged.value = true; }); }; /** The frame has mounted: fold the page and close the lens. */ const ready = (): void => { if (!requested || open.value) return; window.clearTimeout(loadTimer); window.clearTimeout(riseTimer); open.value = true; setDashcamOpen(true, sources.drift()); root().dataset.dashcam = 'on'; }; const close = (): void => { if (!open.value) { if (requested) abandon(); return; } abandon(); open.value = false; setDashcamOpen(false, false); root().dataset.dashcam = 'rise'; riseTimer = window.setTimeout(() => { delete root().dataset.dashcam; }, RISE_MS); void nextTick(() => document.querySelector('.drive-controls__dashcam')?.focus()); }; const onKeydown = (event: KeyboardEvent): void => { if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return; const active = open.value || requested; if (active && event.key === 'Escape') { event.preventDefault(); close(); } else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') { if (active) close(); else show(); if (active || requested) event.preventDefault(); } }; const onResize = (): void => { if ((open.value || requested) && narrow()) close(); }; watch(available, (ok) => { if (!ok) close(); }); onMounted(() => { window.addEventListener('keydown', onKeydown); window.addEventListener('resize', onResize, { passive: true }); }); onUnmounted(() => { window.removeEventListener('keydown', onKeydown); window.removeEventListener('resize', onResize); window.clearTimeout(riseTimer); window.clearTimeout(loadTimer); if (open.value) setDashcamOpen(false, false); delete root().dataset.dashcam; }); return { open, engaged, frame, available, show, ready, close, prefetch }; }