feat(diashow): live slideshow with two-queue policy + pluggable transitions

A fullscreen auto-advancing slideshow any user can start. Design:
docs/CONCEPT_DIASHOW.md.

- lib/diashow/queue.ts: SlideQueue state machine — liveQueue drains first
  (FIFO, seeded by SSE upload-processed), then shuffleQueue (refilled
  from allKnown minus a 5-id ring buffer of recently shown). Pure logic,
  unit-testable.
- lib/diashow/wakelock.ts: Screen Wake Lock wrapper that re-acquires on
  visibility change (the OS drops the lock when the tab hides).
- lib/diashow/transitions/{index,crossfade,kenburns}.ts: registry +
  the v1 transitions. Adding a new animation is one file + one entry —
  the extensibility target from docs/FEATURES §2.9.
- routes/diashow/+page.svelte: fullscreen page, hides bottom nav,
  6 s default dwell (3/6/10 configurable), keyboard shortcuts
  (Escape exits, Space toggles pause), tap-to-reveal overlay with
  pause / dwell / transition / exit. Respects $dataMode to choose
  preview vs. original URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 14:32:55 +02:00
parent 251f9f1469
commit 8a769b52bf
6 changed files with 519 additions and 0 deletions

View File

@@ -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<string, FeedUpload> = 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();
}
}
}
/** FisherYates. Mutates a copy of the input so callers can pass `allKnown.values()`. */
function shuffle<T>(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;
}