a6cbcc273e
Made-with: Cursor
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
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<HTMLVideoElement | 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')}`;
|
|
}
|
|
|
|
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 [tick, setTick] = useState(0);
|
|
const dur = useMemo(
|
|
() => {
|
|
const v = videoRef.current;
|
|
if (!v) return 0;
|
|
return Number.isFinite(v.duration) ? v.duration : 0;
|
|
},
|
|
// tick: перечитываем duration из video ref на каждом кадре RAF
|
|
// 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;
|
|
let raf = 0;
|
|
const loop = () => {
|
|
setTick((x) => x + 1);
|
|
raf = window.requestAnimationFrame(loop);
|
|
};
|
|
raf = window.requestAnimationFrame(loop);
|
|
return () => window.cancelAnimationFrame(raf);
|
|
}, [isVideo]);
|
|
|
|
useEffect(() => {
|
|
if (!isVideo) return;
|
|
void video.dispatch({
|
|
kind: 'target.set',
|
|
assetId,
|
|
autostart: scene.previewVideoAutostart,
|
|
});
|
|
}, [assetId, isVideo, scene, 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();
|
|
}
|
|
}, [assetId, vp, videoRef]);
|
|
|
|
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
{url && scene?.previewAssetType === 'image' ? (
|
|
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
|
) : url && isVideo ? (
|
|
<video
|
|
ref={(el) => {
|
|
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
|
}}
|
|
className={styles.video}
|
|
src={url}
|
|
playsInline
|
|
preload="auto"
|
|
>
|
|
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
|
</video>
|
|
) : (
|
|
<div className={styles.placeholder} />
|
|
)}
|
|
|
|
{isVideo ? (
|
|
<div className={styles.controls}>
|
|
<div
|
|
role="slider"
|
|
tabIndex={0}
|
|
aria-valuemin={0}
|
|
aria-valuemax={dur > 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="Клик — перемотка"
|
|
>
|
|
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
|
</div>
|
|
<div className={styles.row}>
|
|
<div className={styles.transport}>
|
|
<button
|
|
type="button"
|
|
className={styles.transportBtn}
|
|
onClick={() => void video.dispatch({ kind: 'play' })}
|
|
title="Play"
|
|
>
|
|
▶
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.transportBtn}
|
|
onClick={() => void video.dispatch({ kind: 'pause' })}
|
|
title="Pause"
|
|
>
|
|
❚❚
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.transportBtn}
|
|
onClick={() => {
|
|
void video.dispatch({ kind: 'stop' });
|
|
setTick((x) => x + 1);
|
|
}}
|
|
title="Stop"
|
|
>
|
|
■
|
|
</button>
|
|
</div>
|
|
<div>
|
|
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|