Files
ui/src/features/annotations/VideoPlayer.tsx
T
Oleksandr Bezdieniezhnykh 085d7bf17e Merge API remote base URL config into dev
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 18:19:02 +03:00

147 lines
5.1 KiB
TypeScript

import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { endpoints, authenticatedApiUrl } from '../../api'
import type { Media } from '../../types'
interface Props {
media: Media
onTimeUpdate: (time: number) => void
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
onPlayingChange?: (playing: boolean) => void
/** Fires when the <video> reports a valid duration. */
onDurationChange?: (duration: number) => void
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
onMutedChange?: (muted: boolean) => void
children?: React.ReactNode
}
export interface VideoPlayerHandle {
seek: (seconds: number) => void
getVideoElement: () => HTMLVideoElement | null
play: () => void
pause: () => void
toggle: () => void
isPlaying: () => boolean
frameStep: (deltaFrames: number) => void
getDuration: () => number
getCurrentTime: () => number
getFrameRate: () => number
getCurrentFrame: () => number
getTotalFrames: () => number
getVolume: () => number
setVolume: (v: number) => void
toggleMute: () => void
isMuted: () => boolean
}
const FPS = 30
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
}, ref) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
const [muted, setMuted] = useState(false)
const notifyMuted = useCallback((m: boolean) => {
setMuted(m)
onMutedChange?.(m)
}, [onMutedChange])
const videoUrl = media.path.startsWith('blob:')
? media.path
: authenticatedApiUrl(endpoints.annotations.mediaFile(media.id))
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
if (!video) return
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
}, [])
const togglePlay = useCallback(() => {
const v = videoRef.current
if (!v) return
if (v.paused) v.play().catch(() => {})
else v.pause()
}, [])
useImperativeHandle(ref, () => ({
seek(seconds: number) {
const v = videoRef.current
if (v) v.currentTime = seconds
},
getVideoElement() { return videoRef.current },
play() { videoRef.current?.play().catch(() => {}) },
pause() { videoRef.current?.pause() },
toggle() { togglePlay() },
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
frameStep(delta) { stepFrames(delta) },
getDuration() { return videoRef.current?.duration ?? 0 },
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
getFrameRate() { return FPS },
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
getVolume() { return videoRef.current?.volume ?? 1 },
setVolume(v) {
const el = videoRef.current
if (!el) return
el.volume = Math.max(0, Math.min(1, v))
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
},
toggleMute() {
const el = videoRef.current
if (!el) return
el.muted = !el.muted
notifyMuted(el.muted)
},
isMuted() { return !!videoRef.current?.muted },
}))
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
case 'm': case 'M': {
const v = videoRef.current
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
break
}
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [togglePlay, stepFrames])
return (
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
{error && (
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
)}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
<video
ref={videoRef}
src={videoUrl}
muted={muted}
controls={false}
playsInline
className="max-w-full max-h-full object-contain"
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
onPlay={() => onPlayingChange?.(true)}
onPause={() => onPlayingChange?.(false)}
onDurationChange={e => {
const d = (e.target as HTMLVideoElement).duration
if (Number.isFinite(d)) onDurationChange?.(d)
}}
onLoadedMetadata={() => setError(null)}
onError={() => setError(`Failed to load video (${media.name})`)}
/>
{children && <div className="absolute inset-0">{children}</div>}
</div>
</div>
)
})
export default VideoPlayer