mirror of
https://github.com/azaion/ui.git
synced 2026-04-23 00:56:34 +00:00
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.
This commit is contained in:
@@ -1,25 +1,44 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { getToken } from '../../api/client'
|
||||
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
|
||||
selectedClassNum: number
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: Props) {
|
||||
const { t } = useTranslation()
|
||||
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 token = getToken()
|
||||
const videoUrl = `/api/annotations/media/${media.id}/file`
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: `/api/annotations/media/${media.id}/file`
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
@@ -64,48 +83,88 @@ export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: P
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
muted={muted}
|
||||
className="w-full max-h-[50vh] object-contain"
|
||||
onTimeUpdate={e => {
|
||||
const t = (e.target as HTMLVideoElement).currentTime
|
||||
setCurrentTime(t)
|
||||
onTimeUpdate(t)
|
||||
}}
|
||||
onLoadedMetadata={e => setDuration((e.target as HTMLVideoElement).duration)}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="h-1 bg-az-border cursor-pointer"
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
if (videoRef.current) videoRef.current.currentTime = pct * duration
|
||||
}}
|
||||
>
|
||||
<div className="h-full bg-az-orange" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
<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>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-az-header text-xs">
|
||||
<button onClick={togglePlay} className="text-az-text hover:text-white px-1">{playing ? '⏸' : '▶'}</button>
|
||||
<button onClick={stop} className="text-az-text hover:text-white px-1">⏹</button>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} className="text-az-muted hover:text-white px-0.5">-{n}</button>
|
||||
))}
|
||||
<span className="text-az-muted mx-1">|</span>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} className="text-az-muted hover:text-white px-0.5">+{n}</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setMuted(m => !m)} className="text-az-text hover:text-white px-1">
|
||||
{muted ? '🔇' : '🔊'}
|
||||
{/* 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>
|
||||
<span className="text-az-muted">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default VideoPlayer
|
||||
|
||||
Reference in New Issue
Block a user