// 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. // // Completeness is the whole point: `allKnown` is meant to converge to the server's full // set of eligible uploads. The page feeds it via `merge` (incremental / paginated) and // `retainOnly` (evict anything the server no longer returns — deletions, bans, hides), // so every eligible image is eventually shown and no ineligible one lingers. import type { FeedUpload } from '$lib/types'; const RECENT_RING_SIZE = 5; export class SlideQueue { /** Live queue — FIFO. New uploads land here via `merge(_, { live: true })`. */ 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[] = []; /** * Add uploads, de-duplicated by id. `live` items jump onto the live queue (shown next); * otherwise they join the shuffle pool. Returns the count newly added. * * Two things are skipped: * * 1. **Videos, deliberately and by mime type.** The projector shows stills only — a clip * would either hold the slide for its full duration or be cut off mid-way, and it has * no audio path on a room's screen. This used to happen only ACCIDENTALLY, as a side * effect of the derivative check below: a video whose poster frame extracted fine has * a `thumbnail_url` and so was shown as a still frame, while one whose extraction * failed was dropped — the same upload included or excluded depending on whether * ffmpeg happened to find a frame. Now the rule is the mime type, so it is consistent. * 2. **Images with no derivative yet.** A still-compressing photo has no image to show; * it is picked up by a later sync once its preview exists. */ merge(uploads: FeedUpload[], opts: { live: boolean }): number { let added = 0; for (const u of uploads) { if (u.mime_type.startsWith('video/')) continue; if (!u.preview_url && !u.thumbnail_url) continue; if (this.allKnown.has(u.id)) continue; this.allKnown.set(u.id, u); if (opts.live) this.liveQueue.push(u); else this.shuffleQueue.push(u); added++; } return added; } /** True if this id is already in the known set — used to stop a catch-up scan once it * reaches territory we've already ingested. */ has(id: string): boolean { return this.allKnown.has(id); } /** Re-randomise the not-yet-shown shuffle pool. Called once after the initial paginated * load so the first cycle isn't just newest-first (merge appends in feed order). */ reshuffle(): void { this.shuffleQueue = shuffle(this.shuffleQueue); } /** 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 }; } /** * Remove every slide belonging to a user (banned with hide_uploads). Returns * true if the current head was one of them so the caller advances immediately. */ removeByUser(userId: string, currentId: string | null): { wasCurrent: boolean } { let wasCurrent = false; for (const [id, slide] of this.allKnown) { if (slide.user_id === userId) { if (id === currentId) wasCurrent = true; this.allKnown.delete(id); } } this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId); this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId); this.recentlyShown = this.recentlyShown.filter((sid) => this.allKnown.has(sid)); return { wasCurrent }; } /** * Snapshot of all currently-known ids. The page captures this BEFORE a reconcile scan, * then evicts only ids that were known before AND the server no longer lists — the * reconciliation half of `merge`. Snapshotting first means an upload that lands *during* * the scan (not in the snapshot) is never wrongly evicted, so a busy event doesn't * flicker freshly-added slides out and back in. */ knownIds(): string[] { return Array.from(this.allKnown.keys()); } /** 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; }