Files
EventSnap/frontend/src/lib/diashow/queue.ts
Fabian Hamm (Privat) 89ca819529 fix(diashow): never project a video, however its poster turned out
`merge` decided by whether a usable still existed, so the same clip was included or
dropped depending on whether ffmpeg happened to extract a poster — a video WITH a
thumbnail was queued and shown as a frozen frame, one without was skipped. Keyed on
the mime type instead: the projector shows stills only.

The test factory casts through `as unknown as FeedUpload`, so adding a field the queue
reads does not fail typechecking — it fails at runtime, which is how this surfaced as
ten broken tests rather than a compile error. The factory now carries `mime_type` and
the comment says why keeping it in step matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:36:57 +02:00

166 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, FeedUpload> = 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();
}
}
}
/** 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;
}