fix(desktop): restore transcript video position across remounts
A remounted <video> restarted its clip from the top, which read as the attachment reloading mid-playback. Remember the last position and pause state per source (bounded, process-memory) and restore them on mount.
This commit is contained in:
committed by
brooklyn!
parent
6013f62b6d
commit
806fa64fd8
@@ -56,4 +56,38 @@ describe('video playback speed preference', () => {
|
||||
|
||||
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
// #123018: a transcript row's <video> unmounts and remounts while a turn
|
||||
// streams (render-budget slice recycling, markdown re-parse). The position
|
||||
// is remembered per source, so the remounted player resumes instead of
|
||||
// restarting the clip from the top.
|
||||
it('restores the remembered position and play state after a remount (#123018)', async () => {
|
||||
const { TranscriptVideo } = await loadComponent()
|
||||
const { container, unmount } = render(<TranscriptVideo src="file:///tmp/clip.mp4" />)
|
||||
const video = container.querySelector('video')!
|
||||
|
||||
// The user watches 42s in and pauses.
|
||||
Object.defineProperty(video, 'currentTime', { configurable: true, writable: true, value: 42 })
|
||||
Object.defineProperty(video, 'paused', { configurable: true, writable: true, value: true })
|
||||
fireEvent.pause(video)
|
||||
|
||||
unmount()
|
||||
|
||||
const next = render(<TranscriptVideo src="file:///tmp/clip.mp4" />)
|
||||
const remounted = next.container.querySelector('video')!
|
||||
|
||||
expect(remounted.currentTime).toBe(42)
|
||||
})
|
||||
|
||||
it('drops the oldest remembered source once the map passes its bound', async () => {
|
||||
const { rememberVideoPosition, recallVideoPosition } = await import('./transcript-video')
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
rememberVideoPosition(`file:///tmp/clip-${i}.mp4`, i, true)
|
||||
}
|
||||
|
||||
// The bound keeps the map small; the oldest entries fall off the front.
|
||||
expect(recallVideoPosition('file:///tmp/clip-0.mp4')).toBeUndefined()
|
||||
expect(recallVideoPosition('file:///tmp/clip-29.mp4')).toEqual({ paused: true, time: 29 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,36 @@ import { type ComponentProps, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { $videoPlaybackSpeed, setVideoPlaybackSpeed } from '@/store/video-playback-speed'
|
||||
|
||||
// Playback position per source, remembered across remounts. Transcript rows
|
||||
// legitimately unmount while a turn streams (the render-budget slice recycles
|
||||
// older rows; the streaming markdown re-parse re-mounts AST leaves), and a
|
||||
// fresh <video> restarted at 0 read as the clip "reloading mid-playback"
|
||||
// (#123018). Process-memory only: the map deliberately holds a bounded number
|
||||
// of recent sources and is dropped on reload.
|
||||
const MAX_REMEMBERED_SOURCES = 24
|
||||
const rememberedPositions = new Map<string, { paused: boolean; time: number }>()
|
||||
|
||||
export function rememberVideoPosition(src: string, time: number, paused: boolean): void {
|
||||
if (!src || !Number.isFinite(time) || time <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberedPositions.delete(src)
|
||||
rememberedPositions.set(src, { paused, time })
|
||||
|
||||
if (rememberedPositions.size > MAX_REMEMBERED_SOURCES) {
|
||||
const oldest = rememberedPositions.keys().next().value
|
||||
|
||||
if (oldest !== undefined) {
|
||||
rememberedPositions.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recallVideoPosition(src: string): { paused: boolean; time: number } | undefined {
|
||||
return rememberedPositions.get(src)
|
||||
}
|
||||
|
||||
// A transcript <video> that remembers the playback rate. The native controls'
|
||||
// rate menu is the only speed UI; picking a rate there persists it as the
|
||||
// device-level preference every later player (and other open windows) starts
|
||||
@@ -10,6 +40,7 @@ import { $videoPlaybackSpeed, setVideoPlaybackSpeed } from '@/store/video-playba
|
||||
export function TranscriptVideo(props: ComponentProps<'video'>) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const speed = useStore($videoPlaybackSpeed)
|
||||
const src = typeof props.src === 'string' ? props.src : ''
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
@@ -19,6 +50,49 @@ export function TranscriptVideo(props: ComponentProps<'video'>) {
|
||||
}
|
||||
}, [speed])
|
||||
|
||||
// Restore the last position for this source when the element (re)mounts —
|
||||
// a remount mid-turn otherwise restarts the clip from the top.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
const remembered = src ? recallVideoPosition(src) : undefined
|
||||
|
||||
if (video && remembered) {
|
||||
video.currentTime = remembered.time
|
||||
|
||||
if (!remembered.paused) {
|
||||
void video.play().catch(() => {
|
||||
// Autoplay without a gesture can be refused (muted policy); the
|
||||
// user can press play — the position is already restored.
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [src])
|
||||
|
||||
// Keep the remembered position fresh while the element lives.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
|
||||
if (!video || !src) {
|
||||
return
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
rememberVideoPosition(src, video.currentTime, video.paused)
|
||||
}
|
||||
|
||||
video.addEventListener('timeupdate', commit)
|
||||
video.addEventListener('pause', commit)
|
||||
video.addEventListener('ended', commit)
|
||||
|
||||
return () => {
|
||||
commit()
|
||||
|
||||
video.removeEventListener('timeupdate', commit)
|
||||
video.removeEventListener('pause', commit)
|
||||
video.removeEventListener('ended', commit)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
// ratechange also fires when WE set the rate (mount, cross-window sync), so
|
||||
// only a rate that differs from the preference — i.e. one the user picked in
|
||||
// the controls — persists. setVideoPlaybackSpeed drops out-of-range values.
|
||||
|
||||
Reference in New Issue
Block a user