Files
EventSnap/frontend/src/routes/diashow/+page.svelte
MechaCat02 3c3a7d0082 fix(diashow): open SSE on mount; raise upload rate 10→100/hour
diashow only subscribed to SSE events but never called connectSse(), so a
kiosk/projector opening /diashow directly (not via /feed) never opened the
EventSource — the showcase display got a one-time /feed snapshot and no live
updates, showing "Noch keine Beiträge" forever when turned on before any
photos. Open the stream in onMount (idempotent) and close it in onDestroy,
mirroring the feed page.

Raise the default upload_rate_per_hour from 10 to 100 (migration 015, scoped
to installs still on the old default so admin overrides are preserved). Guests
routinely upload bursts of 10-20 photos; the old default throttled the first
burst. Also update the code fallback and the test-mode reseed.

Both verified end-to-end against the docker test stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:29:08 +02:00

395 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { getToken } from '$lib/auth';
import { showBottomNav } from '$lib/ui-store';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { connectSse, disconnectSse, 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, DeltaResponse } 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();
}
// A video finished before its dwell/12s cap — advance immediately (unless paused).
// The {#key current.id} block destroys the old <video> on advance, so a fallback
// timer that already fired can't trigger this handler for a stale slide.
function handleVideoEnded() {
if (!paused) advance();
}
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.
// COALESCE the fetch: a host bulk-uploading fires dozens of `upload-processed` events in
// seconds; one `/feed` fetch per event trips the per-user feed rate limit (429) and drops
// slides. Debounce into a single fetch of the newest slice instead.
let processedDebounce: ReturnType<typeof setTimeout> | null = null;
function handleUploadProcessed(data: string) {
try {
const { upload_id } = JSON.parse(data) as { upload_id: string };
if (!upload_id) return;
} catch {
return;
}
if (processedDebounce) clearTimeout(processedDebounce);
processedDebounce = setTimeout(() => {
processedDebounce = null;
void refreshRecentFromFeed();
}, 500);
}
async function refreshRecentFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=50');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — silent recovery; the next event or reconnect delta retries
}
}
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
}
}
// After an all-night projector reconnects (SSE drop, network blip), the live events
// it missed are gone. The SSE client fans out a `feed-delta` of everything since the
// last seen timestamp — merge it so the show backfills the gap instead of silently
// stalling on a stale set.
function handleFeedDelta(data: string) {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Apply deletions + ban-hides FIRST and unconditionally: both are explicit, uncapped
// lists, so a moderated/removed photo (or a banned user's whole set) must leave the
// rotation even when the delta is truncated. This replays a `user-hidden`/delete the
// projector missed while disconnected — the reason a banned guest's slides used to
// keep cycling all night. (We can't infer removals by absence from a capped /feed
// backfill — older slides beyond the newest 200 would be falsely dropped.)
for (const id of delta.deleted_ids) {
const result = queue.remove(id, current?.id ?? null);
if (result.wasCurrent) advance();
}
for (const userId of delta.hidden_user_ids) {
const result = queue.removeByUser(userId, current?.id ?? null);
if (result.wasCurrent) advance();
}
// A truncated delta's `uploads` is only the newest slice of a larger gap; merging it
// alone would skip the older-but-still-new items in between. Backfill new uploads from
// a full feed fetch (pushLive dedupes by id, so the current slide isn't disturbed).
if (delta.truncated) {
void backfillFromFeed();
return;
}
for (const upload of delta.uploads) {
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
// later `upload-processed` still refines anything that arrives raw.
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — non-fatal; the next live event keeps the show moving
}
}
// Full-feed backfill used when a reconnect delta overflows the cap. Merges via
// pushLive (id-deduped) so a long-running projector recovers the whole gap.
async function backfillFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=200');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — the next live event keeps the show moving
}
}
function handleUserHidden(data: string) {
try {
const payload = JSON.parse(data) as { user_id: string };
const result = queue.removeByUser(payload.user_id, current?.id ?? null);
if (result.wasCurrent) advance();
} catch {
// ignore
}
}
function revealOverlay() {
showOverlay = true;
if (overlayHideTimer) clearTimeout(overlayHideTimer);
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
}
// Manual toggle from the always-visible control button — stays open (no
// auto-hide) so keyboard users can tab through the controls without them
// vanishing mid-interaction.
function toggleOverlay() {
if (overlayHideTimer) clearTimeout(overlayHideTimer);
showOverlay = !showOverlay;
}
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(() => {
// Auth guard — mirror the other protected routes. Without this an
// unauthenticated visitor lands on a permanently-loading empty slideshow
// instead of being sent to /join.
if (!getToken()) {
goto('/join');
return;
}
showBottomNav.set(false);
void acquireWakeLock();
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
// Open the stream ourselves — a kiosk/projector loads /diashow directly (never
// via /feed), so we can't rely on another page having opened the singleton
// EventSource. Without this the show only ever gets its one-time /feed snapshot
// and never receives live `upload-processed` events. connectSse() is idempotent
// (no-ops if the feed page already opened it). Subscriptions are registered above
// first so no early event is missed.
connectSse();
void loadInitial();
});
onDestroy(() => {
showBottomNav.set(true);
clearTimer();
if (overlayHideTimer) clearTimeout(overlayHideTimer);
if (processedDebounce) clearTimeout(processedDebounce);
void releaseWakeLock();
for (const unsub of unsubs) unsub();
disconnectSse();
});
</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}
onended={handleVideoEnded}
/>
{/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}
<!-- Always-visible controls: keyboard-reachable, so the show is never a trap and
the controls are reachable without a pointer. Honours the notch. -->
<div
class="absolute right-0 top-0 z-10 flex gap-2 p-3"
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleOverlay();
}}
aria-label="Steuerung anzeigen"
aria-expanded={showOverlay}
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
exit();
}}
aria-label="Diashow beenden"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if showOverlay}
<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"
>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
onclick={togglePause}
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
{#if paused}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"
><path d="M8 5v14l11-7z" /></svg
>
Fortsetzen
{:else}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"
><path d="M6 5h4v14H6zM14 5h4v14h-4z" /></svg
>
Pause
{/if}
</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 inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg
>
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>