mirror of
https://github.com/azaion/ui.git
synced 2026-04-22 23:16:35 +00:00
63cc18e788
Add local annotation save fallback, PNG+txt download with drawn boxes, shared classColors helper, photo mode icon toggles, and react-dropzone / react-icons dependencies.
171 lines
6.4 KiB
TypeScript
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
|