mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
initial commit
This commit is contained in:
Executable
+641
@@ -0,0 +1,641 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, SkipBack, SkipForward, Loader2, HelpCircle, PictureInPicture2, Check } from 'lucide-react'
|
||||
import { cn, formatTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string
|
||||
poster?: string
|
||||
subtitles?: string
|
||||
onTimeUpdate?: (time: number) => void
|
||||
onEnded?: () => void
|
||||
onProgressSave?: (time: number) => void
|
||||
initialTime?: number
|
||||
autoPlay?: boolean
|
||||
seekRequest?: { time: number; nonce: number } | null
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
subtitles,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onProgressSave,
|
||||
initialTime = 0,
|
||||
autoPlay = true,
|
||||
seekRequest = null,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(initialTime)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [playbackRate, setPlaybackRate] = useState(1)
|
||||
const playbackRates = [0.75, 1, 1.25, 1.5, 2]
|
||||
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false)
|
||||
const [isPiPActive, setIsPiPActive] = useState(false)
|
||||
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const hasAutoPlayedRef = useRef(false)
|
||||
|
||||
const formatTimeDisplay = (seconds: number) => formatTime(seconds)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay && videoRef.current && !hasAutoPlayedRef.current) {
|
||||
hasAutoPlayedRef.current = true
|
||||
const playPromise = videoRef.current.play()
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Auto-play failed (browser policy), user must tap to play
|
||||
setIsPlaying(false)
|
||||
hasAutoPlayedRef.current = false // Allow retry on user interaction
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [autoPlay])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const handleEnterPiP = () => setIsPiPActive(true)
|
||||
const handleLeavePiP = () => setIsPiPActive(false)
|
||||
|
||||
video.addEventListener('enterpictureinpicture', handleEnterPiP)
|
||||
video.addEventListener('leavepictureinpicture', handleLeavePiP)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('enterpictureinpicture', handleEnterPiP)
|
||||
video.removeEventListener('leavepictureinpicture', handleLeavePiP)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
setIsPiPSupported(Boolean((document as Document & { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(Boolean(document.fullscreenElement))
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
|
||||
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000)
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const video = videoRef.current
|
||||
const doc = document as Document & { pictureInPictureEnabled?: boolean; pictureInPictureElement?: Element | null }
|
||||
|
||||
if (!doc.pictureInPictureEnabled) {
|
||||
window.alert('Picture-in-Picture is not supported in this browser.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (doc.pictureInPictureElement === video) {
|
||||
await doc.exitPictureInPicture?.()
|
||||
} else {
|
||||
await video.requestPictureInPicture?.()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Picture-in-Picture toggle failed:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveProgress = useCallback((time: number) => {
|
||||
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
|
||||
progressSaveTimeoutRef.current = setTimeout(() => {
|
||||
onProgressSave?.(time)
|
||||
}, 500)
|
||||
}, [onProgressSave])
|
||||
|
||||
const seekToPercent = (percent: number) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
videoRef.current.currentTime = (percent / 100) * duration
|
||||
setCurrentTime(videoRef.current.currentTime)
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (duration > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const hoverTime = percent * duration
|
||||
setHoverProgress({ time: hoverTime, x: e.clientX - rect.left })
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
const handleProgressLeave = useCallback(() => {
|
||||
setHoverProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
videoRef.current.currentTime = percent * duration
|
||||
setCurrentTime(videoRef.current.currentTime)
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}, [duration, saveProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!videoRef.current) return
|
||||
const video = videoRef.current
|
||||
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
switch (e.key) {
|
||||
case '?':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowShortcuts(prev => !prev)
|
||||
}
|
||||
break
|
||||
case ' ':
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
break
|
||||
case 'j':
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
video.currentTime = Math.max(0, video.currentTime - 10)
|
||||
saveProgress(video.currentTime)
|
||||
break
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + 30)
|
||||
saveProgress(video.currentTime)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
video.volume = Math.min(1, video.volume + 0.1)
|
||||
setVolume(video.volume)
|
||||
setIsMuted(false)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
video.volume = Math.max(0, video.volume - 0.1)
|
||||
setVolume(video.volume)
|
||||
if (video.volume === 0) setIsMuted(true)
|
||||
break
|
||||
case 'm':
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
if (videoRef.current) {
|
||||
if (!isFullscreen) {
|
||||
videoRef.current.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'p':
|
||||
case 'i':
|
||||
e.preventDefault()
|
||||
togglePictureInPicture()
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
if (showShortcuts) {
|
||||
setShowShortcuts(false)
|
||||
break
|
||||
}
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
break
|
||||
}
|
||||
if (isPiPActive) {
|
||||
togglePictureInPicture()
|
||||
}
|
||||
break
|
||||
case ',':
|
||||
case '[':
|
||||
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25)
|
||||
setPlaybackRate(video.playbackRate)
|
||||
break
|
||||
case '.':
|
||||
case ']':
|
||||
video.playbackRate = Math.min(2, video.playbackRate + 0.25)
|
||||
setPlaybackRate(video.playbackRate)
|
||||
break
|
||||
case '0':
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
e.preventDefault()
|
||||
seekToPercent(parseInt(e.key) * 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [togglePlay, isMuted, isFullscreen, saveProgress, duration, seekToPercent, togglePictureInPicture, showShortcuts, isPiPActive])
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
if (initialTime > 0) {
|
||||
videoRef.current.currentTime = initialTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const time = videoRef.current.currentTime
|
||||
setCurrentTime(time)
|
||||
onTimeUpdate?.(time)
|
||||
saveProgress(time)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
if (videoRef.current && videoRef.current.buffered.length > 0) {
|
||||
setBuffered(videoRef.current.buffered.end(videoRef.current.buffered.length - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
onEnded?.()
|
||||
}
|
||||
|
||||
const handlePlay = () => setIsPlaying(true)
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false)
|
||||
if (videoRef.current) {
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVolumeChangeNative = () => {
|
||||
if (videoRef.current) {
|
||||
setVolume(videoRef.current.volume)
|
||||
setIsMuted(videoRef.current.muted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRateChange = () => {
|
||||
if (videoRef.current) {
|
||||
setPlaybackRate(videoRef.current.playbackRate)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = playbackRate
|
||||
}
|
||||
}, [playbackRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current && initialTime > 0 && videoRef.current.readyState >= 1) {
|
||||
videoRef.current.currentTime = initialTime
|
||||
// Allow auto-play attempt again when seeking to saved position
|
||||
hasAutoPlayedRef.current = false
|
||||
}
|
||||
}, [initialTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !seekRequest) return
|
||||
|
||||
const nextTime = Math.max(0, seekRequest.time)
|
||||
videoRef.current.currentTime = nextTime
|
||||
setCurrentTime(nextTime)
|
||||
saveProgress(nextTime)
|
||||
|
||||
videoRef.current.play().catch(() => {})
|
||||
}, [seekRequest?.nonce, saveProgress])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
|
||||
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowSettingsMenu(false)
|
||||
}
|
||||
}
|
||||
if (showSettingsMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showSettingsMenu])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-player-container"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<div className="relative w-full h-full" style={{ position: 'relative' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onDurationChange={handleDurationChange}
|
||||
onProgress={handleProgress}
|
||||
onEnded={handleEnded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onVolumeChange={handleVolumeChangeNative}
|
||||
onRateChange={handleRateChange}
|
||||
onClick={togglePlay}
|
||||
className="w-full h-full cursor-pointer"
|
||||
>
|
||||
{subtitles && (
|
||||
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
|
||||
)}
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{videoRef.current && videoRef.current.readyState < 2 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/90 to-transparent transition-opacity duration-200',
|
||||
showControls || isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 relative">
|
||||
<span className="text-xs text-white font-mono w-12 text-right">
|
||||
{formatTimeDisplay(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="flex-1 h-1.5 bg-white/20 rounded-full cursor-pointer relative"
|
||||
onMouseMove={handleProgressHover}
|
||||
onMouseLeave={handleProgressLeave}
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
{buffered > 0 && duration > 0 && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-white/30 rounded-full"
|
||||
style={{ width: `${(buffered / duration) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-primary rounded-full transition-none"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
{hoverProgress && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 transform -translate-x-1/2 transition-opacity duration-100 opacity-100 pointer-events-none z-20"
|
||||
style={{ left: `${(hoverProgress.x / (duration > 0 ? 1 : 1)) * 100}%` }}
|
||||
>
|
||||
<div className="bg-black/90 text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-lg">
|
||||
{formatTimeDisplay(hoverProgress.time)}
|
||||
</div>
|
||||
<div className="w-0 h-0 border-4 border-t-black/90 border-r-transparent border-l-transparent border-b-transparent mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-white font-mono w-12">
|
||||
{formatTimeDisplay(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 10))}
|
||||
aria-label="Rewind 10s"
|
||||
>
|
||||
<SkipBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.min(duration, videoRef.current.currentTime + 30))}
|
||||
aria-label="Forward 30s"
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
}}
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
const vol = parseFloat(e.target.value)
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = vol
|
||||
setVolume(vol)
|
||||
setIsMuted(vol === 0)
|
||||
}
|
||||
}}
|
||||
className="w-20 h-1 bg-white/20 rounded-lg appearance-none accent-primary cursor-pointer"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen - FIRST */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={async () => {
|
||||
if (videoRef.current) {
|
||||
if (!isFullscreen) {
|
||||
try {
|
||||
await videoRef.current.requestFullscreen({ navigationUI: 'hide' })
|
||||
} catch {
|
||||
videoRef.current.requestFullscreen()
|
||||
}
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'text-white hover:bg-white/20',
|
||||
isPiPActive && 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
onClick={togglePictureInPicture}
|
||||
disabled={!isPiPSupported}
|
||||
aria-label={isPiPActive ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}
|
||||
title={isPiPSupported ? 'Toggle Picture-in-Picture (I)' : 'Picture-in-Picture not supported'}
|
||||
>
|
||||
<PictureInPicture2 className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Settings Menu (3-dot) - Speed + Shortcuts */}
|
||||
<div className="relative" ref={settingsMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
|
||||
aria-label="Settings"
|
||||
aria-expanded={showSettingsMenu}
|
||||
>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="19" cy="12" r="1"></circle>
|
||||
<circle cx="5" cy="12" r="1"></circle>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{showSettingsMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 w-40 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
|
||||
role="menu"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
|
||||
{playbackRates.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate
|
||||
}
|
||||
setPlaybackRate(rate)
|
||||
setShowSettingsMenu(false)
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${playbackRate === rate ? 'bg-primary/20 text-primary' : ''}`}
|
||||
role="menuitemradio"
|
||||
aria-checked={playbackRate === rate}
|
||||
>
|
||||
<span>{rate}x</span>
|
||||
{playbackRate === rate && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
<hr className="border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded"
|
||||
role="menuitem"
|
||||
>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<hr className="border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => setShowSettingsMenu(false)}
|
||||
className="w-full flex items-center justify-center px-3 py-2 text-sm text-white/70 hover:bg-white/10 rounded"
|
||||
role="menuitem"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsOverlay isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user