diff --git a/frontend/src/lib/diashow/queue.ts b/frontend/src/lib/diashow/queue.ts new file mode 100644 index 0000000..433110f --- /dev/null +++ b/frontend/src/lib/diashow/queue.ts @@ -0,0 +1,106 @@ +// Two-queue slide state machine. Pure logic — no DOM, no Svelte, no network. Lets us +// unit-test the policy without spinning up a browser. +// +// Policy: live posts always drain first; the shuffle queue refills from `allKnown` +// (minus the most recent N items) once it empties. A new live post pushes onto the +// live queue and waits for the next slide transition — it never interrupts the +// current slide. + +import type { FeedUpload } from '$lib/types'; + +const RECENT_RING_SIZE = 5; + +export class SlideQueue { + /** Live queue — FIFO. New uploads land here via `pushLive`. */ + private liveQueue: FeedUpload[] = []; + /** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */ + private shuffleQueue: FeedUpload[] = []; + /** Every slide we've ever seen, keyed by id. Source for shuffle refills. */ + private allKnown: Map = new Map(); + /** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */ + private recentlyShown: string[] = []; + + /** Seed the shuffle pool from an initial fetch of the feed. */ + seed(initial: FeedUpload[]): void { + for (const slide of initial) { + this.allKnown.set(slide.id, slide); + } + this.shuffleQueue = shuffle(Array.from(this.allKnown.values())); + } + + /** Add a slide pushed by a new SSE upload-processed event. */ + pushLive(slide: FeedUpload): void { + if (this.allKnown.has(slide.id)) return; + this.allKnown.set(slide.id, slide); + this.liveQueue.push(slide); + } + + /** Pop the next slide. Returns null while both queues are empty (event has no posts). */ + next(): FeedUpload | null { + // 1. Drain live first. + const live = this.liveQueue.shift(); + if (live) { + this.markShown(live.id); + return live; + } + // 2. Refill shuffle queue from `allKnown` minus recently shown. + if (this.shuffleQueue.length === 0) { + this.shuffleQueue = shuffle( + Array.from(this.allKnown.values()).filter( + (s) => !this.recentlyShown.includes(s.id) + ) + ); + // If everything is recently shown (small event), fall back to the full pool. + if (this.shuffleQueue.length === 0) { + this.shuffleQueue = shuffle(Array.from(this.allKnown.values())); + } + } + const next = this.shuffleQueue.shift() ?? null; + if (next) this.markShown(next.id); + return next; + } + + /** + * Remove a slide that was deleted or hidden. Returns true if it was the current + * "head" and the caller should advance immediately (UX: don't keep showing a + * post that the host just deleted). + */ + remove(id: string, currentId: string | null): { wasCurrent: boolean } { + this.allKnown.delete(id); + this.liveQueue = this.liveQueue.filter((s) => s.id !== id); + this.shuffleQueue = this.shuffleQueue.filter((s) => s.id !== id); + this.recentlyShown = this.recentlyShown.filter((sid) => sid !== id); + return { wasCurrent: currentId === id }; + } + + /** Look up a slide by id — for the diashow page to render the current slide. */ + get(id: string): FeedUpload | undefined { + return this.allKnown.get(id); + } + + /** Snapshot — useful for stats / debugging. */ + stats() { + return { + known: this.allKnown.size, + live: this.liveQueue.length, + shuffle: this.shuffleQueue.length + }; + } + + private markShown(id: string): void { + this.recentlyShown.push(id); + while (this.recentlyShown.length > RECENT_RING_SIZE) { + this.recentlyShown.shift(); + } + } +} + +/** Fisher–Yates. Mutates a copy of the input so callers can pass `allKnown.values()`. */ +function shuffle(arr: T[]): T[] { + const out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} diff --git a/frontend/src/lib/diashow/transitions/crossfade.svelte b/frontend/src/lib/diashow/transitions/crossfade.svelte new file mode 100644 index 0000000..6073ef7 --- /dev/null +++ b/frontend/src/lib/diashow/transitions/crossfade.svelte @@ -0,0 +1,38 @@ + + +
+ {#if isVideo} + + + {:else} + + {/if} +
+ + diff --git a/frontend/src/lib/diashow/transitions/index.ts b/frontend/src/lib/diashow/transitions/index.ts new file mode 100644 index 0000000..026426c --- /dev/null +++ b/frontend/src/lib/diashow/transitions/index.ts @@ -0,0 +1,47 @@ +// Pluggable transition registry. The diashow consults this list to populate its +// settings popover and to render the current transition. +// +// Adding a new animation: +// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte. +// 2. Add one entry to `transitions` below. +// 3. Rebuild — the popover updates automatically. +// +// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete: +// no diashow code needs to change to add an animation. + +import type { Component } from 'svelte'; +import Crossfade from './crossfade.svelte'; +import KenBurns from './kenburns.svelte'; + +export interface SlideTransition { + id: string; + label: string; + defaultDurationMs: number; + component: Component; +} + +/** Props every transition Svelte component receives. */ +export interface TransitionProps { + src: string; + isVideo: boolean; + durationMs: number; +} + +export const transitions: SlideTransition[] = [ + { + id: 'crossfade', + label: 'Überblendung', + defaultDurationMs: 400, + component: Crossfade as unknown as Component + }, + { + id: 'kenburns', + label: 'Ken Burns', + defaultDurationMs: 600, + component: KenBurns as unknown as Component + } +]; + +export function findTransition(id: string): SlideTransition { + return transitions.find((t) => t.id === id) ?? transitions[0]; +} diff --git a/frontend/src/lib/diashow/transitions/kenburns.svelte b/frontend/src/lib/diashow/transitions/kenburns.svelte new file mode 100644 index 0000000..0050d71 --- /dev/null +++ b/frontend/src/lib/diashow/transitions/kenburns.svelte @@ -0,0 +1,51 @@ + + +
+ {#if isVideo} + + + {:else} + + {/if} +
+ + diff --git a/frontend/src/lib/diashow/wakelock.ts b/frontend/src/lib/diashow/wakelock.ts new file mode 100644 index 0000000..fcc2bac --- /dev/null +++ b/frontend/src/lib/diashow/wakelock.ts @@ -0,0 +1,54 @@ +// Thin wrapper around the Screen Wake Lock API. Held by the diashow page so a phone +// driving a projector doesn't sleep. No-op on unsupported browsers (Firefox, older +// Safari). +// +// Wake locks die when the document goes hidden; the page re-acquires on visible to +// keep the screen on across short interruptions. + +interface SentinelLike { + release: () => 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; + try { + sentinel = await wakeLock.request('screen'); + } catch { + // User denied, or already released — nothing useful to do. + sentinel = null; + } + + // Re-acquire when the page becomes visible again (the OS releases the lock + // while the tab is hidden). + if (!visibilityHandler) { + visibilityHandler = async () => { + if (document.visibilityState === 'visible' && sentinel === null) { + try { + sentinel = await wakeLock.request('screen'); + } catch { + sentinel = null; + } + } + }; + document.addEventListener('visibilitychange', visibilityHandler); + } +} + +export async function releaseWakeLock(): Promise { + if (sentinel) { + try { + await sentinel.release(); + } catch { + // ignore — release after release is fine + } + sentinel = null; + } + if (visibilityHandler) { + document.removeEventListener('visibilitychange', visibilityHandler); + visibilityHandler = null; + } +} diff --git a/frontend/src/routes/diashow/+page.svelte b/frontend/src/routes/diashow/+page.svelte new file mode 100644 index 0000000..e8d5bd6 --- /dev/null +++ b/frontend/src/routes/diashow/+page.svelte @@ -0,0 +1,223 @@ + + + + +