Files
EventSnap/frontend/src/routes/diashow/+page.svelte
MechaCat02 8a769b52bf 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>
2026-05-16 14:32:55 +02:00

224 lines
6.2 KiB
Svelte

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