Add live show player playback and effects
This commit is contained in:
@@ -17,7 +17,11 @@
|
|||||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
|
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
|
||||||
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T11:11:36.821959901+01:00","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-539.4","title":"Live Show effects presets + background modes","description":"Map effect presets/intensity to animations and implement background modes (blur last, gradient, solid, brand).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T15:58:08.823403031+01:00","created_by":"soeren","updated_at":"2026-01-05T18:14:21.141791556+01:00","closed_at":"2026-01-05T18:14:21.141791556+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.4","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:58:08.841926692+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.4","depends_on_id":"fotospiel-app-539.3","type":"blocks","created_at":"2026-01-05T15:58:08.859783346+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-539.5","title":"Live Show player UX polish (fullscreen, states, performance)","description":"Add fullscreen/keyboard controls, loading/empty/offline states, and performance safeguards (preload, memory).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T15:58:22.340342615+01:00","created_by":"soeren","updated_at":"2026-01-05T18:28:03.575811673+01:00","closed_at":"2026-01-05T18:28:03.575811673+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:58:22.365600168+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:58:22.412769585+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539.3","type":"blocks","created_at":"2026-01-05T15:58:22.450984326+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-55n","title":"Tenant admin onboarding: add Paddle error UX + test coverage","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:40.463283816+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:40.463283816+01:00"}
|
{"id":"fotospiel-app-55n","title":"Tenant admin onboarding: add Paddle error UX + test coverage","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:40.463283816+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:40.463283816+01:00"}
|
||||||
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
||||||
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-exp
|
fotospiel-app-539.5
|
||||||
|
|||||||
69
resources/js/guest/components/LiveShowBackdrop.tsx
Normal file
69
resources/js/guest/components/LiveShowBackdrop.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { LiveShowBackgroundMode, LiveShowPhoto } from '../services/liveShowApi';
|
||||||
|
|
||||||
|
function resolvePhotoUrl(photo?: LiveShowPhoto | null): string | null {
|
||||||
|
if (!photo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return photo.full_url || photo.thumb_url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBlurAmount(intensity: number): number {
|
||||||
|
const safe = Number.isFinite(intensity) ? intensity : 70;
|
||||||
|
return 28 + Math.min(60, Math.max(0, safe)) * 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveShowBackdrop({
|
||||||
|
mode,
|
||||||
|
photo,
|
||||||
|
intensity,
|
||||||
|
}: {
|
||||||
|
mode: LiveShowBackgroundMode;
|
||||||
|
photo?: LiveShowPhoto | null;
|
||||||
|
intensity: number;
|
||||||
|
}) {
|
||||||
|
const photoUrl = resolvePhotoUrl(photo);
|
||||||
|
const blurAmount = resolveBlurAmount(intensity);
|
||||||
|
const fallbackMode = mode === 'blur_last' && !photoUrl ? 'gradient' : mode;
|
||||||
|
|
||||||
|
if (fallbackMode === 'solid') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
|
style={{ backgroundColor: 'rgb(8, 10, 16)' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackMode === 'gradient') {
|
||||||
|
return <div className="pointer-events-none absolute inset-0 z-0 bg-aurora-enhanced" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackMode === 'brand') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(120% 120% at 15% 15%, var(--guest-primary) 0%, rgba(0,0,0,0.8) 55%), radial-gradient(120% 120% at 85% 20%, var(--guest-secondary) 0%, transparent 60%)',
|
||||||
|
backgroundColor: 'rgb(5, 8, 16)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundImage: photoUrl ? `url(${photoUrl})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: `blur(${blurAmount}px) saturate(1.15)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
resources/js/guest/components/LiveShowStage.tsx
Normal file
80
resources/js/guest/components/LiveShowStage.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { LiveShowLayoutMode, LiveShowPhoto } from '../services/liveShowApi';
|
||||||
|
|
||||||
|
const BASE_TILE =
|
||||||
|
'relative overflow-hidden rounded-[28px] bg-black/70 shadow-[0_24px_70px_rgba(0,0,0,0.55)]';
|
||||||
|
|
||||||
|
function PhotoTile({
|
||||||
|
photo,
|
||||||
|
fit,
|
||||||
|
label,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
photo: LiveShowPhoto;
|
||||||
|
fit: 'cover' | 'contain';
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const src = photo.full_url || photo.thumb_url || '';
|
||||||
|
return (
|
||||||
|
<div className={`${BASE_TILE} ${className}`.trim()}>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
className={`h-full w-full object-${fit} object-center`}
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-sm text-white/60">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveShowStage({
|
||||||
|
layout,
|
||||||
|
photos,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
layout: LiveShowLayoutMode;
|
||||||
|
photos: LiveShowPhoto[];
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === 'single') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-6 py-12">
|
||||||
|
<PhotoTile
|
||||||
|
photo={photos[0]}
|
||||||
|
fit="contain"
|
||||||
|
label={title}
|
||||||
|
className="h-[62vh] w-full max-w-[1200px] sm:h-[72vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === 'split') {
|
||||||
|
return (
|
||||||
|
<div className="grid h-[72vh] w-full grid-cols-1 gap-6 px-6 py-12 lg:grid-cols-2">
|
||||||
|
{photos.slice(0, 2).map((photo) => (
|
||||||
|
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-[72vh] w-full grid-cols-2 grid-rows-2 gap-5 px-6 py-12">
|
||||||
|
{photos.slice(0, 4).map((photo) => (
|
||||||
|
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildFramePhotos,
|
||||||
|
resolveIntervalMs,
|
||||||
|
resolveItemsPerFrame,
|
||||||
|
resolvePlaybackQueue,
|
||||||
|
} from '../useLiveShowPlayback';
|
||||||
|
import type { LiveShowPhoto, LiveShowSettings } from '../../services/liveShowApi';
|
||||||
|
|
||||||
|
const baseSettings: LiveShowSettings = {
|
||||||
|
retention_window_hours: 12,
|
||||||
|
moderation_mode: 'manual',
|
||||||
|
playback_mode: 'newest_first',
|
||||||
|
pace_mode: 'auto',
|
||||||
|
fixed_interval_seconds: 8,
|
||||||
|
layout_mode: 'single',
|
||||||
|
effect_preset: 'film_cut',
|
||||||
|
effect_intensity: 70,
|
||||||
|
background_mode: 'blur_last',
|
||||||
|
};
|
||||||
|
|
||||||
|
const photos: LiveShowPhoto[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
full_url: '/one.jpg',
|
||||||
|
thumb_url: '/one-thumb.jpg',
|
||||||
|
approved_at: '2025-01-01T10:00:00Z',
|
||||||
|
is_featured: false,
|
||||||
|
live_priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
full_url: '/two.jpg',
|
||||||
|
thumb_url: '/two-thumb.jpg',
|
||||||
|
approved_at: '2025-01-01T12:00:00Z',
|
||||||
|
is_featured: true,
|
||||||
|
live_priority: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
full_url: '/three.jpg',
|
||||||
|
thumb_url: '/three-thumb.jpg',
|
||||||
|
approved_at: '2025-01-01T11:00:00Z',
|
||||||
|
is_featured: false,
|
||||||
|
live_priority: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('useLiveShowPlayback helpers', () => {
|
||||||
|
it('resolves items per frame per layout', () => {
|
||||||
|
expect(resolveItemsPerFrame('single')).toBe(1);
|
||||||
|
expect(resolveItemsPerFrame('split')).toBe(2);
|
||||||
|
expect(resolveItemsPerFrame('grid_burst')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a curated queue when configured', () => {
|
||||||
|
const queue = resolvePlaybackQueue(photos, {
|
||||||
|
...baseSettings,
|
||||||
|
playback_mode: 'curated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue[0].id).toBe(2);
|
||||||
|
expect(queue.every((photo) => photo.id === 2 || photo.live_priority > 0 || photo.is_featured)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds frame photos without duplicates when list is smaller', () => {
|
||||||
|
const frame = buildFramePhotos([photos[0]], 0, 4);
|
||||||
|
expect(frame).toHaveLength(1);
|
||||||
|
expect(frame[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fixed interval when configured', () => {
|
||||||
|
const interval = resolveIntervalMs(
|
||||||
|
{
|
||||||
|
...baseSettings,
|
||||||
|
pace_mode: 'fixed',
|
||||||
|
fixed_interval_seconds: 12,
|
||||||
|
},
|
||||||
|
photos.length
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(interval).toBe(12_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
229
resources/js/guest/hooks/useLiveShowPlayback.ts
Normal file
229
resources/js/guest/hooks/useLiveShowPlayback.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { LiveShowLayoutMode, LiveShowPhoto, LiveShowSettings } from '../services/liveShowApi';
|
||||||
|
|
||||||
|
const MIN_FIXED_SECONDS = 3;
|
||||||
|
const MAX_FIXED_SECONDS = 20;
|
||||||
|
|
||||||
|
function resolveApprovedAt(photo: LiveShowPhoto): number {
|
||||||
|
if (!photo.approved_at) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(photo.approved_at);
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePriority(photo: LiveShowPhoto): number {
|
||||||
|
return Number.isFinite(photo.live_priority) ? photo.live_priority : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveItemsPerFrame(layout: LiveShowLayoutMode): number {
|
||||||
|
switch (layout) {
|
||||||
|
case 'split':
|
||||||
|
return 2;
|
||||||
|
case 'grid_burst':
|
||||||
|
return 4;
|
||||||
|
case 'single':
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIntervalMs(settings: LiveShowSettings, totalCount: number): number {
|
||||||
|
if (settings.pace_mode === 'fixed') {
|
||||||
|
const safeSeconds = Math.min(MAX_FIXED_SECONDS, Math.max(MIN_FIXED_SECONDS, settings.fixed_interval_seconds));
|
||||||
|
return safeSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount >= 60) return 4500;
|
||||||
|
if (totalCount >= 30) return 5500;
|
||||||
|
if (totalCount >= 15) return 6500;
|
||||||
|
if (totalCount >= 6) return 7500;
|
||||||
|
return 9000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlaybackQueue(photos: LiveShowPhoto[], settings: LiveShowSettings): LiveShowPhoto[] {
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestFirst = [...photos].sort((a, b) => {
|
||||||
|
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return b.id - a.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings.playback_mode === 'newest_first') {
|
||||||
|
return newestFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.playback_mode === 'curated') {
|
||||||
|
const curated = photos.filter((photo) => photo.is_featured || resolvePriority(photo) > 0);
|
||||||
|
const base = curated.length > 0 ? curated : photos;
|
||||||
|
return [...base].sort((a, b) => {
|
||||||
|
const priorityDiff = resolvePriority(b) - resolvePriority(a);
|
||||||
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return b.id - a.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestFirst = [...photos].sort((a, b) => {
|
||||||
|
const timeDiff = resolveApprovedAt(a) - resolveApprovedAt(b);
|
||||||
|
if (timeDiff !== 0) return timeDiff;
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balanced: LiveShowPhoto[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let newestIndex = 0;
|
||||||
|
let oldestIndex = 0;
|
||||||
|
let newestStreak = 0;
|
||||||
|
|
||||||
|
while (balanced.length < photos.length) {
|
||||||
|
let added = false;
|
||||||
|
|
||||||
|
if (newestIndex < newestFirst.length && newestStreak < 2) {
|
||||||
|
const candidate = newestFirst[newestIndex++];
|
||||||
|
if (!seen.has(candidate.id)) {
|
||||||
|
balanced.push(candidate);
|
||||||
|
seen.add(candidate.id);
|
||||||
|
newestStreak += 1;
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
while (oldestIndex < oldestFirst.length && seen.has(oldestFirst[oldestIndex].id)) {
|
||||||
|
oldestIndex += 1;
|
||||||
|
}
|
||||||
|
if (oldestIndex < oldestFirst.length) {
|
||||||
|
const candidate = oldestFirst[oldestIndex++];
|
||||||
|
balanced.push(candidate);
|
||||||
|
seen.add(candidate.id);
|
||||||
|
newestStreak = 0;
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
while (newestIndex < newestFirst.length && seen.has(newestFirst[newestIndex].id)) {
|
||||||
|
newestIndex += 1;
|
||||||
|
}
|
||||||
|
if (newestIndex < newestFirst.length) {
|
||||||
|
const candidate = newestFirst[newestIndex++];
|
||||||
|
balanced.push(candidate);
|
||||||
|
seen.add(candidate.id);
|
||||||
|
newestStreak += 1;
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return balanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFramePhotos(
|
||||||
|
queue: LiveShowPhoto[],
|
||||||
|
startIndex: number,
|
||||||
|
itemsPerFrame: number
|
||||||
|
): LiveShowPhoto[] {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeCount = Math.min(itemsPerFrame, queue.length);
|
||||||
|
const result: LiveShowPhoto[] = [];
|
||||||
|
for (let offset = 0; offset < safeCount; offset += 1) {
|
||||||
|
const idx = (startIndex + offset) % queue.length;
|
||||||
|
result.push(queue[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveShowPlaybackState = {
|
||||||
|
frame: LiveShowPhoto[];
|
||||||
|
layout: LiveShowLayoutMode;
|
||||||
|
intervalMs: number;
|
||||||
|
frameKey: string;
|
||||||
|
nextFrame: LiveShowPhoto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLiveShowPlayback(
|
||||||
|
photos: LiveShowPhoto[],
|
||||||
|
settings: LiveShowSettings,
|
||||||
|
options: { paused?: boolean } = {}
|
||||||
|
): LiveShowPlaybackState {
|
||||||
|
const queue = useMemo(() => resolvePlaybackQueue(photos, settings), [photos, settings]);
|
||||||
|
const layout = settings.layout_mode;
|
||||||
|
const itemsPerFrame = resolveItemsPerFrame(layout);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const currentIdRef = useRef<number | null>(null);
|
||||||
|
const paused = Boolean(options.paused);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
setIndex(0);
|
||||||
|
currentIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIdRef.current !== null) {
|
||||||
|
const existingIndex = queue.findIndex((photo) => photo.id === currentIdRef.current);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
setIndex(existingIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIndex((prev) => prev % queue.length);
|
||||||
|
}, [queue]);
|
||||||
|
|
||||||
|
const frame = useMemo(() => {
|
||||||
|
const framePhotos = buildFramePhotos(queue, index, itemsPerFrame);
|
||||||
|
currentIdRef.current = framePhotos[0]?.id ?? null;
|
||||||
|
return framePhotos;
|
||||||
|
}, [queue, index, itemsPerFrame]);
|
||||||
|
|
||||||
|
const frameKey = useMemo(() => {
|
||||||
|
if (frame.length === 0) {
|
||||||
|
return `empty-${layout}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame.map((photo) => photo.id).join('-');
|
||||||
|
}, [frame, layout]);
|
||||||
|
|
||||||
|
const nextFrame = useMemo(() => {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return buildFramePhotos(queue, index + itemsPerFrame, itemsPerFrame);
|
||||||
|
}, [index, itemsPerFrame, queue]);
|
||||||
|
|
||||||
|
const intervalMs = resolveIntervalMs(settings, queue.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length === 0 || paused) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setIndex((prev) => (prev + itemsPerFrame) % queue.length);
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [intervalMs, itemsPerFrame, queue.length]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
frame,
|
||||||
|
layout,
|
||||||
|
intervalMs,
|
||||||
|
frameKey,
|
||||||
|
nextFrame,
|
||||||
|
};
|
||||||
|
}
|
||||||
317
resources/js/guest/hooks/useLiveShowState.ts
Normal file
317
resources/js/guest/hooks/useLiveShowState.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
buildLiveShowStreamUrl,
|
||||||
|
DEFAULT_LIVE_SHOW_SETTINGS,
|
||||||
|
fetchLiveShowState,
|
||||||
|
fetchLiveShowUpdates,
|
||||||
|
LiveShowCursor,
|
||||||
|
LiveShowEvent,
|
||||||
|
LiveShowPhoto,
|
||||||
|
LiveShowSettings,
|
||||||
|
LiveShowState,
|
||||||
|
LiveShowError,
|
||||||
|
} from '../services/liveShowApi';
|
||||||
|
|
||||||
|
export type LiveShowStatus = 'loading' | 'ready' | 'error';
|
||||||
|
export type LiveShowConnection = 'idle' | 'sse' | 'polling';
|
||||||
|
|
||||||
|
const MAX_PHOTOS = 200;
|
||||||
|
const POLL_INTERVAL_MS = 12_000;
|
||||||
|
const POLL_HIDDEN_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
function mergePhotos(existing: LiveShowPhoto[], incoming: LiveShowPhoto[]): LiveShowPhoto[] {
|
||||||
|
if (incoming.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map<number, LiveShowPhoto>();
|
||||||
|
existing.forEach((photo) => byId.set(photo.id, photo));
|
||||||
|
incoming.forEach((photo) => {
|
||||||
|
if (!byId.has(photo.id)) {
|
||||||
|
byId.set(photo.id, photo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(byId.values()).slice(-MAX_PHOTOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof LiveShowError) {
|
||||||
|
return error.message || 'Live Show konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || 'Live Show konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Live Show konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson<T>(value: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Live show event payload parse failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveShowStateResult = {
|
||||||
|
status: LiveShowStatus;
|
||||||
|
connection: LiveShowConnection;
|
||||||
|
error: string | null;
|
||||||
|
event: LiveShowEvent | null;
|
||||||
|
settings: LiveShowSettings;
|
||||||
|
settingsVersion: string;
|
||||||
|
photos: LiveShowPhoto[];
|
||||||
|
cursor: LiveShowCursor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLiveShowState(token: string | null, limit = 50): LiveShowStateResult {
|
||||||
|
const [status, setStatus] = useState<LiveShowStatus>('loading');
|
||||||
|
const [connection, setConnection] = useState<LiveShowConnection>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [event, setEvent] = useState<LiveShowEvent | null>(null);
|
||||||
|
const [settings, setSettings] = useState<LiveShowSettings>(DEFAULT_LIVE_SHOW_SETTINGS);
|
||||||
|
const [settingsVersion, setSettingsVersion] = useState('');
|
||||||
|
const [photos, setPhotos] = useState<LiveShowPhoto[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<LiveShowCursor | null>(null);
|
||||||
|
const [visible, setVisible] = useState(
|
||||||
|
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||||
|
);
|
||||||
|
const cursorRef = useRef<LiveShowCursor | null>(null);
|
||||||
|
const settingsVersionRef = useRef<string>('');
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const pollingTimerRef = useRef<number | null>(null);
|
||||||
|
const pollInFlight = useRef(false);
|
||||||
|
|
||||||
|
const updateCursor = useCallback((next: LiveShowCursor | null) => {
|
||||||
|
cursorRef.current = next;
|
||||||
|
setCursor(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applySettings = useCallback((nextSettings: LiveShowSettings, nextVersion: string) => {
|
||||||
|
setSettings(nextSettings);
|
||||||
|
setSettingsVersion(nextVersion);
|
||||||
|
settingsVersionRef.current = nextVersion;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyPhotos = useCallback(
|
||||||
|
(incoming: LiveShowPhoto[], nextCursor?: LiveShowCursor | null) => {
|
||||||
|
if (incoming.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhotos((existing) => mergePhotos(existing, incoming));
|
||||||
|
if (nextCursor) {
|
||||||
|
updateCursor(nextCursor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateCursor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeEventSource = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPolling = useCallback(() => {
|
||||||
|
if (pollingTimerRef.current !== null) {
|
||||||
|
window.clearInterval(pollingTimerRef.current);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pollUpdates = useCallback(async () => {
|
||||||
|
if (!token || pollInFlight.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pollInFlight.current = true;
|
||||||
|
try {
|
||||||
|
const update = await fetchLiveShowUpdates(token, {
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
settingsVersion: settingsVersionRef.current,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (update.settings) {
|
||||||
|
applySettings(update.settings, update.settings_version);
|
||||||
|
} else if (update.settings_version && update.settings_version !== settingsVersionRef.current) {
|
||||||
|
settingsVersionRef.current = update.settings_version;
|
||||||
|
setSettingsVersion(update.settings_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.photos.length > 0) {
|
||||||
|
applyPhotos(update.photos, update.cursor ?? cursorRef.current);
|
||||||
|
} else if (update.cursor) {
|
||||||
|
updateCursor(update.cursor);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Live show polling error:', err);
|
||||||
|
} finally {
|
||||||
|
pollInFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [applyPhotos, applySettings, limit, token, updateCursor]);
|
||||||
|
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
clearPolling();
|
||||||
|
setConnection('polling');
|
||||||
|
void pollUpdates();
|
||||||
|
const interval = visible ? POLL_INTERVAL_MS : POLL_HIDDEN_INTERVAL_MS;
|
||||||
|
pollingTimerRef.current = window.setInterval(() => {
|
||||||
|
void pollUpdates();
|
||||||
|
}, interval);
|
||||||
|
}, [clearPolling, pollUpdates, visible]);
|
||||||
|
|
||||||
|
const startSse = useCallback(() => {
|
||||||
|
if (!token || typeof EventSource === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEventSource();
|
||||||
|
|
||||||
|
const url = buildLiveShowStreamUrl(token, {
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
settingsVersion: settingsVersionRef.current,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = new EventSource(url);
|
||||||
|
eventSourceRef.current = stream;
|
||||||
|
setConnection('sse');
|
||||||
|
|
||||||
|
stream.addEventListener('settings.updated', (event) => {
|
||||||
|
const payload = safeParseJson<{
|
||||||
|
settings?: LiveShowSettings;
|
||||||
|
settings_version?: string;
|
||||||
|
}>((event as MessageEvent<string>).data);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.settings && payload.settings_version) {
|
||||||
|
applySettings(payload.settings, payload.settings_version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.addEventListener('photo.approved', (event) => {
|
||||||
|
const payload = safeParseJson<{
|
||||||
|
photo?: LiveShowPhoto;
|
||||||
|
cursor?: LiveShowCursor | null;
|
||||||
|
}>((event as MessageEvent<string>).data);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.photo) {
|
||||||
|
applyPhotos([payload.photo], payload.cursor ?? null);
|
||||||
|
} else if (payload.cursor) {
|
||||||
|
updateCursor(payload.cursor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.addEventListener('error', () => {
|
||||||
|
closeEventSource();
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Live show SSE failed:', err);
|
||||||
|
closeEventSource();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [applyPhotos, applySettings, closeEventSource, limit, startPolling, token, updateCursor]);
|
||||||
|
|
||||||
|
const startStreaming = useCallback(() => {
|
||||||
|
clearPolling();
|
||||||
|
|
||||||
|
if (!startSse()) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
}, [clearPolling, startPolling, startSse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => setVisible(document.visibilityState === 'visible');
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connection !== 'polling') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling();
|
||||||
|
}, [connection, startPolling, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error');
|
||||||
|
setError('Live Show konnte nicht geladen werden.');
|
||||||
|
setEvent(null);
|
||||||
|
setPhotos([]);
|
||||||
|
updateCursor(null);
|
||||||
|
setSettings(DEFAULT_LIVE_SHOW_SETTINGS);
|
||||||
|
setSettingsVersion('');
|
||||||
|
setConnection('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setError(null);
|
||||||
|
setConnection('idle');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: LiveShowState = await fetchLiveShowState(token, limit);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvent(data.event);
|
||||||
|
setPhotos(data.photos);
|
||||||
|
updateCursor(data.cursor);
|
||||||
|
applySettings(data.settings, data.settings_version);
|
||||||
|
setStatus('ready');
|
||||||
|
startStreaming();
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('error');
|
||||||
|
setError(resolveErrorMessage(err));
|
||||||
|
setConnection('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
closeEventSource();
|
||||||
|
clearPolling();
|
||||||
|
setConnection('idle');
|
||||||
|
};
|
||||||
|
}, [applySettings, clearPolling, closeEventSource, limit, startStreaming, token, updateCursor]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
status,
|
||||||
|
connection,
|
||||||
|
error,
|
||||||
|
event,
|
||||||
|
settings,
|
||||||
|
settingsVersion,
|
||||||
|
photos,
|
||||||
|
cursor,
|
||||||
|
}),
|
||||||
|
[status, connection, error, event, settings, settingsVersion, photos, cursor]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,30 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
tabStatus: 'Upload-Status',
|
tabStatus: 'Upload-Status',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
liveShowPlayer: {
|
||||||
|
title: 'Live Show',
|
||||||
|
loading: 'Live Show wird geladen...',
|
||||||
|
connection: {
|
||||||
|
live: 'Live',
|
||||||
|
sync: 'Sync',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
play: 'Weiter',
|
||||||
|
pause: 'Pause',
|
||||||
|
fullscreen: 'Vollbild',
|
||||||
|
exitFullscreen: 'Vollbild verlassen',
|
||||||
|
offline: 'Offline',
|
||||||
|
paused: 'Pausiert',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
title: 'Noch keine Live-Fotos',
|
||||||
|
description: 'Warte auf die ersten Uploads...',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: 'Live Show nicht erreichbar',
|
||||||
|
description: 'Bitte überprüfe den Live-Link.',
|
||||||
|
},
|
||||||
|
},
|
||||||
eventAccess: {
|
eventAccess: {
|
||||||
loading: {
|
loading: {
|
||||||
title: 'Wir prüfen deinen Zugang...',
|
title: 'Wir prüfen deinen Zugang...',
|
||||||
@@ -753,6 +777,30 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
tabStatus: 'Upload status',
|
tabStatus: 'Upload status',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
liveShowPlayer: {
|
||||||
|
title: 'Live Show',
|
||||||
|
loading: 'Loading Live Show...',
|
||||||
|
connection: {
|
||||||
|
live: 'Live',
|
||||||
|
sync: 'Sync',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
play: 'Play',
|
||||||
|
pause: 'Pause',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
exitFullscreen: 'Exit fullscreen',
|
||||||
|
offline: 'Offline',
|
||||||
|
paused: 'Paused',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
title: 'No live photos yet',
|
||||||
|
description: 'Waiting for the first uploads...',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: 'Live Show unavailable',
|
||||||
|
description: 'Please check the live link.',
|
||||||
|
},
|
||||||
|
},
|
||||||
eventAccess: {
|
eventAccess: {
|
||||||
loading: {
|
loading: {
|
||||||
title: 'Checking your access...',
|
title: 'Checking your access...',
|
||||||
|
|||||||
22
resources/js/guest/lib/__tests__/liveShowEffects.test.ts
Normal file
22
resources/js/guest/lib/__tests__/liveShowEffects.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveLiveShowEffect } from '../liveShowEffects';
|
||||||
|
|
||||||
|
describe('resolveLiveShowEffect', () => {
|
||||||
|
it('adds flash overlay for shutter flash preset', () => {
|
||||||
|
const effect = resolveLiveShowEffect('shutter_flash', 80, false);
|
||||||
|
expect(effect.flash).toBeDefined();
|
||||||
|
expect(effect.frame.initial).toBeDefined();
|
||||||
|
expect(effect.frame.animate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps light effects simple without flash', () => {
|
||||||
|
const effect = resolveLiveShowEffect('light_effects', 80, false);
|
||||||
|
expect(effect.flash).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors reduced motion with basic fade', () => {
|
||||||
|
const effect = resolveLiveShowEffect('film_cut', 80, true);
|
||||||
|
expect(effect.flash).toBeUndefined();
|
||||||
|
expect(effect.frame.initial).toEqual({ opacity: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
107
resources/js/guest/lib/liveShowEffects.ts
Normal file
107
resources/js/guest/lib/liveShowEffects.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { MotionProps, Transition } from 'framer-motion';
|
||||||
|
import { IOS_EASE, IOS_EASE_SOFT } from './motion';
|
||||||
|
import type { LiveShowEffectPreset } from '../services/liveShowApi';
|
||||||
|
|
||||||
|
export type LiveShowEffectSpec = {
|
||||||
|
frame: MotionProps;
|
||||||
|
flash?: MotionProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIntensity(intensity: number): number {
|
||||||
|
const safe = Number.isFinite(intensity) ? intensity : 70;
|
||||||
|
return clamp(safe / 100, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransition(duration: number, ease: Transition['ease']): Transition {
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
ease,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLiveShowEffect(
|
||||||
|
preset: LiveShowEffectPreset,
|
||||||
|
intensity: number,
|
||||||
|
reducedMotion: boolean
|
||||||
|
): LiveShowEffectSpec {
|
||||||
|
const strength = reducedMotion ? 0 : resolveIntensity(intensity);
|
||||||
|
const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
|
||||||
|
const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
|
||||||
|
|
||||||
|
if (reducedMotion) {
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
animate: { opacity: 1 },
|
||||||
|
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||||
|
transition: buildTransition(baseDuration, IOS_EASE_SOFT),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case 'shutter_flash': {
|
||||||
|
const scale = 1 + strength * 0.05;
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0, scale, y: 12 * strength },
|
||||||
|
animate: { opacity: 1, scale: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||||
|
transition: buildTransition(baseDuration, IOS_EASE),
|
||||||
|
},
|
||||||
|
flash: {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'polaroid_toss': {
|
||||||
|
const rotation = 3 + strength * 5;
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
|
||||||
|
animate: { opacity: 1, rotate: 0, scale: 1 },
|
||||||
|
exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||||
|
transition: buildTransition(baseDuration, IOS_EASE),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'parallax_glide': {
|
||||||
|
const scale = 1 + strength * 0.06;
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0, scale, y: 24 * strength },
|
||||||
|
animate: { opacity: 1, scale: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||||
|
transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'light_effects': {
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
animate: { opacity: 1 },
|
||||||
|
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
|
||||||
|
transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'film_cut':
|
||||||
|
default: {
|
||||||
|
const scale = 1 + strength * 0.03;
|
||||||
|
return {
|
||||||
|
frame: {
|
||||||
|
initial: { opacity: 0, scale },
|
||||||
|
animate: { opacity: 1, scale: 1 },
|
||||||
|
exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
|
||||||
|
transition: buildTransition(baseDuration, IOS_EASE),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
240
resources/js/guest/pages/LiveShowPlayerPage.tsx
Normal file
240
resources/js/guest/pages/LiveShowPlayerPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Loader2, Maximize2, Minimize2, Pause, Play, WifiOff } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useLiveShowState } from '../hooks/useLiveShowState';
|
||||||
|
import { useLiveShowPlayback } from '../hooks/useLiveShowPlayback';
|
||||||
|
import LiveShowStage from '../components/LiveShowStage';
|
||||||
|
import LiveShowBackdrop from '../components/LiveShowBackdrop';
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { prefersReducedMotion } from '../lib/motion';
|
||||||
|
import { resolveLiveShowEffect } from '../lib/liveShowEffects';
|
||||||
|
|
||||||
|
export default function LiveShowPlayerPage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { status, connection, error, event, photos, settings } = useLiveShowState(token ?? null);
|
||||||
|
const [paused, setPaused] = React.useState(false);
|
||||||
|
const { frame, layout, frameKey, nextFrame } = useLiveShowPlayback(photos, settings, { paused });
|
||||||
|
const hasPhoto = frame.length > 0;
|
||||||
|
const stageTitle = event?.name ?? t('liveShowPlayer.title', 'Live Show');
|
||||||
|
const reducedMotion = prefersReducedMotion();
|
||||||
|
const effect = resolveLiveShowEffect(settings.effect_preset, settings.effect_intensity, reducedMotion);
|
||||||
|
const showStage = status === 'ready' && hasPhoto;
|
||||||
|
const showEmpty = status === 'ready' && !hasPhoto;
|
||||||
|
const [controlsVisible, setControlsVisible] = React.useState(true);
|
||||||
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||||
|
const [isOnline, setIsOnline] = React.useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
||||||
|
const hideTimerRef = React.useRef<number | null>(null);
|
||||||
|
const preloadRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const stageRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.body.classList.add('guest-immersive');
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('guest-immersive');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateOnline = () => setIsOnline(navigator.onLine);
|
||||||
|
window.addEventListener('online', updateOnline);
|
||||||
|
window.addEventListener('offline', updateOnline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', updateOnline);
|
||||||
|
window.removeEventListener('offline', updateOnline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreen);
|
||||||
|
handleFullscreen();
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revealControls = React.useCallback(() => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideTimerRef.current) {
|
||||||
|
window.clearTimeout(hideTimerRef.current);
|
||||||
|
}
|
||||||
|
hideTimerRef.current = window.setTimeout(() => {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!showStage) {
|
||||||
|
setControlsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revealControls();
|
||||||
|
}, [revealControls, showStage, frameKey]);
|
||||||
|
|
||||||
|
const togglePause = React.useCallback(() => {
|
||||||
|
setPaused((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = React.useCallback(async () => {
|
||||||
|
const target = stageRef.current ?? document.documentElement;
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await target.requestFullscreen?.();
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Fullscreen toggle failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.target && (event.target as HTMLElement).closest('input, textarea, select, button')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
togglePause();
|
||||||
|
revealControls();
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === 'f') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleFullscreen();
|
||||||
|
revealControls();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && document.fullscreenElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.exitFullscreen?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [revealControls, toggleFullscreen, togglePause]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const candidates = [...frame, ...nextFrame].slice(0, 6);
|
||||||
|
candidates.forEach((photo) => {
|
||||||
|
const src = photo.full_url || photo.thumb_url;
|
||||||
|
if (!src || preloadRef.current.has(src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
preloadRef.current.add(src);
|
||||||
|
});
|
||||||
|
}, [frame, nextFrame]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white"
|
||||||
|
aria-busy={status === 'loading'}
|
||||||
|
onMouseMove={revealControls}
|
||||||
|
onTouchStart={revealControls}
|
||||||
|
>
|
||||||
|
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
|
||||||
|
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
|
||||||
|
{stageTitle}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
|
||||||
|
{connection === 'sse'
|
||||||
|
? t('liveShowPlayer.connection.live', 'Live')
|
||||||
|
: t('liveShowPlayer.connection.sync', 'Sync')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-white/70">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
|
||||||
|
<p className="text-sm">{t('liveShowPlayer.loading', 'Live Show wird geladen...')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="max-w-md space-y-2 px-6 text-center">
|
||||||
|
<p className="text-lg font-semibold text-white">
|
||||||
|
{t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false} mode="sync">
|
||||||
|
{showStage && (
|
||||||
|
<motion.div key={frameKey} className="relative z-10 flex min-h-0 w-full flex-1 items-stretch" {...effect.frame}>
|
||||||
|
<LiveShowStage layout={layout} photos={frame} title={stageTitle} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{showStage && effect.flash && (
|
||||||
|
<motion.div
|
||||||
|
key={`flash-${frameKey}`}
|
||||||
|
className="pointer-events-none absolute inset-0 z-20 bg-white"
|
||||||
|
{...effect.flash}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{controlsVisible && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||||
|
onClick={togglePause}
|
||||||
|
>
|
||||||
|
{paused ? <Play className="h-4 w-4" aria-hidden /> : <Pause className="h-4 w-4" aria-hidden />}
|
||||||
|
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="h-4 w-4" aria-hidden /> : <Maximize2 className="h-4 w-4" aria-hidden />}
|
||||||
|
<span>
|
||||||
|
{isFullscreen
|
||||||
|
? t('liveShowPlayer.controls.exitFullscreen', 'Exit fullscreen')
|
||||||
|
: t('liveShowPlayer.controls.fullscreen', 'Fullscreen')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!isOnline && (
|
||||||
|
<span className="flex items-center gap-2 text-white/70">
|
||||||
|
<WifiOff className="h-4 w-4" aria-hidden />
|
||||||
|
{t('liveShowPlayer.controls.offline', 'Offline')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{paused && showStage && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||||
|
<div className="rounded-full border border-white/20 bg-black/50 px-6 py-3 text-sm font-semibold uppercase tracking-[0.3em] text-white/80">
|
||||||
|
{t('liveShowPlayer.controls.paused', 'Paused')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmpty && (
|
||||||
|
<div className="max-w-md space-y-2 px-6 text-center text-white/70">
|
||||||
|
<p className="text-lg font-semibold text-white">
|
||||||
|
{t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import LiveShowPlayerPage from '../LiveShowPlayerPage';
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useLiveShowState', () => ({
|
||||||
|
useLiveShowState: () => ({
|
||||||
|
status: 'ready',
|
||||||
|
connection: 'polling',
|
||||||
|
error: null,
|
||||||
|
event: { id: 1, name: 'Showcase' },
|
||||||
|
photos: [],
|
||||||
|
settings: {
|
||||||
|
retention_window_hours: 12,
|
||||||
|
moderation_mode: 'manual',
|
||||||
|
playback_mode: 'newest_first',
|
||||||
|
pace_mode: 'auto',
|
||||||
|
fixed_interval_seconds: 8,
|
||||||
|
layout_mode: 'single',
|
||||||
|
effect_preset: 'film_cut',
|
||||||
|
effect_intensity: 70,
|
||||||
|
background_mode: 'gradient',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useLiveShowPlayback', () => ({
|
||||||
|
useLiveShowPlayback: () => ({
|
||||||
|
frame: [],
|
||||||
|
nextFrame: [],
|
||||||
|
layout: 'single',
|
||||||
|
frameKey: 'empty',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../i18n/useTranslation', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback: string) => fallback,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LiveShowPlayerPage', () => {
|
||||||
|
it('renders empty state when no photos', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/show/demo']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/show/:token" element={<LiveShowPlayerPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Noch keine Live-Fotos')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ const GalleryPage = React.lazy(() => import('./pages/GalleryPage'));
|
|||||||
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
|
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
|
||||||
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
|
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
|
||||||
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
|
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
|
||||||
|
const LiveShowPlayerPage = React.lazy(() => import('./pages/LiveShowPlayerPage'));
|
||||||
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
|
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
|
||||||
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
|
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
|
||||||
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
||||||
@@ -62,6 +63,7 @@ function HomeLayout() {
|
|||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout>, errorElement: <RouteErrorElement /> },
|
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout>, errorElement: <RouteErrorElement /> },
|
||||||
{ path: '/share/:slug', element: <SharedPhotoPage />, errorElement: <RouteErrorElement /> },
|
{ path: '/share/:slug', element: <SharedPhotoPage />, errorElement: <RouteErrorElement /> },
|
||||||
|
{ path: '/show/:token', element: <LiveShowPlayerPage />, errorElement: <RouteErrorElement /> },
|
||||||
{
|
{
|
||||||
path: '/setup/:token',
|
path: '/setup/:token',
|
||||||
element: <SetupLayout />,
|
element: <SetupLayout />,
|
||||||
|
|||||||
34
resources/js/guest/services/__tests__/liveShowApi.test.ts
Normal file
34
resources/js/guest/services/__tests__/liveShowApi.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildLiveShowStreamUrl,
|
||||||
|
DEFAULT_LIVE_SHOW_SETTINGS,
|
||||||
|
normalizeLiveShowSettings,
|
||||||
|
} from '../liveShowApi';
|
||||||
|
|
||||||
|
describe('liveShowApi', () => {
|
||||||
|
it('merges live show settings with defaults', () => {
|
||||||
|
const result = normalizeLiveShowSettings({
|
||||||
|
fixed_interval_seconds: 12,
|
||||||
|
effect_intensity: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fixed_interval_seconds).toBe(12);
|
||||||
|
expect(result.effect_intensity).toBe(15);
|
||||||
|
expect(result.layout_mode).toBe(DEFAULT_LIVE_SHOW_SETTINGS.layout_mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds stream url with query params', () => {
|
||||||
|
const url = buildLiveShowStreamUrl('demo-token', {
|
||||||
|
cursor: { approved_at: '2025-01-01T00:00:00Z', id: 42 },
|
||||||
|
settingsVersion: 'abc',
|
||||||
|
limit: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = new URL(url, 'http://example.test');
|
||||||
|
expect(parsed.pathname).toBe('/api/v1/live-show/demo-token/stream');
|
||||||
|
expect(parsed.searchParams.get('after_approved_at')).toBe('2025-01-01T00:00:00Z');
|
||||||
|
expect(parsed.searchParams.get('after_id')).toBe('42');
|
||||||
|
expect(parsed.searchParams.get('settings_version')).toBe('abc');
|
||||||
|
expect(parsed.searchParams.get('limit')).toBe('60');
|
||||||
|
});
|
||||||
|
});
|
||||||
302
resources/js/guest/services/liveShowApi.ts
Normal file
302
resources/js/guest/services/liveShowApi.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only';
|
||||||
|
export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated';
|
||||||
|
export type LiveShowPaceMode = 'auto' | 'fixed';
|
||||||
|
export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst';
|
||||||
|
export type LiveShowEffectPreset =
|
||||||
|
| 'film_cut'
|
||||||
|
| 'shutter_flash'
|
||||||
|
| 'polaroid_toss'
|
||||||
|
| 'parallax_glide'
|
||||||
|
| 'light_effects';
|
||||||
|
export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand';
|
||||||
|
|
||||||
|
export type LiveShowSettings = {
|
||||||
|
retention_window_hours: number;
|
||||||
|
moderation_mode: LiveShowModerationMode;
|
||||||
|
playback_mode: LiveShowPlaybackMode;
|
||||||
|
pace_mode: LiveShowPaceMode;
|
||||||
|
fixed_interval_seconds: number;
|
||||||
|
layout_mode: LiveShowLayoutMode;
|
||||||
|
effect_preset: LiveShowEffectPreset;
|
||||||
|
effect_intensity: number;
|
||||||
|
background_mode: LiveShowBackgroundMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowCursor = {
|
||||||
|
approved_at: string | null;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowEvent = {
|
||||||
|
id: number;
|
||||||
|
slug?: string | null;
|
||||||
|
name: string;
|
||||||
|
default_locale?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowPhoto = {
|
||||||
|
id: number;
|
||||||
|
full_url: string;
|
||||||
|
thumb_url: string | null;
|
||||||
|
approved_at: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
is_featured: boolean;
|
||||||
|
live_priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowState = {
|
||||||
|
event: LiveShowEvent;
|
||||||
|
settings: LiveShowSettings;
|
||||||
|
settings_version: string;
|
||||||
|
photos: LiveShowPhoto[];
|
||||||
|
cursor: LiveShowCursor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowUpdates = {
|
||||||
|
settings: LiveShowSettings | null;
|
||||||
|
settings_version: string;
|
||||||
|
photos: LiveShowPhoto[];
|
||||||
|
cursor: LiveShowCursor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown';
|
||||||
|
|
||||||
|
export class LiveShowError extends Error {
|
||||||
|
readonly code: LiveShowErrorCode;
|
||||||
|
readonly status?: number;
|
||||||
|
|
||||||
|
constructor(code: LiveShowErrorCode, message: string, status?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'LiveShowError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = {
|
||||||
|
retention_window_hours: 12,
|
||||||
|
moderation_mode: 'manual',
|
||||||
|
playback_mode: 'newest_first',
|
||||||
|
pace_mode: 'auto',
|
||||||
|
fixed_interval_seconds: 8,
|
||||||
|
layout_mode: 'single',
|
||||||
|
effect_preset: 'film_cut',
|
||||||
|
effect_intensity: 70,
|
||||||
|
background_mode: 'blur_last',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EVENT_NAME = 'Fotospiel Live Show';
|
||||||
|
|
||||||
|
function coerceLocalized(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const preferredKeys = ['de', 'en'];
|
||||||
|
|
||||||
|
for (const key of preferredKeys) {
|
||||||
|
const candidate = obj[key];
|
||||||
|
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
||||||
|
if (typeof firstString === 'string') {
|
||||||
|
return firstString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLiveShowSettings(raw?: Partial<LiveShowSettings> | null): LiveShowSettings {
|
||||||
|
const merged = {
|
||||||
|
...DEFAULT_LIVE_SHOW_SETTINGS,
|
||||||
|
...(raw ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours),
|
||||||
|
moderation_mode: merged.moderation_mode,
|
||||||
|
playback_mode: merged.playback_mode,
|
||||||
|
pace_mode: merged.pace_mode,
|
||||||
|
fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds),
|
||||||
|
layout_mode: merged.layout_mode,
|
||||||
|
effect_preset: merged.effect_preset,
|
||||||
|
effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity),
|
||||||
|
background_mode: merged.background_mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLiveShowEvent(raw: Record<string, unknown>): LiveShowEvent {
|
||||||
|
return {
|
||||||
|
id: Number(raw.id ?? 0),
|
||||||
|
slug: typeof raw.slug === 'string' ? raw.slug : null,
|
||||||
|
name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME),
|
||||||
|
default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLiveShowPhoto(raw: Record<string, unknown>): LiveShowPhoto {
|
||||||
|
return {
|
||||||
|
id: Number(raw.id ?? 0),
|
||||||
|
full_url: String(raw.full_url ?? ''),
|
||||||
|
thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null,
|
||||||
|
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
|
||||||
|
width: typeof raw.width === 'number' ? raw.width : null,
|
||||||
|
height: typeof raw.height === 'number' ? raw.height : null,
|
||||||
|
is_featured: Boolean(raw.is_featured),
|
||||||
|
live_priority: Number(raw.live_priority ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCursor(raw: Record<string, unknown> | null): LiveShowCursor | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
|
||||||
|
id: Number(raw.id ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorCode(status: number, payload: Record<string, unknown> | null): LiveShowErrorCode {
|
||||||
|
const error = typeof payload?.error === 'string' ? payload.error : null;
|
||||||
|
|
||||||
|
if (error === 'live_show_not_found' || status === 404) {
|
||||||
|
return 'not_found';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error === 'invalid_cursor' || status === 422) {
|
||||||
|
return 'invalid_cursor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 429) {
|
||||||
|
return 'rate_limited';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = data as Record<string, unknown> | null;
|
||||||
|
const code = resolveErrorCode(response.status, payload);
|
||||||
|
const message =
|
||||||
|
typeof payload?.message === 'string'
|
||||||
|
? payload.message
|
||||||
|
: typeof payload?.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: 'Live show request failed';
|
||||||
|
throw new LiveShowError(code, message, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(options: {
|
||||||
|
cursor?: LiveShowCursor | null;
|
||||||
|
settingsVersion?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.limit) {
|
||||||
|
params.set('limit', String(options.limit));
|
||||||
|
}
|
||||||
|
if (options.settingsVersion) {
|
||||||
|
params.set('settings_version', options.settingsVersion);
|
||||||
|
}
|
||||||
|
if (options.cursor?.approved_at) {
|
||||||
|
params.set('after_approved_at', options.cursor.approved_at);
|
||||||
|
params.set('after_id', String(options.cursor.id));
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLiveShowStreamUrl(
|
||||||
|
token: string,
|
||||||
|
options: {
|
||||||
|
cursor?: LiveShowCursor | null;
|
||||||
|
settingsVersion?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const params = buildParams(options);
|
||||||
|
const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`;
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${base}?${query}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveShowState(token: string, limit = 50): Promise<LiveShowState> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (limit) {
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'omit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await handleResponse<Record<string, unknown>>(response);
|
||||||
|
const rawEvent = (data.event as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: normalizeLiveShowEvent(rawEvent),
|
||||||
|
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
|
||||||
|
settings_version: String(data.settings_version ?? ''),
|
||||||
|
photos: Array.isArray(data.photos)
|
||||||
|
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
||||||
|
: [],
|
||||||
|
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveShowUpdates(
|
||||||
|
token: string,
|
||||||
|
options: {
|
||||||
|
cursor?: LiveShowCursor | null;
|
||||||
|
settingsVersion?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<LiveShowUpdates> {
|
||||||
|
const params = buildParams(options);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'omit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await handleResponse<Record<string, unknown>>(response);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
|
||||||
|
settings_version: String(data.settings_version ?? ''),
|
||||||
|
photos: Array.isArray(data.photos)
|
||||||
|
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
||||||
|
: [],
|
||||||
|
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -340,6 +340,7 @@ Route::prefix('event-admin')->group(function () {
|
|||||||
});
|
});
|
||||||
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
||||||
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
||||||
|
Route::view('/show/{token}', 'guest')->where('token', '.*')->name('guest.live-show');
|
||||||
Route::view('/e/{token}/{path?}', 'guest')
|
Route::view('/e/{token}/{path?}', 'guest')
|
||||||
->where('token', '.*')
|
->where('token', '.*')
|
||||||
->where('path', '.*')
|
->where('path', '.*')
|
||||||
|
|||||||
16
tests/Feature/GuestLiveShowRouteTest.php
Normal file
16
tests/Feature/GuestLiveShowRouteTest.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GuestLiveShowRouteTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_live_show_route_serves_guest_shell(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/show/demo-live-show');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertViewIs('guest');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user