94 lines
4.7 KiB
TypeScript
94 lines
4.7 KiB
TypeScript
// Only the hovered node owns a decoder; pending hover work shares the same lease.
|
|
let stopCurrentPreview: (() => void) | undefined;
|
|
export const VIDEO_HOVER_DELAY_MS = 350;
|
|
export const VIDEO_HOVER_PREVIEW_MS = 3000;
|
|
|
|
export function bindCanvasVideoHoverPreview(element: HTMLElement, resolveSource: () => Promise<string>) {
|
|
let stop: (() => void) | undefined;
|
|
const enter = (event: PointerEvent) => {
|
|
if (event.pointerType !== "mouse" || event.buttons || document.hidden
|
|
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
|| element.closest('[data-canvas-viewport-interacting="true"]')) return;
|
|
stopCurrentPreview?.();
|
|
let disposed = false;
|
|
let video: HTMLVideoElement | undefined;
|
|
let playbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const listeners = new AbortController();
|
|
const viewport = element.closest("[data-canvas-viewport]");
|
|
const visibility = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => !entry.isIntersecting)) cancel();
|
|
});
|
|
const observer = new MutationObserver(() => {
|
|
if (viewport?.getAttribute("data-canvas-viewport-interacting") === "true") cancel();
|
|
});
|
|
const cancel = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
clearTimeout(delay);
|
|
clearTimeout(deadline);
|
|
clearTimeout(playbackTimer);
|
|
listeners.abort();
|
|
observer.disconnect();
|
|
visibility.disconnect();
|
|
if (video) {
|
|
video.pause();
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
video.remove();
|
|
}
|
|
if (stopCurrentPreview === cancel) stopCurrentPreview = undefined;
|
|
if (stop === cancel) stop = undefined;
|
|
};
|
|
const delay = setTimeout(() => {
|
|
// Explicit playback wins over an incidental hover.
|
|
if (Array.from(document.querySelectorAll("video, audio")).some((media) => !(media as HTMLMediaElement).paused)) {
|
|
cancel();
|
|
return;
|
|
}
|
|
void resolveSource().then((src) => {
|
|
if (disposed) return;
|
|
if (!src || !element.isConnected || document.hidden) { cancel(); return; }
|
|
video = document.createElement("video");
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.preload = "metadata";
|
|
video.className = "pointer-events-none absolute inset-0 size-full rounded-[var(--node-radius)] bg-black object-contain";
|
|
video.setAttribute("aria-hidden", "true");
|
|
video.dataset.canvasHoverPreview = "true";
|
|
video.addEventListener("ended", cancel, { signal: listeners.signal });
|
|
video.addEventListener("error", cancel, { signal: listeners.signal });
|
|
video.addEventListener("timeupdate", () => {
|
|
if (video && video.currentTime >= VIDEO_HOVER_PREVIEW_MS / 1000) cancel();
|
|
}, { signal: listeners.signal });
|
|
video.addEventListener("playing", () => {
|
|
if (!playbackTimer) playbackTimer = setTimeout(cancel, VIDEO_HOVER_PREVIEW_MS);
|
|
}, { signal: listeners.signal });
|
|
video.src = src;
|
|
element.appendChild(video);
|
|
void video.play().catch(cancel);
|
|
}).catch(cancel);
|
|
}, VIDEO_HOVER_DELAY_MS);
|
|
// Bound stalled network/decoder sessions as well as actual playback.
|
|
const deadline = setTimeout(cancel, 8000);
|
|
stop = cancel;
|
|
stopCurrentPreview = cancel;
|
|
visibility.observe(element);
|
|
if (viewport) observer.observe(viewport, { attributes: true, attributeFilter: ["data-canvas-viewport-interacting"] });
|
|
for (const type of ["pointerdown", "wheel", "keydown", "blur"] as const) {
|
|
window.addEventListener(type, cancel, { capture: true, passive: true, signal: listeners.signal });
|
|
}
|
|
document.addEventListener("visibilitychange", cancel, { signal: listeners.signal });
|
|
document.addEventListener("play", (event) => { if (event.target !== video) cancel(); }, { capture: true, signal: listeners.signal });
|
|
};
|
|
const leave = () => stop?.();
|
|
element.addEventListener("pointerenter", enter);
|
|
element.addEventListener("pointerleave", leave);
|
|
element.addEventListener("pointercancel", leave);
|
|
return () => {
|
|
leave();
|
|
element.removeEventListener("pointerenter", enter);
|
|
element.removeEventListener("pointerleave", leave);
|
|
element.removeEventListener("pointercancel", leave);
|
|
};
|
|
}
|