feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
@@ -26,6 +26,13 @@ function fmt(sec: number): string {
|
||||
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();
|
||||
@@ -37,28 +44,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
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<HTMLDivElement | null>(null);
|
||||
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const durTimeRef = useRef<HTMLDivElement | null>(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: 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;
|
||||
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;
|
||||
@@ -91,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
} 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 scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
||||
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 (
|
||||
<div className={styles.root}>
|
||||
@@ -116,8 +133,8 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
preload="auto"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
onTimeUpdate={paintScrub}
|
||||
onLoadedMetadata={paintScrub}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</ContainedVideo>
|
||||
@@ -128,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
{isVideo ? (
|
||||
<div className={styles.controls}>
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
className={scrubClass}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
className={[styles.scrub, styles.scrubDefault].join(' ')}
|
||||
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);
|
||||
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') setTick((x) => x + 1);
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
|
||||
}}
|
||||
title={t('control.scrubSeek')}
|
||||
>
|
||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
||||
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.transport}>
|
||||
@@ -176,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
className={styles.transportBtn}
|
||||
onClick={() => {
|
||||
void video.dispatch({ kind: 'stop' });
|
||||
setTick((x) => x + 1);
|
||||
paintScrub();
|
||||
}}
|
||||
title={t('control.transportStop')}
|
||||
>
|
||||
@@ -184,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
||||
<span ref={curTimeRef}>0:00</span>
|
||||
{' / '}
|
||||
<span ref={durTimeRef}>—:—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user