diff --git a/frontend/src/lib/diashow/transitions/crossfade.svelte b/frontend/src/lib/diashow/transitions/crossfade.svelte index eefe71f..27969c6 100644 --- a/frontend/src/lib/diashow/transitions/crossfade.svelte +++ b/frontend/src/lib/diashow/transitions/crossfade.svelte @@ -14,9 +14,13 @@ let { src, isVideo, durationMs, onended }: Props = $props(); +
{#if isVideo} @@ -27,6 +31,9 @@
diff --git a/frontend/src/lib/diashow/wakelock.ts b/frontend/src/lib/diashow/wakelock.ts index 4185cdc..a47dac3 100644 --- a/frontend/src/lib/diashow/wakelock.ts +++ b/frontend/src/lib/diashow/wakelock.ts @@ -7,33 +7,43 @@ interface SentinelLike { release: () => Promise; + // WakeLockSentinel is an EventTarget; it fires `release` when the lock ends — including + // when the OS drops it because the tab was hidden. + addEventListener?: (type: 'release', listener: () => void) => void; } +type WakeLock = { request: (t: string) => Promise }; + let sentinel: SentinelLike | null = null; let visibilityHandler: (() => void) | null = null; -export async function acquireWakeLock(): Promise { - const wakeLock = ( - navigator as Navigator & { wakeLock?: { request: (t: string) => Promise } } - ).wakeLock; - if (!wakeLock) return; +async function request(wakeLock: WakeLock): Promise { try { sentinel = await wakeLock.request('screen'); + // The OS auto-releases the lock when the tab is hidden. Without clearing our handle + // on that event, the visibility handler's `sentinel === null` guard would never be + // true and the lock would never be re-acquired — the screen could then sleep after + // the first time the tab lost focus. Null it on release so re-acquire can fire. + sentinel.addEventListener?.('release', () => { + sentinel = null; + }); } catch { // User denied, or already released — nothing useful to do. sentinel = null; } +} + +export async function acquireWakeLock(): Promise { + const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock; + if (!wakeLock) return; + await request(wakeLock); // Re-acquire when the page becomes visible again (the OS releases the lock // while the tab is hidden). if (!visibilityHandler) { - visibilityHandler = async () => { + visibilityHandler = () => { if (document.visibilityState === 'visible' && sentinel === null) { - try { - sentinel = await wakeLock.request('screen'); - } catch { - sentinel = null; - } + void request(wakeLock); } }; document.addEventListener('visibilitychange', visibilityHandler); diff --git a/frontend/src/routes/diashow/+page.svelte b/frontend/src/routes/diashow/+page.svelte index 1ed41ff..3c4b821 100644 --- a/frontend/src/routes/diashow/+page.svelte +++ b/frontend/src/routes/diashow/+page.svelte @@ -15,17 +15,36 @@ let queue = new SlideQueue(); let current = $state(null); + // Two-layer crossfade. Previously the diashow rendered only `current` inside a + // `{#key current.id}` block, so on every advance Svelte destroyed the old slide the + // instant the id changed and mounted the new one starting at opacity 0 over the black + // backdrop — the old image vanished to black, then the new one faded up (and only + // popped in once it decoded). That's the "black flash". Instead we keep the outgoing + // slide fully opaque *underneath* the incoming one while it fades in, then drop it — + // there's always an opaque frame on screen, so the black never shows through. + type SlideLayer = { key: string; src: string; isVideo: boolean; phase: 'enter' | 'exit' }; + let layers = $state([]); + let dropPrevTimer: ReturnType | null = null; + // Guards the async (decode-gated) layer commit against a newer advance superseding it. + let slideToken = 0; let dwellMs = $state(6000); let transitionId = $state('crossfade'); let paused = $state(false); let showOverlay = $state(false); - let overlayHideTimer: ReturnType | null = null; let advanceTimer: ReturnType | null = null; const unsubs: Array<() => void> = []; + // Controls auto-hide: the top-right cluster shows on pointer/keyboard activity and fades + // after a few seconds idle (the cursor hides with it) so a projector shows only photos. + let controlsVisible = $state(true); + let controlsHideTimer: ReturnType | null = null; + // Fullscreen toggle via the Fullscreen API — an in-app button, independent of the + // browser's own F11 chrome. `isFullscreen` mirrors the actual state via fullscreenchange. + let isFullscreen = $state(false); + let rootEl: HTMLDivElement | undefined = $state(); + const transitionDef = $derived(findTransition(transitionId)); - const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : ''); const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false); function isEmpty(): boolean { @@ -49,10 +68,63 @@ function advance() { const next = queue.next(); - current = next; + showSlide(next); if (current) scheduleNext(); } + // Swap in the next slide as the top layer while holding the outgoing one beneath it + // for the length of the transition. For images we decode the incoming frame first so + // the fade reveals a painted picture rather than a blank one; the outgoing slide stays + // visible until then, so there's never a black gap. Videos can't be pre-decoded, so + // they swap in immediately. + function showSlide(next: FeedUpload | null) { + const token = ++slideToken; + if (dropPrevTimer) { + clearTimeout(dropPrevTimer); + dropPrevTimer = null; + } + current = next; + if (!next) { + layers = []; + return; + } + const slide: SlideLayer = { + key: next.id, + src: pickMediaUrl($dataMode, next), + isVideo: next.mime_type.startsWith('video/'), + phase: 'enter' + }; + const prev = layers.length ? layers[layers.length - 1] : null; + // Re-showing the same slide (e.g. a feed re-fetch resolved to the current head) — + // just replace it; never stack a slide on top of itself. + if (prev && prev.key === slide.key) { + layers = [slide]; + return; + } + const commit = () => { + if (token !== slideToken) return; // superseded by a newer advance — drop this frame + // Mark the outgoing frame as exiting so slide transitions push it off in step with + // the incoming one. Crossfade/Ken Burns ignore `phase` and just hold it opaque. + const exiting = prev ? { ...prev, phase: 'exit' as const } : null; + layers = exiting ? [exiting, slide] : [slide]; + if (exiting) { + dropPrevTimer = setTimeout(() => { + dropPrevTimer = null; + layers = layers.filter((l) => l.key !== exiting.key); + }, transitionDef.defaultDurationMs + 80); + } + }; + if (slide.isVideo) { + commit(); + return; + } + const pre = new Image(); + pre.src = slide.src; + // decode() resolves once the bitmap is ready to paint; on failure just commit anyway + // (a broken image should still advance the show rather than stall it). + pre.decode().then(commit).catch(commit); + } + // A video finished before its dwell/12s cap — advance immediately (unless paused). // The {#key current.id} block destroys the old