The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
686 lines
26 KiB
Svelte
686 lines
26 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 { 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];
|
|
// Cap on how long we wait for the next image to decode before showing it anyway.
|
|
const PRELOAD_TIMEOUT_MS = 4000;
|
|
// How far inside the dwell the preload budget must finish.
|
|
//
|
|
// `advance()` starts the preload and `scheduleNext()` starts the dwell timer together, and
|
|
// `commit()` is discarded if the slide token has moved on. So whenever the preload budget
|
|
// is >= the dwell, a screen whose images consistently decode slowly (cold cache on a
|
|
// congested venue uplink — the normal state for a projector on party wifi) can never land
|
|
// a commit: at 3s dwell the 4s preload is discarded, the next one gets 4s and is discarded
|
|
// at 6s, forever. The wall sticks on one photo for the rest of the night while the queue
|
|
// drains silently behind it, and nothing on screen says anything is wrong.
|
|
//
|
|
// Bounding the budget strictly below the dwell means the timeout always fires first and
|
|
// commits whatever it has — a possibly-undecoded image is a far better outcome than a
|
|
// frozen wall that needs a human.
|
|
const PRELOAD_HEADROOM_MS = 500;
|
|
// If every source for a slide is unreadable we skip it — but bail out of skipping after
|
|
// this many in a row so a total media outage can't hot-loop the show.
|
|
const MAX_CONSECUTIVE_SKIPS = 5;
|
|
// Feed pagination: the server caps `limit` at 100, so we page to cover larger events.
|
|
const FEED_PAGE = 100;
|
|
const MAX_PAGES = 100; // safety bound (~10k images) against a runaway pagination loop
|
|
// Periodic full reconcile — the completeness backstop for anything a live event missed
|
|
// (dropped SSE, rate-limited fetch, an add/remove that never arrived).
|
|
const RECONCILE_INTERVAL_MS = 120_000;
|
|
|
|
let queue = new SlideQueue();
|
|
let current = $state<FeedUpload | null>(null);
|
|
// Two-layer crossfade. Previously the diashow rendered only `current` inside a
|
|
// `{#key current.id}` block, so on every advance Svelte destroyed the old slide the
|
|
// instant the id changed and mounted the new one starting at opacity 0 over the black
|
|
// backdrop — the old image vanished to black, then the new one faded up (and only
|
|
// popped in once it decoded). That's the "black flash". Instead we keep the outgoing
|
|
// slide fully opaque *underneath* the incoming one while it fades in, then drop it —
|
|
// there's always an opaque frame on screen, so the black never shows through.
|
|
type SlideLayer = { key: string; src: string; isVideo: boolean; phase: 'enter' | 'exit' };
|
|
let layers = $state<SlideLayer[]>([]);
|
|
let dropPrevTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
|
// Guards the async (decode-gated) layer commit against a newer advance superseding it.
|
|
let slideToken = 0;
|
|
// Consecutive slides skipped because no source decoded — reset the moment one shows.
|
|
let consecutiveSkips = 0;
|
|
let dwellMs = $state(6000);
|
|
// Set when the browser has no Screen Wake Lock API at all (Firefox, Safari < 16.4, most TV
|
|
// browsers). The operator sets this screen up once and walks away, so a silent no-op meant
|
|
// discovering the OS had dimmed the projector only by looking at it.
|
|
let wakeLockUnsupported = $state(false);
|
|
let transitionId = $state('crossfade');
|
|
let paused = $state(false);
|
|
let showOverlay = $state(false);
|
|
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const unsubs: Array<() => void> = [];
|
|
|
|
// Controls auto-hide: the top-right cluster shows on pointer/keyboard activity and fades
|
|
// after a few seconds idle (the cursor hides with it) so a projector shows only photos.
|
|
let controlsVisible = $state(true);
|
|
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
// Fullscreen toggle via the Fullscreen API — an in-app button, independent of the
|
|
// browser's own F11 chrome. `isFullscreen` mirrors the actual state via fullscreenchange.
|
|
let isFullscreen = $state(false);
|
|
let rootEl: HTMLDivElement | undefined = $state();
|
|
|
|
const transitionDef = $derived(findTransition(transitionId));
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// `immediate` = hard-cut with no exiting layer, used when the outgoing slide was just
|
|
// removed (deleted / banned): it must vanish at once, not linger through the transition.
|
|
function advance(immediate = false) {
|
|
const next = queue.next();
|
|
showSlide(next, immediate);
|
|
if (current) scheduleNext();
|
|
}
|
|
|
|
// The diashow is a big screen (usually on good connectivity), so it ALWAYS prefers the
|
|
// crisp ~2048px `display` derivative — decoupled from the per-device data-saver mode,
|
|
// which is about a guest's phone data plan and irrelevant here. It falls back to the
|
|
// original (uploads without a display yet) and then the 800px preview.
|
|
function imageCandidates(u: FeedUpload): string[] {
|
|
const displayOrOriginal = u.display_url ?? `/api/v1/upload/${u.id}/original`;
|
|
const list = [displayOrOriginal];
|
|
if (u.preview_url && u.preview_url !== displayOrOriginal) list.push(u.preview_url);
|
|
return list;
|
|
}
|
|
|
|
// Swap in the next slide as the top layer while holding the outgoing one beneath it for
|
|
// the length of the transition. Images are decode-gated (the fade reveals a painted
|
|
// frame, not a blank one) and try each source in turn; the outgoing slide stays visible
|
|
// until one commits, so there's never a black gap. Videos can't be pre-decoded, so they
|
|
// swap in immediately.
|
|
function showSlide(next: FeedUpload | null, immediate = false) {
|
|
const token = ++slideToken;
|
|
if (dropPrevTimer) {
|
|
clearTimeout(dropPrevTimer);
|
|
dropPrevTimer = null;
|
|
}
|
|
current = next;
|
|
if (!next) {
|
|
layers = [];
|
|
return;
|
|
}
|
|
const isVid = next.mime_type.startsWith('video/');
|
|
const prev = layers.length ? layers[layers.length - 1] : null;
|
|
|
|
const commit = (src: string) => {
|
|
if (token !== slideToken) return; // superseded by a newer advance — drop this frame
|
|
consecutiveSkips = 0; // a slide actually reached the screen — reset the skip guard
|
|
const slide: SlideLayer = { key: next.id, src, isVideo: isVid, phase: 'enter' };
|
|
// Re-showing the same slide id (e.g. a re-fetch resolved to the current head) —
|
|
// just replace it; never stack a slide on itself.
|
|
if (prev && prev.key === slide.key) {
|
|
layers = [slide];
|
|
return;
|
|
}
|
|
// Removal-driven advance: drop the outgoing (removed) frame instantly — no exiting
|
|
// layer. A brief blank over black beats showing a just-deleted/banned photo for
|
|
// another second while it slides away.
|
|
if (immediate) {
|
|
layers = [slide];
|
|
return;
|
|
}
|
|
// Mark the outgoing frame as exiting so slide transitions push it off in step with
|
|
// the incoming one. Crossfade/Ken Burns ignore `phase` and just hold it opaque.
|
|
const exiting = prev ? { ...prev, phase: 'exit' as const } : null;
|
|
layers = exiting ? [exiting, slide] : [slide];
|
|
if (exiting) {
|
|
dropPrevTimer = setTimeout(() => {
|
|
dropPrevTimer = null;
|
|
layers = layers.filter((l) => l.key !== exiting.key);
|
|
}, transitionDef.defaultDurationMs + 80);
|
|
}
|
|
};
|
|
|
|
// Videos play the original file directly (can't be pre-decoded) — show immediately.
|
|
if (isVid) {
|
|
commit(`/api/v1/upload/${next.id}/original`);
|
|
return;
|
|
}
|
|
|
|
// Try each image source in order. A decoded one is shown; a SLOW one is shown after
|
|
// the timeout (don't stall the wall); a BROKEN one (fast decode reject) falls through
|
|
// to the next source. If every source is unreadable, skip to a different slide rather
|
|
// than parking a broken frame — guarded so a full media outage can't hot-loop.
|
|
const candidates = imageCandidates(next);
|
|
const tryCandidate = (i: number) => {
|
|
if (token !== slideToken) return;
|
|
if (i >= candidates.length) {
|
|
if (consecutiveSkips < MAX_CONSECUTIVE_SKIPS) {
|
|
consecutiveSkips++;
|
|
advance();
|
|
} else {
|
|
consecutiveSkips = 0;
|
|
commit(candidates[candidates.length - 1]); // give up skipping; show last try
|
|
}
|
|
return;
|
|
}
|
|
let settled = false;
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
const done = (action: () => void) => {
|
|
if (settled || token !== slideToken) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
action();
|
|
};
|
|
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
|
|
const preloadBudget = Math.max(
|
|
250,
|
|
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
|
|
);
|
|
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
|
|
const pre = new Image();
|
|
pre.src = candidates[i];
|
|
pre.decode().then(
|
|
() => done(() => commit(candidates[i])),
|
|
() => done(() => tryCandidate(i + 1))
|
|
);
|
|
};
|
|
tryCandidate(0);
|
|
}
|
|
|
|
// A video finished before its dwell/12s cap — advance immediately (unless paused).
|
|
// `onended` is only wired to the layer whose key === current.id (see the template), so a
|
|
// stale/exiting <video> that ends mid-transition can't trigger an advance.
|
|
function handleVideoEnded() {
|
|
if (!paused) advance();
|
|
}
|
|
|
|
// Full reconcile: page the ENTIRE eligible feed, merge everything, then evict anything
|
|
// the server no longer returns. v_feed already excludes deleted/banned/hidden, so the
|
|
// feed's id-set IS the eligible set — retaining exactly those ids converges the show to
|
|
// the server truth in both directions, catching any add/remove a live event missed.
|
|
// Runs on mount (initial fill), on reconnect deltas, and periodically as the backstop.
|
|
// `initial` reshuffles once the whole set is in, so the first cycle isn't newest-first.
|
|
async function reconcile(initial = false) {
|
|
// Ids known BEFORE the scan — only these are eviction candidates, so an upload that
|
|
// arrives mid-scan (not in this snapshot) is never wrongly pruned.
|
|
const before = new Set(queue.knownIds());
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local scan-bookkeeping set inside one reconcile() call; never stored in $state, so no reactivity is involved.
|
|
const seen = new Set<string>();
|
|
let cursor: string | null = null;
|
|
let complete = false;
|
|
try {
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const qs: string = cursor ? `?limit=${FEED_PAGE}&cursor=${cursor}` : `?limit=${FEED_PAGE}`;
|
|
const feed: FeedResponse = await api.get<FeedResponse>(`/feed${qs}`);
|
|
for (const u of feed.uploads) seen.add(u.id);
|
|
queue.merge(feed.uploads, { live: false });
|
|
if (!current) advance(); // start the show the moment the first page lands
|
|
if (feed.next_cursor == null) {
|
|
complete = true;
|
|
break;
|
|
}
|
|
cursor = feed.next_cursor;
|
|
}
|
|
} catch {
|
|
// Partial fetch (network / rate-limit): keep what we merged but DON'T prune — a
|
|
// truncated `seen` set must never evict valid slides. The next reconcile retries.
|
|
complete = false;
|
|
}
|
|
if (complete) {
|
|
if (initial) queue.reshuffle();
|
|
// Evict only pre-existing slides the server no longer lists (deletions/bans/hides
|
|
// we missed a live event for). Hard-cut if the current slide was one of them.
|
|
let removedCurrent = false;
|
|
for (const id of before) {
|
|
if (!seen.has(id) && queue.remove(id, current?.id ?? null).wasCurrent) {
|
|
removedCurrent = true;
|
|
}
|
|
}
|
|
if (removedCurrent) advance(true);
|
|
}
|
|
if (!current) advance();
|
|
}
|
|
|
|
// Live catch-up after `upload-processed`. Pages from the newest downward, merging onto
|
|
// the live queue (shown soon), and stops at the first page overlapping what we already
|
|
// know — so a burst larger than one 100-item page is fully captured, while a steady
|
|
// trickle costs a single request. We deliberately ignore `new-upload` (fires before
|
|
// compression, so preview_url is still null and the item isn't displayable yet).
|
|
async function catchUpNew() {
|
|
let cursor: string | null = null;
|
|
try {
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const qs: string = cursor ? `?limit=${FEED_PAGE}&cursor=${cursor}` : `?limit=${FEED_PAGE}`;
|
|
const feed: FeedResponse = await api.get<FeedResponse>(`/feed${qs}`);
|
|
// A known id marks the boundary: everything below it we already have.
|
|
const reachedKnown = feed.uploads.some((u) => queue.has(u.id));
|
|
queue.merge(feed.uploads, { live: true });
|
|
if (!current) advance();
|
|
if (feed.next_cursor == null || reachedKnown) break;
|
|
cursor = feed.next_cursor;
|
|
}
|
|
} catch {
|
|
// transient — the max-wait debounce re-fires and the periodic reconcile backstops
|
|
}
|
|
}
|
|
|
|
// Debounce `upload-processed` into a single catch-up, BUT with a max-wait: a sustained
|
|
// burst (every guest uploading at once) would otherwise keep resetting a plain trailing
|
|
// debounce and never fire. The max-wait forces a catch-up at least every 2s mid-burst.
|
|
let processedDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
let processedMaxWait: ReturnType<typeof setTimeout> | null = null;
|
|
function fireCatchUp() {
|
|
if (processedDebounce) {
|
|
clearTimeout(processedDebounce);
|
|
processedDebounce = null;
|
|
}
|
|
if (processedMaxWait) {
|
|
clearTimeout(processedMaxWait);
|
|
processedMaxWait = null;
|
|
}
|
|
void catchUpNew();
|
|
}
|
|
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(fireCatchUp, 500);
|
|
if (!processedMaxWait) processedMaxWait = setTimeout(fireCatchUp, 2000);
|
|
}
|
|
|
|
function handleUploadDeleted(data: string) {
|
|
try {
|
|
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
|
// Hard-cut: a just-deleted photo must not linger through the exit transition.
|
|
if (queue.remove(upload_id, current?.id ?? null).wasCurrent) advance(true);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function handleUserHidden(data: string) {
|
|
try {
|
|
const { user_id } = JSON.parse(data) as { user_id: string };
|
|
if (queue.removeByUser(user_id, current?.id ?? null).wasCurrent) advance(true);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Reconnect after an SSE drop: the client fans out a delta of everything since the last
|
|
// seen timestamp. Apply removals first (hard-cut off the current slide if affected),
|
|
// then merge additions; a truncated delta means the gap overflowed the cap, so fall back
|
|
// to a full catch-up scan.
|
|
function handleFeedDelta(data: string) {
|
|
try {
|
|
const delta = JSON.parse(data) as DeltaResponse;
|
|
let removedCurrent = false;
|
|
for (const id of delta.deleted_ids) {
|
|
if (queue.remove(id, current?.id ?? null).wasCurrent) removedCurrent = true;
|
|
}
|
|
for (const userId of delta.hidden_user_ids) {
|
|
if (queue.removeByUser(userId, current?.id ?? null).wasCurrent) removedCurrent = true;
|
|
}
|
|
if (removedCurrent) advance(true); // one advance regardless of how many were removed
|
|
if (delta.truncated) {
|
|
void catchUpNew();
|
|
return;
|
|
}
|
|
queue.merge(delta.uploads, { live: true });
|
|
if (!current) advance();
|
|
} catch {
|
|
// ignore — non-fatal; the periodic reconcile backstops anything dropped here
|
|
}
|
|
}
|
|
|
|
// Reveal the control cluster on any pointer/keyboard activity and re-arm the idle timer.
|
|
// Controls stay up while the settings panel is open so it can't vanish mid-interaction.
|
|
function showControls() {
|
|
controlsVisible = true;
|
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
|
controlsHideTimer = setTimeout(() => {
|
|
if (!showOverlay) controlsVisible = false;
|
|
}, 3000);
|
|
}
|
|
|
|
// Manual toggle from the control button — stays open (no auto-hide) so keyboard users
|
|
// can tab through the settings without them vanishing mid-interaction.
|
|
function toggleOverlay() {
|
|
showOverlay = !showOverlay;
|
|
showControls();
|
|
}
|
|
|
|
async function toggleFullscreen() {
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
} else {
|
|
await (rootEl ?? document.documentElement).requestFullscreen();
|
|
}
|
|
} catch {
|
|
// Fullscreen can be refused (missing user gesture, unsupported) — non-fatal.
|
|
}
|
|
}
|
|
|
|
function handleFullscreenChange() {
|
|
isFullscreen = document.fullscreenElement !== null;
|
|
}
|
|
|
|
function togglePause() {
|
|
paused = !paused;
|
|
if (paused) {
|
|
clearTimer();
|
|
} else {
|
|
scheduleNext();
|
|
}
|
|
}
|
|
|
|
function exit() {
|
|
void goto('/feed');
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
showControls();
|
|
if (e.key === 'Escape') {
|
|
// In fullscreen the browser exits on Escape — don't ALSO navigate away.
|
|
if (isFullscreen) return;
|
|
if (showOverlay) {
|
|
showOverlay = false;
|
|
} else {
|
|
exit();
|
|
}
|
|
}
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
togglePause();
|
|
}
|
|
if (e.key === 'f' || e.key === 'F') {
|
|
e.preventDefault();
|
|
void toggleFullscreen();
|
|
}
|
|
}
|
|
|
|
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().then((supported) => {
|
|
wakeLockUnsupported = !supported;
|
|
});
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
showControls(); // show briefly on entry, then fade after the idle timeout
|
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
|
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
|
// An unban restores that guest's photos to the eligible set. The periodic `reconcile`
|
|
// below would pick this up on its own, but this is the projector nobody is standing at —
|
|
// waiting a whole interval to un-hide photos the host has just decided are fine again is
|
|
// needlessly visible. A full reconcile is the only correct response anyway: the hidden
|
|
// cards were dropped from local state, so there is nothing to restore in place.
|
|
unsubs.push(onSseEvent('user-shown', () => void reconcile()));
|
|
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 reconcile(true); // initial full load
|
|
// Completeness backstop: periodically re-sync the whole eligible set, so a dropped
|
|
// SSE event, a rate-limited catch-up, or any missed add/remove self-heals.
|
|
reconcileInterval = setInterval(() => void reconcile(), RECONCILE_INTERVAL_MS);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
showBottomNav.set(true);
|
|
clearTimer();
|
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
|
if (processedDebounce) clearTimeout(processedDebounce);
|
|
if (processedMaxWait) clearTimeout(processedMaxWait);
|
|
if (reconcileInterval) clearInterval(reconcileInterval);
|
|
if (dropPrevTimer) clearTimeout(dropPrevTimer);
|
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
// Leave fullscreen when exiting the show, so /feed isn't stuck fullscreen.
|
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
|
void releaseWakeLock();
|
|
for (const unsub of unsubs) unsub();
|
|
disconnectSse();
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div
|
|
bind:this={rootEl}
|
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
|
class:cursor-none={!controlsVisible}
|
|
role="presentation"
|
|
onpointermove={showControls}
|
|
onpointerdown={showControls}
|
|
>
|
|
{#if layers.length}
|
|
{#each layers as layer (layer.key)}
|
|
<transitionDef.component
|
|
src={layer.src}
|
|
isVideo={layer.isVideo}
|
|
durationMs={transitionDef.defaultDurationMs}
|
|
phase={layer.phase}
|
|
onended={layer.key === current?.id ? handleVideoEnded : undefined}
|
|
{...transitionDef.props ?? {}}
|
|
/>
|
|
{/each}
|
|
{: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}
|
|
|
|
<!-- The one thing the operator must know before walking away. This browser cannot hold a
|
|
screen wake lock at all, so the OS will dim or lock the display on its own schedule and
|
|
somebody will have to walk over and tap it. Placed bottom-left, out of the controls'
|
|
corner, and deliberately not auto-hidden with them — it is setup information, and it is
|
|
only ever shown when it is actionable. -->
|
|
{#if wakeLockUnsupported}
|
|
<div
|
|
class="pointer-events-none absolute bottom-4 left-4 max-w-xs rounded-md bg-black/60 px-3 py-2 text-left text-xs text-white/70 backdrop-blur"
|
|
>
|
|
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische Bildschirmsperre
|
|
am Gerät deaktivieren.
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Controls appear on pointer/keyboard activity and fade when idle. Still fully
|
|
keyboard-reachable (any key reveals them), so the show is never a trap. Notch-safe. -->
|
|
<div
|
|
class="absolute right-0 top-0 z-10 flex gap-2 p-3 transition-opacity duration-300"
|
|
class:opacity-0={!controlsVisible}
|
|
class:pointer-events-none={!controlsVisible}
|
|
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();
|
|
void toggleFullscreen();
|
|
}}
|
|
aria-label={isFullscreen ? 'Vollbild beenden' : 'Vollbild'}
|
|
aria-pressed={isFullscreen}
|
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
|
>
|
|
{#if isFullscreen}
|
|
<!-- arrows pointing in -->
|
|
<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="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<!-- arrows pointing out -->
|
|
<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="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
</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>
|