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:
106
frontend/src/lib/diashow/queue.ts
Normal file
106
frontend/src/lib/diashow/queue.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Two-queue slide state machine. Pure logic — no DOM, no Svelte, no network. Lets us
|
||||||
|
// unit-test the policy without spinning up a browser.
|
||||||
|
//
|
||||||
|
// Policy: live posts always drain first; the shuffle queue refills from `allKnown`
|
||||||
|
// (minus the most recent N items) once it empties. A new live post pushes onto the
|
||||||
|
// live queue and waits for the next slide transition — it never interrupts the
|
||||||
|
// current slide.
|
||||||
|
|
||||||
|
import type { FeedUpload } from '$lib/types';
|
||||||
|
|
||||||
|
const RECENT_RING_SIZE = 5;
|
||||||
|
|
||||||
|
export class SlideQueue {
|
||||||
|
/** Live queue — FIFO. New uploads land here via `pushLive`. */
|
||||||
|
private liveQueue: FeedUpload[] = [];
|
||||||
|
/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
|
||||||
|
private shuffleQueue: FeedUpload[] = [];
|
||||||
|
/** Every slide we've ever seen, keyed by id. Source for shuffle refills. */
|
||||||
|
private allKnown: Map<string, FeedUpload> = new Map();
|
||||||
|
/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
|
||||||
|
private recentlyShown: string[] = [];
|
||||||
|
|
||||||
|
/** Seed the shuffle pool from an initial fetch of the feed. */
|
||||||
|
seed(initial: FeedUpload[]): void {
|
||||||
|
for (const slide of initial) {
|
||||||
|
this.allKnown.set(slide.id, slide);
|
||||||
|
}
|
||||||
|
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a slide pushed by a new SSE upload-processed event. */
|
||||||
|
pushLive(slide: FeedUpload): void {
|
||||||
|
if (this.allKnown.has(slide.id)) return;
|
||||||
|
this.allKnown.set(slide.id, slide);
|
||||||
|
this.liveQueue.push(slide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pop the next slide. Returns null while both queues are empty (event has no posts). */
|
||||||
|
next(): FeedUpload | null {
|
||||||
|
// 1. Drain live first.
|
||||||
|
const live = this.liveQueue.shift();
|
||||||
|
if (live) {
|
||||||
|
this.markShown(live.id);
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
// 2. Refill shuffle queue from `allKnown` minus recently shown.
|
||||||
|
if (this.shuffleQueue.length === 0) {
|
||||||
|
this.shuffleQueue = shuffle(
|
||||||
|
Array.from(this.allKnown.values()).filter(
|
||||||
|
(s) => !this.recentlyShown.includes(s.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// If everything is recently shown (small event), fall back to the full pool.
|
||||||
|
if (this.shuffleQueue.length === 0) {
|
||||||
|
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = this.shuffleQueue.shift() ?? null;
|
||||||
|
if (next) this.markShown(next.id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a slide that was deleted or hidden. Returns true if it was the current
|
||||||
|
* "head" and the caller should advance immediately (UX: don't keep showing a
|
||||||
|
* post that the host just deleted).
|
||||||
|
*/
|
||||||
|
remove(id: string, currentId: string | null): { wasCurrent: boolean } {
|
||||||
|
this.allKnown.delete(id);
|
||||||
|
this.liveQueue = this.liveQueue.filter((s) => s.id !== id);
|
||||||
|
this.shuffleQueue = this.shuffleQueue.filter((s) => s.id !== id);
|
||||||
|
this.recentlyShown = this.recentlyShown.filter((sid) => sid !== id);
|
||||||
|
return { wasCurrent: currentId === id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a slide by id — for the diashow page to render the current slide. */
|
||||||
|
get(id: string): FeedUpload | undefined {
|
||||||
|
return this.allKnown.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot — useful for stats / debugging. */
|
||||||
|
stats() {
|
||||||
|
return {
|
||||||
|
known: this.allKnown.size,
|
||||||
|
live: this.liveQueue.length,
|
||||||
|
shuffle: this.shuffleQueue.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private markShown(id: string): void {
|
||||||
|
this.recentlyShown.push(id);
|
||||||
|
while (this.recentlyShown.length > RECENT_RING_SIZE) {
|
||||||
|
this.recentlyShown.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher–Yates. Mutates a copy of the input so callers can pass `allKnown.values()`. */
|
||||||
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
|
const out = arr.slice();
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
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>
|
||||||
54
frontend/src/lib/diashow/wakelock.ts
Normal file
54
frontend/src/lib/diashow/wakelock.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Thin wrapper around the Screen Wake Lock API. Held by the diashow page so a phone
|
||||||
|
// driving a projector doesn't sleep. No-op on unsupported browsers (Firefox, older
|
||||||
|
// Safari).
|
||||||
|
//
|
||||||
|
// Wake locks die when the document goes hidden; the page re-acquires on visible to
|
||||||
|
// keep the screen on across short interruptions.
|
||||||
|
|
||||||
|
interface SentinelLike {
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
sentinel = await wakeLock.request('screen');
|
||||||
|
} catch {
|
||||||
|
// User denied, or already released — nothing useful to do.
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||||
|
// while the tab is hidden).
|
||||||
|
if (!visibilityHandler) {
|
||||||
|
visibilityHandler = async () => {
|
||||||
|
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||||
|
try {
|
||||||
|
sentinel = await wakeLock.request('screen');
|
||||||
|
} catch {
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseWakeLock(): Promise<void> {
|
||||||
|
if (sentinel) {
|
||||||
|
try {
|
||||||
|
await sentinel.release();
|
||||||
|
} catch {
|
||||||
|
// ignore — release after release is fine
|
||||||
|
}
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
if (visibilityHandler) {
|
||||||
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||||
|
visibilityHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
frontend/src/routes/diashow/+page.svelte
Normal file
223
frontend/src/routes/diashow/+page.svelte
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
|
import { 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 } from '$lib/types';
|
||||||
|
|
||||||
|
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||||
|
|
||||||
|
let queue = new SlideQueue();
|
||||||
|
let current = $state<FeedUpload | null>(null);
|
||||||
|
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> = [];
|
||||||
|
|
||||||
|
const transitionDef = $derived(findTransition(transitionId));
|
||||||
|
|
||||||
|
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
const next = queue.next();
|
||||||
|
current = next;
|
||||||
|
if (current) scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitial() {
|
||||||
|
try {
|
||||||
|
const feed = await api.get<FeedResponse>('/feed?limit=200');
|
||||||
|
queue.seed(feed.uploads);
|
||||||
|
advance();
|
||||||
|
} catch {
|
||||||
|
// Silent — placeholder stays shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `upload-processed` carries only `{ upload_id }`; we re-fetch from /feed to get the
|
||||||
|
// preview/thumbnail URLs that just became available. We deliberately do NOT listen
|
||||||
|
// to `new-upload` here — its payload arrives before compression finishes
|
||||||
|
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
|
||||||
|
// preview would never be picked up if we enqueued the pre-processed version first.
|
||||||
|
async function handleUploadProcessed(data: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { upload_id: string };
|
||||||
|
if (!payload.upload_id) return;
|
||||||
|
const feed = await api.get<FeedResponse>('/feed?limit=20');
|
||||||
|
const found = feed.uploads.find((u) => u.id === payload.upload_id);
|
||||||
|
if (found) {
|
||||||
|
queue.pushLive(found);
|
||||||
|
if (!current) advance();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — silent recovery; the next event will retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadDeleted(data: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { upload_id: string };
|
||||||
|
const result = queue.remove(payload.upload_id, current?.id ?? null);
|
||||||
|
if (result.wasCurrent) advance();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealOverlay() {
|
||||||
|
showOverlay = true;
|
||||||
|
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||||
|
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePause() {
|
||||||
|
paused = !paused;
|
||||||
|
if (paused) {
|
||||||
|
clearTimer();
|
||||||
|
} else {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exit() {
|
||||||
|
void goto('/feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showOverlay) {
|
||||||
|
showOverlay = false;
|
||||||
|
} else {
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
showBottomNav.set(false);
|
||||||
|
void acquireWakeLock();
|
||||||
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||||
|
void loadInitial();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
showBottomNav.set(true);
|
||||||
|
clearTimer();
|
||||||
|
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||||
|
void releaseWakeLock();
|
||||||
|
for (const unsub of unsubs) unsub();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
||||||
|
role="presentation"
|
||||||
|
onclick={revealOverlay}
|
||||||
|
>
|
||||||
|
{#if current}
|
||||||
|
{#key current.id + '|' + transitionDef.id}
|
||||||
|
<transitionDef.component
|
||||||
|
src={mediaSrc}
|
||||||
|
{isVideo}
|
||||||
|
durationMs={transitionDef.defaultDurationMs}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{: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}
|
||||||
|
|
||||||
|
{#if showOverlay}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<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"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePause}
|
||||||
|
class="rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{paused ? '▶ Fortsetzen' : '⏸ Pause'}
|
||||||
|
</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 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||||
|
>
|
||||||
|
✕ 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>
|
||||||
Reference in New Issue
Block a user