import React, { useEffect, useRef } from 'react'; import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import type { SessionState } from '../../shared/ipc/contracts'; import type { SceneViewCamera } from '../../shared/types'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { ContainedVideo } from '../shared/ContainedVideo'; 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; viewCamera?: SceneViewCamera | null; 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')}`; } function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } { if (!v) return { cur: 0, dur: 0 }; const dur = Number.isFinite(v.duration) ? v.duration : 0; const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0; return { cur, dur }; } export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) { const { t } = useEditorI18n(); 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 lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null); const scrubRef = useRef(null); const scrubFillRef = useRef(null); const curTimeRef = useRef(null); const durTimeRef = useRef(null); const paintScrub = (): void => { const { cur, dur } = readVideoTimes(videoRef.current); const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; if (scrubFillRef.current) { scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; } if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur); if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—'; if (scrubRef.current) { scrubRef.current.setAttribute('aria-valuemin', '0'); scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0)); scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur))); scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0); scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0); } }; useEffect(() => { if (!isVideo) return; if (!assetId) return; const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? ''; const prev = lastTargetRef.current; if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) { return; } lastTargetRef.current = { sceneKey, assetId, autostart }; void video.dispatch({ kind: 'target.set', assetId, autostart, }); }, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, 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(); } paintScrub(); // 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 seekByClientX = (clientX: number): void => { const v = videoRef.current; const { dur } = readVideoTimes(v); if (!v || !dur || !scrubRef.current) return; const rect = scrubRef.current.getBoundingClientRect(); const next = (clientX - rect.left) / Math.max(1, rect.width); const timeSec = Math.max(0, Math.min(dur, next * dur)); void video.dispatch({ kind: 'seek', timeSec }); v.currentTime = timeSec; paintScrub(); }; return (
{url && scene?.previewAssetType === 'image' ? ( ) : url && isVideo ? ( ) : (
)} {isVideo ? (
{ seekByClientX(e.clientX); }} onKeyDown={(e) => { const { cur, dur } = readVideoTimes(videoRef.current); 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') paintScrub(); }} title={t('control.scrubSeek')} >
0:00 {' / '} —:—
) : null}
); }