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();
|
||||
</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
|
||||
class="absolute inset-0 flex items-center justify-center bg-black"
|
||||
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
|
||||
class="slide absolute inset-0 flex items-center justify-center bg-black"
|
||||
style="--fade-dur: {durationMs}ms"
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
@@ -27,6 +31,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slide {
|
||||
animation: crossfade-in var(--fade-dur, 400ms) ease-out forwards;
|
||||
}
|
||||
@keyframes crossfade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// Adding a new animation:
|
||||
// 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.
|
||||
//
|
||||
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
||||
@@ -12,12 +13,16 @@
|
||||
import type { Component } from 'svelte';
|
||||
import Crossfade from './crossfade.svelte';
|
||||
import KenBurns from './kenburns.svelte';
|
||||
import Slide from './slide.svelte';
|
||||
|
||||
export interface SlideTransition {
|
||||
id: string;
|
||||
label: string;
|
||||
defaultDurationMs: number;
|
||||
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. */
|
||||
@@ -27,6 +32,12 @@ export interface TransitionProps {
|
||||
durationMs: number;
|
||||
/** Fired when a video slide finishes so the parent can advance early. */
|
||||
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[] = [
|
||||
@@ -41,6 +52,27 @@ export const transitions: SlideTransition[] = [
|
||||
label: 'Ken Burns',
|
||||
defaultDurationMs: 600,
|
||||
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);
|
||||
</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
|
||||
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||
style="animation: kb-fade {durationMs}ms ease-out forwards;"
|
||||
class="kb absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||
style="--fade-dur: {durationMs}ms"
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
@@ -29,13 +34,19 @@
|
||||
<img
|
||||
{src}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
|
||||
class="kb-img h-full w-full object-cover"
|
||||
style="transform-origin: {50 + panX}% {50 + panY}%;"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kb {
|
||||
animation: kb-fade var(--fade-dur, 600ms) ease-out forwards;
|
||||
}
|
||||
.kb-img {
|
||||
animation: kb-zoom 10s ease-out forwards;
|
||||
}
|
||||
@keyframes kb-fade {
|
||||
from {
|
||||
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 {
|
||||
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 visibilityHandler: (() => void) | null = null;
|
||||
|
||||
export async function acquireWakeLock(): Promise<void> {
|
||||
const wakeLock = (
|
||||
navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }
|
||||
).wakeLock;
|
||||
if (!wakeLock) return;
|
||||
async function request(wakeLock: WakeLock): Promise<void> {
|
||||
try {
|
||||
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 {
|
||||
// User denied, or already released — nothing useful to do.
|
||||
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
|
||||
// while the tab is hidden).
|
||||
if (!visibilityHandler) {
|
||||
visibilityHandler = async () => {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||
try {
|
||||
sentinel = await wakeLock.request('screen');
|
||||
} catch {
|
||||
sentinel = null;
|
||||
}
|
||||
void request(wakeLock);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
|
||||
Reference in New Issue
Block a user