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:
223
frontend/src/routes/diashow/+page.svelte
Normal file
223
frontend/src/routes/diashow/+page.svelte
Normal file
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
import { SlideQueue } from '$lib/diashow/queue';
|
||||
import { transitions, findTransition } from '$lib/diashow/transitions';
|
||||
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
|
||||
import type { FeedUpload, FeedResponse } from '$lib/types';
|
||||
|
||||
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||
|
||||
let queue = new SlideQueue();
|
||||
let current = $state<FeedUpload | null>(null);
|
||||
let dwellMs = $state(6000);
|
||||
let transitionId = $state('crossfade');
|
||||
let paused = $state(false);
|
||||
let showOverlay = $state(false);
|
||||
let overlayHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
const transitionDef = $derived(findTransition(transitionId));
|
||||
|
||||
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
|
||||
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
||||
|
||||
function isEmpty(): boolean {
|
||||
return queue.stats().known === 0;
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
clearTimer();
|
||||
if (paused) return;
|
||||
// Videos: advance on `ended` or after `max(dwell, 12s)` — whichever first.
|
||||
const ms = isVideo ? Math.max(dwellMs, 12000) : dwellMs;
|
||||
advanceTimer = setTimeout(advance, ms);
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (advanceTimer) {
|
||||
clearTimeout(advanceTimer);
|
||||
advanceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function advance() {
|
||||
const next = queue.next();
|
||||
current = next;
|
||||
if (current) scheduleNext();
|
||||
}
|
||||
|
||||
async function loadInitial() {
|
||||
try {
|
||||
const feed = await api.get<FeedResponse>('/feed?limit=200');
|
||||
queue.seed(feed.uploads);
|
||||
advance();
|
||||
} catch {
|
||||
// Silent — placeholder stays shown
|
||||
}
|
||||
}
|
||||
|
||||
// `upload-processed` carries only `{ upload_id }`; we re-fetch from /feed to get the
|
||||
// preview/thumbnail URLs that just became available. We deliberately do NOT listen
|
||||
// to `new-upload` here — its payload arrives before compression finishes
|
||||
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
|
||||
// preview would never be picked up if we enqueued the pre-processed version first.
|
||||
async function handleUploadProcessed(data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { upload_id: string };
|
||||
if (!payload.upload_id) return;
|
||||
const feed = await api.get<FeedResponse>('/feed?limit=20');
|
||||
const found = feed.uploads.find((u) => u.id === payload.upload_id);
|
||||
if (found) {
|
||||
queue.pushLive(found);
|
||||
if (!current) advance();
|
||||
}
|
||||
} catch {
|
||||
// ignore — silent recovery; the next event will retry
|
||||
}
|
||||
}
|
||||
|
||||
function handleUploadDeleted(data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { upload_id: string };
|
||||
const result = queue.remove(payload.upload_id, current?.id ?? null);
|
||||
if (result.wasCurrent) advance();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function revealOverlay() {
|
||||
showOverlay = true;
|
||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
clearTimer();
|
||||
} else {
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
function exit() {
|
||||
void goto('/feed');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showOverlay) {
|
||||
showOverlay = false;
|
||||
} else {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
togglePause();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
showBottomNav.set(false);
|
||||
void acquireWakeLock();
|
||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||
void loadInitial();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
showBottomNav.set(true);
|
||||
clearTimer();
|
||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||
void releaseWakeLock();
|
||||
for (const unsub of unsubs) unsub();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
||||
role="presentation"
|
||||
onclick={revealOverlay}
|
||||
>
|
||||
{#if current}
|
||||
{#key current.id + '|' + transitionDef.id}
|
||||
<transitionDef.component
|
||||
src={mediaSrc}
|
||||
{isVideo}
|
||||
durationMs={transitionDef.defaultDurationMs}
|
||||
/>
|
||||
{/key}
|
||||
{:else if isEmpty()}
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
||||
<p class="mt-2 text-white/60">Neue Beiträge erscheinen hier automatisch.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-white/60">Lade…</div>
|
||||
{/if}
|
||||
|
||||
{#if showOverlay}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 pb-10"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePause}
|
||||
class="rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||
>
|
||||
{paused ? '▶ Fortsetzen' : '⏸ Pause'}
|
||||
</button>
|
||||
|
||||
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
|
||||
Dauer
|
||||
<select
|
||||
bind:value={dwellMs}
|
||||
onchange={scheduleNext}
|
||||
class="bg-transparent text-sm font-medium focus:outline-none"
|
||||
>
|
||||
{#each DWELL_OPTIONS as ms (ms)}
|
||||
<option value={ms} class="bg-black">{ms / 1000} s</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
|
||||
Übergang
|
||||
<select bind:value={transitionId} class="bg-transparent text-sm font-medium focus:outline-none">
|
||||
{#each transitions as t (t.id)}
|
||||
<option value={t.id} class="bg-black">{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={exit}
|
||||
class="ml-auto rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||
>
|
||||
✕ Beenden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if current}
|
||||
<p class="text-xs text-white/70">
|
||||
{current.uploader_name}
|
||||
{#if current.caption}· {current.caption}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user