feat(diashow): live slideshow with two-queue policy + pluggable transitions
A fullscreen auto-advancing slideshow any user can start. Design:
docs/CONCEPT_DIASHOW.md.
- lib/diashow/queue.ts: SlideQueue state machine — liveQueue drains first
(FIFO, seeded by SSE upload-processed), then shuffleQueue (refilled
from allKnown minus a 5-id ring buffer of recently shown). Pure logic,
unit-testable.
- lib/diashow/wakelock.ts: Screen Wake Lock wrapper that re-acquires on
visibility change (the OS drops the lock when the tab hides).
- lib/diashow/transitions/{index,crossfade,kenburns}.ts: registry +
the v1 transitions. Adding a new animation is one file + one entry —
the extensibility target from docs/FEATURES §2.9.
- routes/diashow/+page.svelte: fullscreen page, hides bottom nav,
6 s default dwell (3/6/10 configurable), keyboard shortcuts
(Escape exits, Space toggles pause), tap-to-reveal overlay with
pause / dwell / transition / exit. Respects $dataMode to choose
preview vs. original URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal file
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
// Simplest transition: render whatever the parent gave us, full-bleed, with a CSS
|
||||
// opacity fade. Tied to the parent's `{#key}` block — when the src changes, the
|
||||
// parent re-mounts this component and the `in:` transition runs.
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
isVideo: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
let { src, isVideo, durationMs }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black"
|
||||
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
{src}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-contain"
|
||||
></video>
|
||||
{:else}
|
||||
<img {src} alt="" class="h-full w-full object-contain" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes crossfade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/lib/diashow/transitions/index.ts
Normal file
47
frontend/src/lib/diashow/transitions/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Pluggable transition registry. The diashow consults this list to populate its
|
||||
// settings popover and to render the current transition.
|
||||
//
|
||||
// Adding a new animation:
|
||||
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
||||
// 2. Add one entry to `transitions` below.
|
||||
// 3. Rebuild — the popover updates automatically.
|
||||
//
|
||||
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
||||
// no diashow code needs to change to add an animation.
|
||||
|
||||
import type { Component } from 'svelte';
|
||||
import Crossfade from './crossfade.svelte';
|
||||
import KenBurns from './kenburns.svelte';
|
||||
|
||||
export interface SlideTransition {
|
||||
id: string;
|
||||
label: string;
|
||||
defaultDurationMs: number;
|
||||
component: Component<TransitionProps>;
|
||||
}
|
||||
|
||||
/** Props every transition Svelte component receives. */
|
||||
export interface TransitionProps {
|
||||
src: string;
|
||||
isVideo: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export const transitions: SlideTransition[] = [
|
||||
{
|
||||
id: 'crossfade',
|
||||
label: 'Überblendung',
|
||||
defaultDurationMs: 400,
|
||||
component: Crossfade as unknown as Component<TransitionProps>
|
||||
},
|
||||
{
|
||||
id: 'kenburns',
|
||||
label: 'Ken Burns',
|
||||
defaultDurationMs: 600,
|
||||
component: KenBurns as unknown as Component<TransitionProps>
|
||||
}
|
||||
];
|
||||
|
||||
export function findTransition(id: string): SlideTransition {
|
||||
return transitions.find((t) => t.id === id) ?? transitions[0];
|
||||
}
|
||||
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal file
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
// Crossfade + slow zoom and pan (the "Ken Burns" effect). Image-only — videos
|
||||
// fall back to the crossfade behaviour to avoid double-animation.
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
isVideo: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
let { src, isVideo, durationMs }: Props = $props();
|
||||
|
||||
// Mild random pan so each slide feels different. Range chosen so the image never
|
||||
// pans out of frame given the object-fit: cover.
|
||||
const panX = Math.round((Math.random() - 0.5) * 6); // -3% .. +3%
|
||||
const panY = Math.round((Math.random() - 0.5) * 6);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||
style="animation: kb-fade {durationMs}ms ease-out forwards;"
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
{src}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-contain"
|
||||
></video>
|
||||
{:else}
|
||||
<img
|
||||
{src}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes kb-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes kb-zoom {
|
||||
from { transform: scale(1.0); }
|
||||
to { transform: scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user