230 lines
6.1 KiB
TypeScript
230 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|