138 lines
5.6 KiB
TypeScript
138 lines
5.6 KiB
TypeScript
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<FrameModule> => 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<Ref<boolean>>;
|
|
sceneRunning: Readonly<Ref<boolean>>;
|
|
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
|
|
drift: () => boolean;
|
|
/** Test seam: the frame chunk loader. */
|
|
load?: () => Promise<FrameModule>;
|
|
}) {
|
|
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<Component | null>(null);
|
|
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
|
|
let loading: Promise<boolean> | 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<boolean> => load().then(
|
|
(module) => { frame.value = markRaw(module.default); return true; },
|
|
() => false,
|
|
);
|
|
const prefetch = (): Promise<boolean> => {
|
|
if (frame.value) return Promise.resolve(true);
|
|
return (loading ??= attempt()
|
|
.then((ok) => ok || new Promise<boolean>((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<HTMLElement>('.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 };
|
|
}
|