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;
|
||||
}
|
||||
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal file
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal 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>
|
||||
47
frontend/src/lib/diashow/transitions/index.ts
Normal file
47
frontend/src/lib/diashow/transitions/index.ts
Normal 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];
|
||||
}
|
||||
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal file
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal 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>
|
||||
54
frontend/src/lib/diashow/wakelock.ts
Normal file
54
frontend/src/lib/diashow/wakelock.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user