fix(diashow): working transitions + slide/push, fullscreen, activity controls

Transitions never actually animated: Svelte scopes @keyframes names but does NOT
rewrite animation references in inline style attributes, so the inline
'animation: crossfade-in ...' never matched its scoped keyframe — a hard cut, no fade
(Ken Burns' zoom was dead too). Move the animation into scoped classes and pass dynamic
values via CSS custom properties.

- Real two-layer crossfade: hold the outgoing frame opaque beneath the incoming one
  (which is decode-gated), so there's no black flash and no decode pop.
- New slide transitions (from below/left/right) as one reusable component; the previous
  frame is pushed off the opposite edge in step (a conveyor/push), easeInOutSine 1s.
- Fullscreen toggle button (Fullscreen API) + 'f' shortcut; Esc in fullscreen no longer
  also navigates away.
- Controls now reveal on pointer/keyboard activity and fade when idle (cursor hides too)
  instead of being always visible.
- wakelock: null the sentinel on the OS 'release' event so re-acquire-on-visible
  actually fires (previously the screen could sleep after the tab was first hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 15:22:15 +02:00
parent 44641473ea
commit 4026648f98
6 changed files with 356 additions and 40 deletions

View File

@@ -15,17 +15,36 @@
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;
// Guards the async (decode-gated) layer commit against a newer advance superseding it.
let slideToken = 0;
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> = [];
// 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 mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
function isEmpty(): boolean {
@@ -49,10 +68,63 @@
function advance() {
const next = queue.next();
current = next;
showSlide(next);
if (current) scheduleNext();
}
// Swap in the next slide as the top layer while holding the outgoing one beneath it
// for the length of the transition. For images we decode the incoming frame first so
// the fade reveals a painted picture rather than a blank one; the outgoing slide stays
// visible until then, so there's never a black gap. Videos can't be pre-decoded, so
// they swap in immediately.
function showSlide(next: FeedUpload | null) {
const token = ++slideToken;
if (dropPrevTimer) {
clearTimeout(dropPrevTimer);
dropPrevTimer = null;
}
current = next;
if (!next) {
layers = [];
return;
}
const slide: SlideLayer = {
key: next.id,
src: pickMediaUrl($dataMode, next),
isVideo: next.mime_type.startsWith('video/'),
phase: 'enter'
};
const prev = layers.length ? layers[layers.length - 1] : null;
// Re-showing the same slide (e.g. a feed re-fetch resolved to the current head) —
// just replace it; never stack a slide on top of itself.
if (prev && prev.key === slide.key) {
layers = [slide];
return;
}
const commit = () => {
if (token !== slideToken) return; // superseded by a newer advance — drop this frame
// 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);
}
};
if (slide.isVideo) {
commit();
return;
}
const pre = new Image();
pre.src = slide.src;
// decode() resolves once the bitmap is ready to paint; on failure just commit anyway
// (a broken image should still advance the show rather than stall it).
pre.decode().then(commit).catch(commit);
}
// 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.
@@ -178,18 +250,37 @@
}
}
function revealOverlay() {
showOverlay = true;
if (overlayHideTimer) clearTimeout(overlayHideTimer);
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
// 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 always-visible control button — stays open (no
// auto-hide) so keyboard users can tab through the controls without them
// vanishing mid-interaction.
// 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() {
if (overlayHideTimer) clearTimeout(overlayHideTimer);
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() {
@@ -206,7 +297,10 @@
}
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 {
@@ -217,6 +311,10 @@
e.preventDefault();
togglePause();
}
if (e.key === 'f' || e.key === 'F') {
e.preventDefault();
void toggleFullscreen();
}
}
onMount(() => {
@@ -229,6 +327,8 @@
}
showBottomNav.set(false);
void acquireWakeLock();
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));
@@ -246,8 +346,12 @@
onDestroy(() => {
showBottomNav.set(true);
clearTimer();
if (overlayHideTimer) clearTimeout(overlayHideTimer);
if (controlsHideTimer) clearTimeout(controlsHideTimer);
if (processedDebounce) clearTimeout(processedDebounce);
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();
@@ -257,19 +361,24 @@
<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"
onclick={revealOverlay}
onpointermove={showControls}
onpointerdown={showControls}
>
{#if current}
{#key current.id + '|' + transitionDef.id}
{#if layers.length}
{#each layers as layer (layer.key)}
<transitionDef.component
src={mediaSrc}
{isVideo}
src={layer.src}
isVideo={layer.isVideo}
durationMs={transitionDef.defaultDurationMs}
onended={handleVideoEnded}
phase={layer.phase}
onended={layer.key === current?.id ? handleVideoEnded : undefined}
{...transitionDef.props ?? {}}
/>
{/key}
{/each}
{:else if isEmpty()}
<div class="text-center">
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
@@ -279,10 +388,12 @@
<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. -->
<!-- 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"
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
@@ -303,6 +414,36 @@
/>
</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) => {