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:
@@ -14,9 +14,13 @@
|
|||||||
let { src, isVideo, durationMs, onended }: Props = $props();
|
let { src, isVideo, durationMs, onended }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- The fade lives in a scoped class (not an inline `style="animation:…"`): Svelte scopes
|
||||||
|
`@keyframes` names and only rewrites `animation` references inside this <style> block —
|
||||||
|
an inline animation name stays unscoped and silently never matches, so the fade would
|
||||||
|
never run. The dynamic duration is passed through as a CSS custom property instead. -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black"
|
class="slide absolute inset-0 flex items-center justify-center bg-black"
|
||||||
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
|
style="--fade-dur: {durationMs}ms"
|
||||||
>
|
>
|
||||||
{#if isVideo}
|
{#if isVideo}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
@@ -27,6 +31,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.slide {
|
||||||
|
animation: crossfade-in var(--fade-dur, 400ms) ease-out forwards;
|
||||||
|
}
|
||||||
@keyframes crossfade-in {
|
@keyframes crossfade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Adding a new animation:
|
// Adding a new animation:
|
||||||
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
||||||
// 2. Add one entry to `transitions` below.
|
// 2. Add one entry to `transitions` below (reuse a component with different `props`
|
||||||
|
// to expose variants — see the slide directions).
|
||||||
// 3. Rebuild — the popover updates automatically.
|
// 3. Rebuild — the popover updates automatically.
|
||||||
//
|
//
|
||||||
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
||||||
@@ -12,12 +13,16 @@
|
|||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import Crossfade from './crossfade.svelte';
|
import Crossfade from './crossfade.svelte';
|
||||||
import KenBurns from './kenburns.svelte';
|
import KenBurns from './kenburns.svelte';
|
||||||
|
import Slide from './slide.svelte';
|
||||||
|
|
||||||
export interface SlideTransition {
|
export interface SlideTransition {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
defaultDurationMs: number;
|
defaultDurationMs: number;
|
||||||
component: Component<TransitionProps>;
|
component: Component<TransitionProps>;
|
||||||
|
/** Extra static props merged into the component — lets one component back several
|
||||||
|
* registry entries (e.g. the slide directions). */
|
||||||
|
props?: Partial<TransitionProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props every transition Svelte component receives. */
|
/** Props every transition Svelte component receives. */
|
||||||
@@ -27,6 +32,12 @@ export interface TransitionProps {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** Fired when a video slide finishes so the parent can advance early. */
|
/** Fired when a video slide finishes so the parent can advance early. */
|
||||||
onended?: () => void;
|
onended?: () => void;
|
||||||
|
/** Edge a slide transition enters from (ignored by non-slide components). */
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right';
|
||||||
|
/** Which half of a two-layer transition this instance plays: the incoming slide
|
||||||
|
* ('enter') or the outgoing one being pushed off ('exit'). Ignored by transitions
|
||||||
|
* that keep the previous frame static (crossfade, Ken Burns). */
|
||||||
|
phase?: 'enter' | 'exit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transitions: SlideTransition[] = [
|
export const transitions: SlideTransition[] = [
|
||||||
@@ -41,6 +52,27 @@ export const transitions: SlideTransition[] = [
|
|||||||
label: 'Ken Burns',
|
label: 'Ken Burns',
|
||||||
defaultDurationMs: 600,
|
defaultDurationMs: 600,
|
||||||
component: KenBurns as unknown as Component<TransitionProps>
|
component: KenBurns as unknown as Component<TransitionProps>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-up',
|
||||||
|
label: 'Von unten',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'up' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-left',
|
||||||
|
label: 'Von rechts',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'left' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-right',
|
||||||
|
label: 'Von links',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'right' }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,14 @@
|
|||||||
const panY = Math.round((Math.random() - 0.5) * 6);
|
const panY = Math.round((Math.random() - 0.5) * 6);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Both animations live in scoped classes rather than inline `style="animation:…"`.
|
||||||
|
Svelte scopes `@keyframes` names and only rewrites `animation` references inside this
|
||||||
|
<style> block, so an inline animation name never matches the scoped keyframe and the
|
||||||
|
effect silently dies. Dynamic values (fade duration, pan origin) pass through as inline
|
||||||
|
custom properties / properties that Svelte leaves untouched. -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
class="kb absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||||
style="animation: kb-fade {durationMs}ms ease-out forwards;"
|
style="--fade-dur: {durationMs}ms"
|
||||||
>
|
>
|
||||||
{#if isVideo}
|
{#if isVideo}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
@@ -29,13 +34,19 @@
|
|||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover"
|
class="kb-img h-full w-full object-cover"
|
||||||
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
|
style="transform-origin: {50 + panX}% {50 + panY}%;"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.kb {
|
||||||
|
animation: kb-fade var(--fade-dur, 600ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
.kb-img {
|
||||||
|
animation: kb-zoom 10s ease-out forwards;
|
||||||
|
}
|
||||||
@keyframes kb-fade {
|
@keyframes kb-fade {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
115
frontend/src/lib/diashow/transitions/slide.svelte
Normal file
115
frontend/src/lib/diashow/transitions/slide.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Slide transition: the incoming slide glides in from one edge while the outgoing slide
|
||||||
|
// is pushed off the opposite edge on the same axis — the pair travels together, edge to
|
||||||
|
// edge, like a filmstrip / conveyor. `phase` tells this instance which half it is:
|
||||||
|
// enter → starts offscreen, settles at centre (the new photo)
|
||||||
|
// exit → starts at centre, leaves offscreen the same way (the previous photo)
|
||||||
|
// The diashow's two-layer stack renders both at once, so the push reads as one motion.
|
||||||
|
//
|
||||||
|
// The animation lives in scoped classes, not an inline animation declaration: Svelte
|
||||||
|
// scopes keyframe names and only rewrites animation references inside the scoped style
|
||||||
|
// block, so an inline animation name silently never matches its keyframe. Dynamic values
|
||||||
|
// (duration, offsets) ride in as inline CSS custom properties, which pass through
|
||||||
|
// untouched. Switching the is-exit class swaps animation-name, which restarts the
|
||||||
|
// animation — the mechanism that lets the previous frame begin its exit.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
isVideo: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
/** Fired when a video slide finishes so the parent can advance early. */
|
||||||
|
onended?: () => void;
|
||||||
|
/** Edge the slide enters from. */
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right';
|
||||||
|
/** Which half of the push this instance plays. */
|
||||||
|
phase?: 'enter' | 'exit';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { src, isVideo, durationMs, onended, direction = 'up', phase = 'enter' }: Props = $props();
|
||||||
|
|
||||||
|
// Offscreen offsets. The incoming slide starts at `enterFrom` and ends centred; the
|
||||||
|
// outgoing slide ends at `exitTo` — the opposite edge on the same axis — so both travel
|
||||||
|
// the same direction and stay adjacent (no overlap, no gap).
|
||||||
|
const enterFrom = $derived(
|
||||||
|
{
|
||||||
|
up: 'translateY(100%)',
|
||||||
|
down: 'translateY(-100%)',
|
||||||
|
left: 'translateX(100%)',
|
||||||
|
right: 'translateX(-100%)'
|
||||||
|
}[direction]
|
||||||
|
);
|
||||||
|
const exitTo = $derived(
|
||||||
|
{
|
||||||
|
up: 'translateY(-100%)',
|
||||||
|
down: 'translateY(100%)',
|
||||||
|
left: 'translateX(-100%)',
|
||||||
|
right: 'translateX(100%)'
|
||||||
|
}[direction]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="slide-layer absolute inset-0 flex items-center justify-center bg-black"
|
||||||
|
class:is-exit={phase === 'exit'}
|
||||||
|
style="--slide-dur: {durationMs}ms; --enter-from: {enterFrom}; --exit-to: {exitTo};"
|
||||||
|
>
|
||||||
|
{#if isVideo}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video {src} autoplay muted playsinline {onended} class="h-full w-full object-contain"></video>
|
||||||
|
{:else}
|
||||||
|
<img {src} alt="" class="h-full w-full object-contain" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slide-layer {
|
||||||
|
/* easeInOutSine — a gentle slow → fast → slow curve so the push eases in and settles
|
||||||
|
softly rather than lurching from a fast start. */
|
||||||
|
animation: slide-enter var(--slide-dur, 1000ms) cubic-bezier(0.37, 0, 0.63, 1) forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.slide-layer.is-exit {
|
||||||
|
animation-name: slide-exit;
|
||||||
|
}
|
||||||
|
@keyframes slide-enter {
|
||||||
|
from {
|
||||||
|
transform: var(--enter-from, translateY(100%));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-exit {
|
||||||
|
from {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: var(--exit-to, translateY(-100%));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Respect users who ask for less motion — cross-dissolve instead of travelling. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.slide-layer {
|
||||||
|
animation: slide-fade var(--slide-dur, 1000ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
.slide-layer.is-exit {
|
||||||
|
animation: slide-fade-out var(--slide-dur, 1000ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,33 +7,43 @@
|
|||||||
|
|
||||||
interface SentinelLike {
|
interface SentinelLike {
|
||||||
release: () => Promise<void>;
|
release: () => Promise<void>;
|
||||||
|
// WakeLockSentinel is an EventTarget; it fires `release` when the lock ends — including
|
||||||
|
// when the OS drops it because the tab was hidden.
|
||||||
|
addEventListener?: (type: 'release', listener: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WakeLock = { request: (t: string) => Promise<SentinelLike> };
|
||||||
|
|
||||||
let sentinel: SentinelLike | null = null;
|
let sentinel: SentinelLike | null = null;
|
||||||
let visibilityHandler: (() => void) | null = null;
|
let visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
export async function acquireWakeLock(): Promise<void> {
|
async function request(wakeLock: WakeLock): Promise<void> {
|
||||||
const wakeLock = (
|
|
||||||
navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }
|
|
||||||
).wakeLock;
|
|
||||||
if (!wakeLock) return;
|
|
||||||
try {
|
try {
|
||||||
sentinel = await wakeLock.request('screen');
|
sentinel = await wakeLock.request('screen');
|
||||||
|
// The OS auto-releases the lock when the tab is hidden. Without clearing our handle
|
||||||
|
// on that event, the visibility handler's `sentinel === null` guard would never be
|
||||||
|
// true and the lock would never be re-acquired — the screen could then sleep after
|
||||||
|
// the first time the tab lost focus. Null it on release so re-acquire can fire.
|
||||||
|
sentinel.addEventListener?.('release', () => {
|
||||||
|
sentinel = null;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// User denied, or already released — nothing useful to do.
|
// User denied, or already released — nothing useful to do.
|
||||||
sentinel = null;
|
sentinel = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireWakeLock(): Promise<void> {
|
||||||
|
const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock;
|
||||||
|
if (!wakeLock) return;
|
||||||
|
await request(wakeLock);
|
||||||
|
|
||||||
// Re-acquire when the page becomes visible again (the OS releases the lock
|
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||||
// while the tab is hidden).
|
// while the tab is hidden).
|
||||||
if (!visibilityHandler) {
|
if (!visibilityHandler) {
|
||||||
visibilityHandler = async () => {
|
visibilityHandler = () => {
|
||||||
if (document.visibilityState === 'visible' && sentinel === null) {
|
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||||
try {
|
void request(wakeLock);
|
||||||
sentinel = await wakeLock.request('screen');
|
|
||||||
} catch {
|
|
||||||
sentinel = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', visibilityHandler);
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
|
|||||||
@@ -15,17 +15,36 @@
|
|||||||
|
|
||||||
let queue = new SlideQueue();
|
let queue = new SlideQueue();
|
||||||
let current = $state<FeedUpload | null>(null);
|
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 dwellMs = $state(6000);
|
||||||
let transitionId = $state('crossfade');
|
let transitionId = $state('crossfade');
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let showOverlay = $state(false);
|
let showOverlay = $state(false);
|
||||||
let overlayHideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const unsubs: Array<() => void> = [];
|
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 transitionDef = $derived(findTransition(transitionId));
|
||||||
|
|
||||||
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
|
|
||||||
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
||||||
|
|
||||||
function isEmpty(): boolean {
|
function isEmpty(): boolean {
|
||||||
@@ -49,10 +68,63 @@
|
|||||||
|
|
||||||
function advance() {
|
function advance() {
|
||||||
const next = queue.next();
|
const next = queue.next();
|
||||||
current = next;
|
showSlide(next);
|
||||||
if (current) scheduleNext();
|
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).
|
// 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
|
// 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.
|
// timer that already fired can't trigger this handler for a stale slide.
|
||||||
@@ -178,18 +250,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealOverlay() {
|
// Reveal the control cluster on any pointer/keyboard activity and re-arm the idle timer.
|
||||||
showOverlay = true;
|
// Controls stay up while the settings panel is open so it can't vanish mid-interaction.
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
function showControls() {
|
||||||
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
|
controlsVisible = true;
|
||||||
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
|
controlsHideTimer = setTimeout(() => {
|
||||||
|
if (!showOverlay) controlsVisible = false;
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual toggle from the always-visible control button — stays open (no
|
// Manual toggle from the control button — stays open (no auto-hide) so keyboard users
|
||||||
// auto-hide) so keyboard users can tab through the controls without them
|
// can tab through the settings without them vanishing mid-interaction.
|
||||||
// vanishing mid-interaction.
|
|
||||||
function toggleOverlay() {
|
function toggleOverlay() {
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
|
||||||
showOverlay = !showOverlay;
|
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() {
|
function togglePause() {
|
||||||
@@ -206,7 +297,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
showControls();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// In fullscreen the browser exits on Escape — don't ALSO navigate away.
|
||||||
|
if (isFullscreen) return;
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
showOverlay = false;
|
showOverlay = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -217,6 +311,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePause();
|
togglePause();
|
||||||
}
|
}
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFullscreen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -229,6 +327,8 @@
|
|||||||
}
|
}
|
||||||
showBottomNav.set(false);
|
showBottomNav.set(false);
|
||||||
void acquireWakeLock();
|
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-processed', handleUploadProcessed));
|
||||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||||
@@ -246,8 +346,12 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
showBottomNav.set(true);
|
showBottomNav.set(true);
|
||||||
clearTimer();
|
clearTimer();
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
if (processedDebounce) clearTimeout(processedDebounce);
|
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();
|
void releaseWakeLock();
|
||||||
for (const unsub of unsubs) unsub();
|
for (const unsub of unsubs) unsub();
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
@@ -257,19 +361,24 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={rootEl}
|
||||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
||||||
|
class:cursor-none={!controlsVisible}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={revealOverlay}
|
onpointermove={showControls}
|
||||||
|
onpointerdown={showControls}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if layers.length}
|
||||||
{#key current.id + '|' + transitionDef.id}
|
{#each layers as layer (layer.key)}
|
||||||
<transitionDef.component
|
<transitionDef.component
|
||||||
src={mediaSrc}
|
src={layer.src}
|
||||||
{isVideo}
|
isVideo={layer.isVideo}
|
||||||
durationMs={transitionDef.defaultDurationMs}
|
durationMs={transitionDef.defaultDurationMs}
|
||||||
onended={handleVideoEnded}
|
phase={layer.phase}
|
||||||
|
onended={layer.key === current?.id ? handleVideoEnded : undefined}
|
||||||
|
{...transitionDef.props ?? {}}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/each}
|
||||||
{:else if isEmpty()}
|
{:else if isEmpty()}
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
||||||
@@ -279,10 +388,12 @@
|
|||||||
<div class="text-white/60">Lade…</div>
|
<div class="text-white/60">Lade…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Always-visible controls: keyboard-reachable, so the show is never a trap and
|
<!-- Controls appear on pointer/keyboard activity and fade when idle. Still fully
|
||||||
the controls are reachable without a pointer. Honours the notch. -->
|
keyboard-reachable (any key reveals them), so the show is never a trap. Notch-safe. -->
|
||||||
<div
|
<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)"
|
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -303,6 +414,36 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user