Files
ui/src/features/annotations/VideoPlayer.tsx
T
Armen Rohalov 63cc18e788 Enhance annotations: save/download, fallback classes, photo mode icons
Add local annotation save fallback, PNG+txt download with drawn boxes,
shared classColors helper, photo mode icon toggles, and react-dropzone
/ react-icons dependencies.
2026-04-17 23:33:00 +03:00

171 lines
6.4 KiB
TypeScript

import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
import type { Media } from '../../types'
interface Props {
media: Media
onTimeUpdate: (time: number) => void
children?: React.ReactNode
}
const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
getVideoElement: () => HTMLVideoElement | null
}
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
const videoRef = useRef<HTMLVideoElement>(null)
useImperativeHandle(ref, () => ({
seek(seconds: number) {
if (videoRef.current) {
videoRef.current.currentTime = seconds
setCurrentTime(seconds)
}
},
getVideoElement() {
return videoRef.current
},
}))
const [error, setError] = useState<string | null>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [muted, setMuted] = useState(false)
const videoUrl = media.path.startsWith('blob:')
? media.path
: `/api/annotations/media/${media.id}/file`
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
if (!video) return
const fps = 30
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
}, [])
const togglePlay = useCallback(() => {
const v = videoRef.current
if (!v) return
if (v.paused) { v.play(); setPlaying(true) }
else { v.pause(); setPlaying(false) }
}, [])
const stop = useCallback(() => {
const v = videoRef.current
if (!v) return
v.pause()
v.currentTime = 0
setPlaying(false)
}, [])
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': setMuted(m => !m); break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [togglePlay, stepFrames])
const formatTime = (s: number) => {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
}
return (
<div className="bg-black flex flex-col flex-1 min-h-0">
{error && <div className="bg-az-red/80 text-white text-xs px-2 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 => {
const t = (e.target as HTMLVideoElement).currentTime
setCurrentTime(t)
onTimeUpdate(t)
}}
onLoadedMetadata={e => {
setDuration((e.target as HTMLVideoElement).duration)
setError(null)
}}
onError={() => setError(`Failed to load video (${media.name})`)}
/>
{children && <div className="absolute inset-0">{children}</div>}
</div>
{/* Progress row: time | slider | remaining */}
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
<input
type="range"
min={0}
max={duration || 1}
step={0.01}
value={currentTime}
onChange={e => {
const v = Number(e.target.value)
setCurrentTime(v)
if (videoRef.current) videoRef.current.currentTime = v
}}
className="flex-1 accent-az-orange h-1 cursor-pointer"
style={{
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
}}
/>
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
{/* Buttons row */}
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
<FaStepBackward size={14} />
</button>
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
</button>
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
<FaStepForward size={14} />
</button>
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
<FaStop size={14} />
</button>
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
-{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
+{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
</button>
</div>
</div>
)
})
export default VideoPlayer