mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 19:01:11 +00:00
Reskin to v2 surface/accent tokens + JetBrains Mono headings to match _docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection banner, multi-band gradient rows in the annotations sidebar, class-distribution summary footer, and DOM-overlay bbox labels with affiliation icon + readiness dot. Split VideoPlayer chrome out into the page-level controls row (transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber component; player events replace 200ms polling. Other: - Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV). - Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash. - Extract hexToRgba to src/class-colors and time helpers to src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar / AnnotationsPage). - CanvasEditor: shallow-compare label chips before commit, NaN-guard annotation-time parser, cancel cursor RAF on unmount. - AnnotationsPage: track AI-banner close timer, push initial volume to the <video> on media change, drop the duplicate parent muted state. - Fixed sidebar widths (resize handles removed per design).
This commit is contained in:
@@ -1,42 +1,52 @@
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import { endpoints } 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
|
||||
}
|
||||
|
||||
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
|
||||
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 VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const FPS = 30
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(seconds: number) {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = seconds
|
||||
setCurrentTime(seconds)
|
||||
}
|
||||
},
|
||||
getVideoElement() {
|
||||
return videoRef.current
|
||||
},
|
||||
}))
|
||||
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 [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
const notifyMuted = useCallback((m: boolean) => {
|
||||
setMuted(m)
|
||||
onMutedChange?.(m)
|
||||
}, [onMutedChange])
|
||||
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: endpoints.annotations.mediaFile(media.id)
|
||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
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))
|
||||
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(); setPlaying(true) }
|
||||
else { v.pause(); setPlaying(false) }
|
||||
if (v.paused) v.play().catch(() => {})
|
||||
else v.pause()
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.pause()
|
||||
v.currentTime = 0
|
||||
setPlaying(false)
|
||||
}, [])
|
||||
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) => {
|
||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
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
|
||||
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])
|
||||
|
||||
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="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}
|
||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
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)
|
||||
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>
|
||||
{/* 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>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user