fix(video): prevent preview/presentation playback loop

- Avoid per-render target.set dispatch that reset playback

- Stop timer-based and per-frame React ticking while playing

- Add regression tests for render-loop sources

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-20 18:30:47 +08:00
parent 2ce1e02753
commit add699a320
6 changed files with 85 additions and 34 deletions
+10 -1
View File
@@ -53,7 +53,16 @@ export function PresentationView({
} else {
el.pause();
}
}, [scene?.previewAssetId, scene?.previewAssetType, vp]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
}, [
scene?.previewAssetId,
scene?.previewAssetType,
previewUrl,
vp?.revision,
vp?.targetAssetId,
vp?.playing,
vp?.playbackRate,
]);
return (
<div className={styles.root}>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
@@ -9,31 +9,36 @@ export function useVideoPlaybackState(): readonly [
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<VideoPlaybackState | null>(null);
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
useEffect(() => {
if (!state) return;
const id = window.setInterval(() => {
setClientNowMs(Date.now());
}, 250);
return () => window.clearInterval(id);
}, [state]);
const [playback, setPlayback] = useState<VideoPlaybackState | null>(null);
/** serverNowMs Date.now() at last IPC sync; lets us compute a live clock without React timers. */
const timeOffsetMsRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
setState(r.state);
setTimeOffsetMs(r.state.serverNowMs - Date.now());
timeOffsetMsRef.current = r.state.serverNowMs - Date.now();
setPlayback(r.state);
});
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
setState(next);
setTimeOffsetMs(next.serverNowMs - Date.now());
timeOffsetMsRef.current = next.serverNowMs - Date.now();
setPlayback((prev) => {
if (prev?.revision === next.revision) return prev;
return next;
});
});
}, [api]);
const state = useMemo((): VideoPlaybackState | null => {
if (!playback) return null;
return {
...playback,
get serverNowMs(): number {
return Date.now() + timeOffsetMsRef.current;
},
};
}, [playback]);
return [
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.video.dispatch, { event });