annotations v2: redesign
ci/woodpecker/push/build-arm Pipeline failed

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:
Armen Rohalov
2026-05-28 02:28:10 +03:00
parent cfffb4bdd7
commit f754afff46
17 changed files with 1279 additions and 415 deletions
+80 -105
View File
@@ -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>
)
})