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;
}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
// Simplest transition: render whatever the parent gave us, full-bleed, with a CSS
// opacity fade. Tied to the parent's `{#key}` block — when the src changes, the
// parent re-mounts this component and the `in:` transition runs.
interface Props {
src: string;
isVideo: boolean;
durationMs: number;
}
let { src, isVideo, durationMs }: Props = $props();
</script>
<div
class="absolute inset-0 flex items-center justify-center bg-black"
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
>
{#if isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video
{src}
autoplay
muted
playsinline
class="h-full w-full object-contain"
></video>
{:else}
<img {src} alt="" class="h-full w-full object-contain" />
{/if}
</div>
<style>
@keyframes crossfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -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<TransitionProps>;
}
/** 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<TransitionProps>
},
{
id: 'kenburns',
label: 'Ken Burns',
defaultDurationMs: 600,
component: KenBurns as unknown as Component<TransitionProps>
}
];
export function findTransition(id: string): SlideTransition {
return transitions.find((t) => t.id === id) ?? transitions[0];
}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
// Crossfade + slow zoom and pan (the "Ken Burns" effect). Image-only — videos
// fall back to the crossfade behaviour to avoid double-animation.
interface Props {
src: string;
isVideo: boolean;
durationMs: number;
}
let { src, isVideo, durationMs }: Props = $props();
// Mild random pan so each slide feels different. Range chosen so the image never
// pans out of frame given the object-fit: cover.
const panX = Math.round((Math.random() - 0.5) * 6); // -3% .. +3%
const panY = Math.round((Math.random() - 0.5) * 6);
</script>
<div
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
style="animation: kb-fade {durationMs}ms ease-out forwards;"
>
{#if isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video
{src}
autoplay
muted
playsinline
class="h-full w-full object-contain"
></video>
{:else}
<img
{src}
alt=""
class="h-full w-full object-cover"
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
/>
{/if}
</div>
<style>
@keyframes kb-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes kb-zoom {
from { transform: scale(1.0); }
to { transform: scale(1.1); }
}
</style>

View File

@@ -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<void>;
}
let sentinel: SentinelLike | null = null;
let visibilityHandler: (() => void) | null = null;
export async function acquireWakeLock(): Promise<void> {
const wakeLock = (navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }).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<void> {
if (sentinel) {
try {
await sentinel.release();
} catch {
// ignore — release after release is fine
}
sentinel = null;
}
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null;
}
}