feat: dashcam photo mode with telemetry frame, lens drift and postcard capture

This commit is contained in:
2026-10-11 07:49:56 +03:00
parent d71b0ecfd4
commit 568b0802b4
29 changed files with 1472 additions and 7 deletions

View File

@@ -0,0 +1,77 @@
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)';
/**
* 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.
*/
export function useDashcam(sources: {
/** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */
allowed: Readonly<Ref<boolean>>;
sceneRunning: Readonly<Ref<boolean>>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
drift: () => boolean;
}) {
const open = shallowRef(false);
const frame = shallowRef<Component | null>(null);
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
let loading: Promise<void> | null = null;
let riseTimer = 0;
const root = (): HTMLElement => document.documentElement;
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then(
(module) => { frame.value = markRaw(module.default); },
() => { loading = null; },
));
const show = (): void => {
if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
if (window.matchMedia?.(NARROW).matches) return;
window.clearTimeout(riseTimer);
open.value = true;
setDashcamOpen(true, sources.drift());
root().dataset.dashcam = 'on';
void prefetch();
};
const close = (): void => {
if (!open.value) return;
open.value = false;
setDashcamOpen(false, false);
root().dataset.dashcam = 'rise';
riseTimer = window.setTimeout(() => { delete root().dataset.dashcam; }, RISE_MS);
void nextTick(() => document.querySelector<HTMLElement>('.drive-controls__dashcam')?.focus());
};
const onKeydown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
if (open.value && event.key === 'Escape') {
event.preventDefault();
close();
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
const wasOpen = open.value;
if (wasOpen) close();
else show();
if (wasOpen || open.value) event.preventDefault();
}
};
watch(sources.allowed, (allowed) => { if (!allowed) close(); });
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
window.clearTimeout(riseTimer);
if (open.value) setDashcamOpen(false, false);
delete root().dataset.dashcam;
});
return { open, frame, available, show, close, prefetch };
}