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:
Brooklyn Nicholson
2026-09-27 06:31:15 -05:00
committed by brooklyn!
parent 6013f62b6d
commit 806fa64fd8
2 changed files with 108 additions and 0 deletions

View File

@@ -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 })
})
})

View File

@@ -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.