import React, { useEffect, useMemo, useState } from 'react'; import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import type { SessionState } from '../../shared/ipc/contracts'; import { RotatedImage } from '../shared/RotatedImage'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState'; import styles from './ControlScenePreview.module.css'; type Props = { session: SessionState | null; videoRef: React.RefObject; onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void; }; function fmt(sec: number): string { if (!Number.isFinite(sec) || sec < 0) return '0:00'; const s = Math.floor(sec); const m = Math.floor(s / 60); const r = s % 60; return `${String(m)}:${String(r).padStart(2, '0')}`; } export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) { const [vp, video] = useVideoPlaybackState(); const scene = session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined; const url = useAssetUrl(scene?.previewAssetId ?? null); const rot = scene?.previewRotationDeg ?? 0; const isVideo = scene?.previewAssetType === 'video'; const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null; const autostart = scene?.previewVideoAutostart ?? false; const [tick, setTick] = useState(0); const dur = useMemo( () => { const v = videoRef.current; if (!v) return 0; return Number.isFinite(v.duration) ? v.duration : 0; }, // tick: timeupdate / loadedmetadata перечитывают duration и currentTime // eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно [tick, videoRef], ); const cur = useMemo( () => { const v = videoRef.current; if (!v) return 0; return Number.isFinite(v.currentTime) ? v.currentTime : 0; }, // eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно [tick, videoRef], ); const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; useEffect(() => { if (!isVideo) return; if (!assetId) return; // `target.set` bumps revision and resets anchors; avoid firing on every render. if (vp?.targetAssetId === assetId) return; void video.dispatch({ kind: 'target.set', assetId, autostart, }); }, [assetId, isVideo, autostart, vp?.targetAssetId, video]); useEffect(() => { const v = videoRef.current; if (!v) return; if (!vp) return; if (vp.targetAssetId !== assetId) return; v.playbackRate = vp.playbackRate; const desired = computeTimeSec(vp, vp.serverNowMs); if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) { v.currentTime = Math.max(0, desired); } if (vp.playing) { void v.play().catch(() => undefined); } else { v.pause(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates) }, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]); const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' '); return (
{url && scene?.previewAssetType === 'image' ? ( ) : url && isVideo ? ( ) : (
)} {isVideo ? (
0 ? Math.round(dur) : 0} aria-valuenow={Math.round(cur)} className={scrubClass} onClick={(e) => { const v = videoRef.current; if (!v || !dur) return; const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const next = (e.clientX - rect.left) / rect.width; void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) }); setTick((x) => x + 1); }} onKeyDown={(e) => { if (!dur) return; if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) }); if (e.key === 'ArrowRight') void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) }); if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1); }} title="Клик — перемотка" >
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
) : null}
); }