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:
106
frontend/src/lib/diashow/queue.ts
Normal file
106
frontend/src/lib/diashow/queue.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fisher–Yates. 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;
|
||||
}
|
||||
Reference in New Issue
Block a user