mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 16:31:11 +00:00
085d7bf17e
Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
|
import { endpoints, authenticatedApiUrl } 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
|
|
}
|
|
|
|
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 FPS = 30
|
|
|
|
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 [muted, setMuted] = useState(false)
|
|
|
|
const notifyMuted = useCallback((m: boolean) => {
|
|
setMuted(m)
|
|
onMutedChange?.(m)
|
|
}, [onMutedChange])
|
|
|
|
const videoUrl = media.path.startsWith('blob:')
|
|
? media.path
|
|
: authenticatedApiUrl(endpoints.annotations.mediaFile(media.id))
|
|
|
|
const stepFrames = useCallback((count: number) => {
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
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().catch(() => {})
|
|
else v.pause()
|
|
}, [])
|
|
|
|
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) => {
|
|
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': {
|
|
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])
|
|
|
|
return (
|
|
<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}
|
|
src={videoUrl}
|
|
muted={muted}
|
|
controls={false}
|
|
playsInline
|
|
className="max-w-full max-h-full object-contain"
|
|
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>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default VideoPlayer
|