Initial commit: modern React video player library

Add all source files for a feature-rich, reusable video player built with React, TypeScript, and Vite. Includes core components, context, hooks, utilities, styles, demo app, and configuration files.
This commit is contained in:
hibna
2025-10-29 07:49:06 +03:00
parent d68df70124
commit b57b24d051
47 changed files with 4414 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
.controls-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--player-z-controls);
display: flex;
flex-direction: column;
justify-content: flex-end;
transition: opacity var(--player-transition-normal) ease;
cursor: default;
}
.controls-layer.hidden.playing {
opacity: 0;
cursor: none;
}
/* Allow clicks to pass through to video when controls are hidden */
.controls-layer.hidden.playing .controls-bar {
transform: translateY(100%);
pointer-events: none;
}
.controls-layer.visible {
opacity: 1;
}
.controls-bar {
background: linear-gradient(to top, var(--player-bg-controls) 0%, transparent 100%);
padding: var(--player-spacing-xl) var(--player-spacing-lg) var(--player-spacing-lg);
transition: transform var(--player-transition-normal) ease;
transform: translateY(0);
}
.progress-container {
margin-bottom: var(--player-spacing-md);
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--player-spacing-sm);
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: var(--player-spacing-sm);
}
.controls-right {
margin-left: auto;
}
/* Mobile responsiveness */
@media (max-width: 640px) {
.controls-bar {
padding: var(--player-spacing-lg) var(--player-spacing-md) var(--player-spacing-md);
}
.controls-row {
gap: var(--player-spacing-xs);
}
.controls-left,
.controls-right {
gap: var(--player-spacing-xs);
}
}
+178
View File
@@ -0,0 +1,178 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
import { PlayPauseButton } from './controls/PlayPauseButton'
import { ProgressBar } from './controls/ProgressBar'
import { VolumeControl } from './controls/VolumeControl'
import { TimeDisplay } from './controls/TimeDisplay'
import { FullscreenButton } from './controls/FullscreenButton'
import { PIPButton } from './controls/PIPButton'
import { SettingsButton } from './controls/SettingsButton'
import { LoadingSpinner } from './overlays/LoadingSpinner'
import { CenterPlayButton } from './controls/CenterPlayButton'
import { SettingsMenu } from './menus/SettingsMenu'
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
import { useTouchGestures } from '../hooks/useTouchGestures'
import type { SubtitleTrack, AudioTrack } from '../types'
import './ControlsLayer.css'
interface ControlsLayerProps {
keyboardShortcuts?: boolean
pictureInPicture?: boolean
subtitles?: SubtitleTrack[]
audioTracks?: AudioTrack[]
}
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
keyboardShortcuts = true,
pictureInPicture = true,
subtitles = [],
audioTracks = [],
}) => {
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } = usePlayerContext()
const [mouseMoving, setMouseMoving] = useState(false)
const hideTimeoutRef = useRef<number>()
const containerRef = useRef<HTMLDivElement>(null)
const lastClickTimeRef = useRef<number>(0)
// Auto-hide controls after inactivity
useEffect(() => {
if (videoState.playing) {
// Show controls on mouse movement
if (mouseMoving) {
showControls()
}
// Clear existing timeout
if (hideTimeoutRef.current) {
window.clearTimeout(hideTimeoutRef.current)
}
// Hide controls after inactivity (3 seconds in all modes)
if (mouseMoving) {
const hideDelay = 3000
hideTimeoutRef.current = window.setTimeout(() => {
hideControls()
setMouseMoving(false)
}, hideDelay)
}
} else {
// Always show controls when paused
showControls()
}
return () => {
if (hideTimeoutRef.current) {
window.clearTimeout(hideTimeoutRef.current)
}
}
}, [mouseMoving, videoState.playing, videoState.fullscreen, showControls, hideControls])
// Handle mouse movement
const handleMouseMove = useCallback(() => {
if (!mouseMoving) {
setMouseMoving(true)
}
}, [mouseMoving])
const handleMouseLeave = useCallback(() => {
// Only hide controls on mouse leave when in fullscreen mode
// When player is small, controls should stay visible
setMouseMoving(false)
if (videoState.playing && videoState.fullscreen) {
hideControls()
}
}, [videoState.playing, videoState.fullscreen, hideControls])
// Keyboard shortcuts
useKeyboardShortcuts(keyboardShortcuts)
// Touch gestures
useTouchGestures(containerRef)
// Handle click for play/pause and double-click for fullscreen
const handleClick = useCallback(
(e: React.MouseEvent) => {
// Get the actual element that was clicked
const target = e.target as HTMLElement
const currentTarget = e.currentTarget as HTMLElement
// Allow clicks on:
// 1. The controls layer itself (when controls are hidden, pointer-events: none makes it work)
// 2. The center play overlay
// Don't handle clicks on control buttons or other interactive elements
const isClickableArea =
target === currentTarget ||
target.classList.contains('center-play-overlay') ||
target.classList.contains('controls-layer')
if (!isClickableArea) {
return
}
const now = Date.now()
const timeSinceLastClick = now - lastClickTimeRef.current
if (timeSinceLastClick < 300) {
// Double click - toggle fullscreen
e.preventDefault() // Prevent text selection on double click
toggleFullscreen()
lastClickTimeRef.current = 0
} else {
// Single click - toggle play/pause (with delay to detect double click)
setTimeout(() => {
if (Date.now() - lastClickTimeRef.current >= 300) {
togglePlay()
}
}, 300)
lastClickTimeRef.current = now
}
},
[togglePlay, toggleFullscreen]
)
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
videoState.playing ? 'playing' : 'paused'
}`
return (
<div
ref={containerRef}
className={controlsClassName}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{/* Loading spinner */}
{videoState.loading && <LoadingSpinner />}
{/* Center play button (only when paused) */}
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
{/* Bottom controls bar */}
<div className="controls-bar">
{/* Progress bar (full width on top) */}
<div className="progress-container">
<ProgressBar />
</div>
{/* Control buttons */}
<div className="controls-row">
<div className="controls-left">
<PlayPauseButton />
<VolumeControl />
<TimeDisplay />
</div>
<div className="controls-right">
<div style={{ position: 'relative' }}>
<SettingsButton />
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} />
</div>
{pictureInPicture && <PIPButton />}
<FullscreenButton />
</div>
</div>
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
.video-container {
position: relative;
width: 100%;
height: 100%;
z-index: var(--player-z-video);
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* Hide native controls */
.video-element::-webkit-media-controls {
display: none !important;
}
.video-element::-webkit-media-controls-enclosure {
display: none !important;
}
.video-element::-webkit-media-controls-panel {
display: none !important;
}
+406
View File
@@ -0,0 +1,406 @@
import React, { useEffect, useCallback, useState } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
import type { SubtitleTrack, AudioTrack } from '../types'
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
import { getHlsAudioTracks, setHlsAudioTrack } from '../utils/hlsLoader'
import './VideoElement.css'
interface VideoElementProps {
src: string
poster?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
subtitles?: SubtitleTrack[]
onPlay?: () => void
onPause?: () => void
onEnded?: () => void
onTimeUpdate?: (currentTime: number) => void
onVolumeChange?: (volume: number) => void
onError?: (error: Error) => void
onLoadedMetadata?: () => void
onSeeking?: () => void
onSeeked?: () => void
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
}
export const VideoElement: React.FC<VideoElementProps> = ({
src,
poster,
autoplay = false,
loop = false,
muted = false,
subtitles = [],
onPlay,
onPause,
onEnded,
onTimeUpdate,
onVolumeChange,
onError,
onLoadedMetadata,
onSeeking,
onSeeked,
onAudioTracksLoaded,
}) => {
const { videoRef, containerRef, setVideoState, toggleFullscreen, settings } = usePlayerContext()
const lastClickTimeRef = React.useRef<number>(0)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
// Handle video events
const handlePlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: true }))
onPlay?.()
}, [setVideoState, onPlay])
const handlePause = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: false }))
onPause?.()
}, [setVideoState, onPause])
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current
if (!video) return
const currentTime = video.currentTime
const buffered = video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1) : 0
setVideoState((prev) => ({
...prev,
currentTime,
buffered,
}))
onTimeUpdate?.(currentTime)
}, [videoRef, setVideoState, onTimeUpdate])
const handleLoadedMetadata = useCallback(() => {
const video = videoRef.current
if (!video) return
setVideoState((prev) => ({
...prev,
duration: video.duration,
volume: video.volume,
muted: video.muted,
}))
onLoadedMetadata?.()
}, [videoRef, setVideoState, onLoadedMetadata])
const handleVolumeChange = useCallback(() => {
const video = videoRef.current
if (!video) return
setVideoState((prev) => ({
...prev,
volume: video.volume,
muted: video.muted,
}))
onVolumeChange?.(video.volume)
}, [videoRef, setVideoState, onVolumeChange])
const handleSeeking = useCallback(() => {
setVideoState((prev) => ({ ...prev, seeking: true }))
onSeeking?.()
}, [setVideoState, onSeeking])
const handleSeeked = useCallback(() => {
setVideoState((prev) => ({ ...prev, seeking: false }))
onSeeked?.()
}, [setVideoState, onSeeked])
const handleWaiting = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: true }))
}, [setVideoState])
const handleCanPlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: false }))
}, [setVideoState])
const handleEnded = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: false }))
onEnded?.()
}, [setVideoState, onEnded])
const handleError = useCallback(() => {
const video = videoRef.current
if (!video || !video.error) return
let errorMessage = `Video error: ${video.error.message}`
// Check if it's a CORS-related error
const videoError = video.error
if (videoError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED ||
videoError.code === MediaError.MEDIA_ERR_NETWORK) {
// Could be a CORS issue
const corsMessage = getCORSErrorMessage(video.src || src)
console.error(corsMessage)
errorMessage = `Failed to load video. This might be a CORS issue. Check console for details.`
}
const error = new Error(errorMessage)
setVideoState((prev) => ({ ...prev, error, loading: false }))
onError?.(error)
}, [videoRef, setVideoState, onError, src])
// Handle double-click on video for fullscreen toggle
const handleVideoClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
const now = Date.now()
const timeSinceLastClick = now - lastClickTimeRef.current
if (timeSinceLastClick < 300) {
// Double click - toggle fullscreen
e.preventDefault()
console.log('🖱️ [Video Click] Double-click detected, toggling fullscreen')
toggleFullscreen()
lastClickTimeRef.current = 0
} else {
// Single click - record time
lastClickTimeRef.current = now
console.log('🖱️ [Video Click] Single click detected')
}
},
[toggleFullscreen]
)
// Handle fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
const isFullscreen = !!document.fullscreenElement
setVideoState((prev) => ({ ...prev, fullscreen: isFullscreen }))
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [setVideoState])
// Handle PIP changes
useEffect(() => {
const handlePIPChange = () => {
const isPIP = !!document.pictureInPictureElement
setVideoState((prev) => ({ ...prev, pictureInPicture: isPIP }))
}
document.addEventListener('enterpictureinpicture', handlePIPChange)
document.addEventListener('leavepictureinpicture', handlePIPChange)
return () => {
document.removeEventListener('enterpictureinpicture', handlePIPChange)
document.removeEventListener('leavepictureinpicture', handlePIPChange)
}
}, [setVideoState])
// Detect HLS source and load hls.js if needed
useEffect(() => {
const video = videoRef.current
if (!video) return
// Validate video URL first
const validation = validateVideoURL(src)
if (!validation.valid) {
const error = new Error(validation.error || 'Invalid video URL')
setVideoState((prev) => ({ ...prev, error, loading: false }))
onError?.(error)
return
}
// Log warning if external URL
if (validation.warning) {
console.warn(`⚠️ [Video] ${validation.warning}`)
}
const isHLS = src.includes('.m3u8')
let cleanupFn: (() => void) | null = null
const setupHls = async () => {
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
// Browser doesn't support HLS natively, load hls.js
try {
// Dynamic import with CDN fallback
const { loadHls, isHlsSupported } = await import('../utils/hlsLoader')
const Hls = await loadHls()
if (!isHlsSupported(Hls)) {
throw new Error('HLS.js is not supported in this browser')
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
})
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('✅ [HLS] Manifest parsed successfully')
// Extract audio tracks after manifest is parsed
// Sometimes audio tracks are not immediately available, so we try with a small delay
setTimeout(() => {
const tracks = getHlsAudioTracks(hls)
if (tracks.length > 0) {
console.log(`✅ [HLS] Found ${tracks.length} audio tracks:`, tracks)
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
} else {
console.log('️ [HLS] No audio tracks found in manifest')
}
}, 100)
if (autoplay) {
video.play().catch((err) => {
console.warn('⚠️ [HLS] Autoplay prevented:', err)
})
}
})
// Also listen to audio track updates
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_event: any, data: any) => {
console.log('🔄 [HLS] Audio tracks updated:', data)
const tracks = getHlsAudioTracks(hls)
if (tracks.length > 0) {
console.log(`✅ [HLS] Found ${tracks.length} audio tracks after update:`, tracks)
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
}
})
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
console.error('❌ [HLS] Error:', data)
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('❌ [HLS] Fatal network error, trying to recover...')
hls.startLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('❌ [HLS] Fatal media error, trying to recover...')
hls.recoverMediaError()
break
default:
console.error('❌ [HLS] Fatal error, cannot recover')
handleError()
break
}
}
})
// Store hls instance for cleanup
;(video as any).__hlsInstance = hls
// Setup cleanup function
cleanupFn = () => {
console.log('🧹 [HLS] Cleaning up HLS instance...')
if (hls) {
hls.destroy()
}
delete (video as any).__hlsInstance
}
} catch (err) {
console.error('❌ [HLS] Failed to load or initialize hls.js:', err)
let error: Error
if (err instanceof Error && isCORSError(err)) {
const corsMessage = getCORSErrorMessage(src)
console.error(corsMessage)
error = new Error('Failed to load HLS stream. This might be a CORS issue. Check console for details.')
} else {
error = err instanceof Error ? err : new Error('Failed to load HLS')
}
setVideoState((prev) => ({
...prev,
error,
loading: false,
}))
onError?.(error)
}
} else {
// Native support or regular video
video.src = src
if (autoplay) {
video.play().catch((err) => {
console.warn('⚠️ [Video] Autoplay prevented:', err)
})
}
}
}
setupHls()
// Cleanup function
return () => {
if (cleanupFn) {
cleanupFn()
}
// Also check for any lingering HLS instance
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
}
}, [src, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded])
// Handle audio track changes
useEffect(() => {
const video = videoRef.current
if (!video || !settings.audioTrack) return
const hlsInstance = (video as any).__hlsInstance
if (!hlsInstance) return
// Find the index of the selected audio track
const trackIndex = availableAudioTracks.findIndex(
(track) => track.language === settings.audioTrack?.language
)
if (trackIndex !== -1) {
setHlsAudioTrack(hlsInstance, trackIndex)
console.log(`✅ [Video] Audio track changed to: ${settings.audioTrack.name}`)
}
}, [settings.audioTrack, availableAudioTracks, videoRef])
return (
<div ref={containerRef} className="video-container">
<video
ref={videoRef}
className="video-element"
poster={poster}
loop={loop}
muted={muted}
playsInline
preload="metadata"
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onEnded={handleEnded}
onError={handleError}
onClick={handleVideoClick}
>
{subtitles.map((subtitle, index) => (
<track
key={index}
kind="subtitles"
src={subtitle.src}
srcLang={subtitle.lang}
label={subtitle.label}
default={subtitle.default}
/>
))}
</video>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
.video-player {
position: relative;
width: 100%;
max-width: 100%;
background-color: var(--player-bg);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
-webkit-user-select: none;
}
.video-player *,
.video-player *::before,
.video-player *::after {
box-sizing: border-box;
}
.video-player:fullscreen {
width: 100vw;
height: 100vh;
}
.video-player:-webkit-full-screen {
width: 100vw;
height: 100vh;
}
.video-player:-moz-full-screen {
width: 100vw;
height: 100vh;
}
.video-player:-ms-fullscreen {
width: 100vw;
height: 100vh;
}
/* Aspect ratio container */
.video-player::before {
content: '';
display: block;
padding-top: 56.25%; /* 16:9 aspect ratio */
}
.video-player > * {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Remove default video controls */
.video-player video::-webkit-media-controls {
display: none !important;
}
.video-player video::-webkit-media-controls-enclosure {
display: none !important;
}
+89
View File
@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react'
import { PlayerProvider } from '../contexts/PlayerContext'
import { VideoElement } from './VideoElement'
import { ControlsLayer } from './ControlsLayer'
import type { VideoPlayerProps, AudioTrack } from '../types'
import { initializePolyfills } from '../utils/polyfills'
import '../styles/variables.css'
import './VideoPlayer.css'
// Initialize polyfills once
let polyfillsInitialized = false
if (!polyfillsInitialized) {
initializePolyfills()
polyfillsInitialized = true
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
src,
poster,
autoplay = false,
loop = false,
muted = false,
controls = true,
subtitles = [],
theme,
keyboardShortcuts = true,
pictureInPicture = true,
className = '',
style,
onPlay,
onPause,
onEnded,
onTimeUpdate,
onVolumeChange,
onError,
onLoadedMetadata,
onSeeking,
onSeeked,
}) => {
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
// Apply theme CSS variables
useEffect(() => {
if (theme) {
const root = document.documentElement
if (theme.primaryColor) root.style.setProperty('--player-primary', theme.primaryColor)
if (theme.accentColor) root.style.setProperty('--player-primary-hover', theme.accentColor)
if (theme.backgroundColor) root.style.setProperty('--player-bg', theme.backgroundColor)
if (theme.textColor) root.style.setProperty('--player-text', theme.textColor)
}
}, [theme])
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
setAudioTracks(tracks)
}, [])
return (
<PlayerProvider initialMuted={muted}>
<div className={`video-player ${className}`} style={style}>
<VideoElement
src={src}
poster={poster}
autoplay={autoplay}
loop={loop}
muted={muted}
subtitles={subtitles}
onPlay={onPlay}
onPause={onPause}
onEnded={onEnded}
onTimeUpdate={onTimeUpdate}
onVolumeChange={onVolumeChange}
onError={onError}
onLoadedMetadata={onLoadedMetadata}
onSeeking={onSeeking}
onSeeked={onSeeked}
onAudioTracksLoaded={handleAudioTracksLoaded}
/>
{controls && (
<ControlsLayer
keyboardShortcuts={keyboardShortcuts}
pictureInPicture={pictureInPicture}
subtitles={subtitles}
audioTracks={audioTracks}
/>
)}
</div>
</PlayerProvider>
)
}
@@ -0,0 +1,55 @@
.center-play-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
pointer-events: none;
}
.center-play-button {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: var(--player-primary);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--player-transition-normal) ease;
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
pointer-events: all;
}
.center-play-button:hover {
background-color: var(--player-primary-hover);
transform: scale(1.1);
box-shadow: 0 6px 30px rgba(239, 68, 68, 0.6);
}
.center-play-button:active {
transform: scale(1);
}
.center-play-button svg {
width: 40px;
height: 40px;
margin-left: 4px; /* Optical adjustment for play icon */
}
@media (max-width: 640px) {
.center-play-button {
width: 64px;
height: 64px;
}
.center-play-button svg {
width: 32px;
height: 32px;
}
}
@@ -0,0 +1,21 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { PlayIcon } from '../../icons'
import './CenterPlayButton.css'
export const CenterPlayButton: React.FC = () => {
const { play } = usePlayerContext()
return (
<div className="center-play-overlay">
<button
className="center-play-button"
onClick={play}
aria-label="Play"
title="Play"
>
<PlayIcon size={64} color="var(--player-text)" />
</button>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
.control-button {
background: none;
border: none;
color: var(--player-text);
cursor: pointer;
padding: var(--player-spacing-sm);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--player-radius-sm);
transition: all var(--player-transition-fast) ease;
position: relative;
}
.control-button:hover {
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.05);
}
.control-button:active {
transform: scale(0.95);
}
.control-button:focus-visible {
outline: 2px solid var(--player-primary);
outline-offset: 2px;
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-button:disabled:hover {
background-color: transparent;
transform: none;
}
/* Icon sizing */
.control-button svg {
width: var(--player-icon-md);
height: var(--player-icon-md);
pointer-events: none;
}
@media (max-width: 640px) {
.control-button {
padding: var(--player-spacing-xs);
}
.control-button svg {
width: var(--player-icon-sm);
height: var(--player-icon-sm);
}
}
@@ -0,0 +1,23 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { FullscreenIcon, FullscreenExitIcon } from '../../icons'
import './ControlButton.css'
export const FullscreenButton: React.FC = () => {
const { videoState, toggleFullscreen } = usePlayerContext()
return (
<button
className="control-button fullscreen-button"
onClick={toggleFullscreen}
aria-label={videoState.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
title={videoState.fullscreen ? 'Exit fullscreen (F)' : 'Enter fullscreen (F)'}
>
{videoState.fullscreen ? (
<FullscreenExitIcon size={24} color="var(--player-text)" />
) : (
<FullscreenIcon size={24} color="var(--player-text)" />
)}
</button>
)
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { PIPIcon } from '../../icons'
import './ControlButton.css'
export const PIPButton: React.FC = () => {
const { videoState, togglePictureInPicture } = usePlayerContext()
// Check if PIP is supported
const isPIPSupported = 'pictureInPictureEnabled' in document
if (!isPIPSupported) {
return null
}
return (
<button
className="control-button pip-button"
onClick={togglePictureInPicture}
aria-label={videoState.pictureInPicture ? 'Exit picture-in-picture' : 'Enter picture-in-picture'}
title="Picture-in-picture (P)"
>
<PIPIcon size={24} color="var(--player-text)" />
</button>
)
}
@@ -0,0 +1,23 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { PlayIcon, PauseIcon } from '../../icons'
import './ControlButton.css'
export const PlayPauseButton: React.FC = () => {
const { videoState, togglePlay } = usePlayerContext()
return (
<button
className="control-button play-pause-button"
onClick={togglePlay}
aria-label={videoState.playing ? 'Pause' : 'Play'}
title={videoState.playing ? 'Pause (Space)' : 'Play (Space)'}
>
{videoState.playing ? (
<PauseIcon size={24} color="var(--player-text)" />
) : (
<PlayIcon size={24} color="var(--player-text)" />
)}
</button>
)
}
+112
View File
@@ -0,0 +1,112 @@
.progress-bar {
position: relative;
width: 100%;
height: 20px;
cursor: pointer;
display: flex;
align-items: center;
}
.progress-track {
position: relative;
width: 100%;
height: 4px;
background-color: var(--player-progress-bg);
border-radius: var(--player-radius-full);
overflow: hidden;
transition: height var(--player-transition-fast) ease;
}
.progress-bar:hover .progress-track,
.progress-bar.seeking .progress-track {
height: 6px;
}
.progress-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--player-buffered);
transition: width 0.1s ease;
}
.progress-played {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--player-primary);
transition: width 0.1s ease;
display: flex;
align-items: center;
justify-content: flex-end;
}
.progress-handle {
width: 12px;
height: 12px;
background-color: var(--player-primary);
border-radius: 50%;
transform: scale(0);
transition: transform var(--player-transition-fast) ease;
margin-right: -6px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.progress-bar:hover .progress-handle,
.progress-bar.seeking .progress-handle {
transform: scale(1);
}
.progress-tooltip {
position: absolute;
bottom: calc(100% + 8px);
transform: translateX(-50%);
background-color: var(--player-bg-menu);
color: var(--player-text);
padding: 4px 8px;
border-radius: var(--player-radius-sm);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--player-shadow-md);
z-index: 10;
}
.progress-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--player-bg-menu);
}
@media (max-width: 640px) {
.progress-bar {
height: 24px;
}
.progress-track {
height: 3px;
}
.progress-bar:hover .progress-track,
.progress-bar.seeking .progress-track {
height: 5px;
}
.progress-handle {
width: 10px;
height: 10px;
margin-right: -5px;
}
/* Always show handle on mobile for easier touch interaction */
.progress-handle {
transform: scale(1);
}
}
+116
View File
@@ -0,0 +1,116 @@
import React, { useRef, useState, useCallback, useEffect } from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import './ProgressBar.css'
export const ProgressBar: React.FC = () => {
const { videoState, seek } = usePlayerContext()
const progressRef = useRef<HTMLDivElement>(null)
const [seeking, setSeeking] = useState(false)
const [hoverTime, setHoverTime] = useState<number | null>(null)
const [hoverPosition, setHoverPosition] = useState<number>(0)
const getProgressFromPosition = useCallback(
(clientX: number): number => {
if (!progressRef.current) return 0
const rect = progressRef.current.getBoundingClientRect()
const position = (clientX - rect.left) / rect.width
return Math.max(0, Math.min(1, position))
},
[]
)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setSeeking(true)
const progress = getProgressFromPosition(e.clientX)
seek(progress * videoState.duration)
},
[getProgressFromPosition, seek, videoState.duration]
)
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const progress = getProgressFromPosition(e.clientX)
const time = progress * videoState.duration
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const position = e.clientX - rect.left
setHoverTime(time)
setHoverPosition(position)
if (seeking) {
seek(time)
}
},
[getProgressFromPosition, videoState.duration, seeking, seek]
)
const handleMouseUp = useCallback(() => {
setSeeking(false)
}, [])
const handleMouseLeave = useCallback(() => {
setHoverTime(null)
setSeeking(false)
}, [])
useEffect(() => {
if (seeking) {
const handleGlobalMouseUp = () => setSeeking(false)
window.addEventListener('mouseup', handleGlobalMouseUp)
return () => window.removeEventListener('mouseup', handleGlobalMouseUp)
}
}, [seeking])
const progress = videoState.duration > 0 ? (videoState.currentTime / videoState.duration) * 100 : 0
const buffered = videoState.duration > 0 ? (videoState.buffered / videoState.duration) * 100 : 0
const formatTime = (seconds: number): string => {
if (isNaN(seconds) || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div
ref={progressRef}
className={`progress-bar ${seeking ? 'seeking' : ''}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
role="slider"
aria-label="Video progress"
aria-valuemin={0}
aria-valuemax={videoState.duration}
aria-valuenow={videoState.currentTime}
aria-valuetext={formatTime(videoState.currentTime)}
>
{/* Background track */}
<div className="progress-track">
{/* Buffered progress */}
<div className="progress-buffered" style={{ width: `${buffered}%` }} />
{/* Played progress */}
<div className="progress-played" style={{ width: `${progress}%` }}>
<div className="progress-handle" />
</div>
</div>
{/* Hover time tooltip */}
{hoverTime !== null && (
<div
className="progress-tooltip"
style={{
left: `${hoverPosition}px`,
}}
>
{formatTime(hoverTime)}
</div>
)}
</div>
)
}
@@ -0,0 +1,19 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { SettingsIcon } from '../../icons'
import './ControlButton.css'
export const SettingsButton: React.FC = () => {
const { toggleSettings } = usePlayerContext()
return (
<button
className="control-button settings-button"
onClick={toggleSettings}
aria-label="Settings"
title="Settings"
>
<SettingsIcon size={24} color="var(--player-text)" />
</button>
)
}
+24
View File
@@ -0,0 +1,24 @@
.time-display {
display: flex;
align-items: center;
gap: 4px;
color: var(--player-text);
font-size: 14px;
font-weight: 500;
font-variant-numeric: tabular-nums;
user-select: none;
}
.time-separator {
color: var(--player-text-secondary);
}
.time-duration {
color: var(--player-text-secondary);
}
@media (max-width: 640px) {
.time-display {
font-size: 12px;
}
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { formatTime } from '../../utils/time'
import './TimeDisplay.css'
export const TimeDisplay: React.FC = () => {
const { videoState } = usePlayerContext()
return (
<div className="time-display">
<span className="time-current">{formatTime(videoState.currentTime)}</span>
<span className="time-separator">/</span>
<span className="time-duration">{formatTime(videoState.duration)}</span>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
.volume-control {
display: flex;
align-items: center;
gap: var(--player-spacing-sm);
position: relative;
}
.volume-slider-container {
position: relative;
width: 0;
height: 6px;
background-color: var(--player-progress-bg);
border-radius: var(--player-radius-full);
overflow: visible;
transition: width var(--player-transition-normal) ease, opacity var(--player-transition-normal) ease;
opacity: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.volume-slider-container.visible {
width: 100px;
opacity: 1;
}
.volume-slider {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 100%;
transform: translateY(-50%);
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
z-index: 2;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--player-primary);
cursor: pointer;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
}
.volume-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background-color: var(--player-primary);
cursor: pointer;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
}
.volume-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
}
.volume-slider:focus {
outline: none;
}
.volume-slider:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--player-primary);
outline-offset: 2px;
}
.volume-slider:focus-visible::-moz-range-thumb {
outline: 2px solid var(--player-primary);
outline-offset: 2px;
}
.volume-slider-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, var(--player-primary) 0%, var(--player-primary-hover) 100%);
pointer-events: none;
transition: width 0.1s ease;
z-index: 1;
border-radius: var(--player-radius-full);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
}
/* Mobile: Show slider vertically */
@media (max-width: 640px) {
.volume-slider-container.visible {
width: 70px;
}
}
+67
View File
@@ -0,0 +1,67 @@
import React, { useState, useRef, useCallback } from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { VolumeUpIcon, VolumeDownIcon, VolumeMuteIcon } from '../../icons'
import './VolumeControl.css'
export const VolumeControl: React.FC = () => {
const { videoState, setVolume, toggleMute } = usePlayerContext()
const [showSlider, setShowSlider] = useState(false)
const timeoutRef = useRef<number>()
const handleMouseEnter = useCallback(() => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
}
setShowSlider(true)
}, [])
const handleMouseLeave = useCallback(() => {
timeoutRef.current = window.setTimeout(() => {
setShowSlider(false)
}, 300)
}, [])
const handleSliderChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const volume = parseFloat(e.target.value)
setVolume(volume)
},
[setVolume]
)
const VolumeIcon = videoState.muted ? VolumeMuteIcon : videoState.volume > 0.5 ? VolumeUpIcon : VolumeDownIcon
return (
<div
className="volume-control"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
className="control-button volume-button"
onClick={toggleMute}
aria-label={videoState.muted ? 'Unmute' : 'Mute'}
title={videoState.muted ? 'Unmute (M)' : 'Mute (M)'}
>
<VolumeIcon size={24} color="var(--player-text)" />
</button>
<div className={`volume-slider-container ${showSlider ? 'visible' : ''}`}>
<input
type="range"
min="0"
max="1"
step="0.01"
value={videoState.muted ? 0 : videoState.volume}
onChange={handleSliderChange}
className="volume-slider"
aria-label="Volume"
/>
<div
className="volume-slider-fill"
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
/>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
.settings-menu {
position: absolute;
bottom: calc(100% + 12px);
right: 0;
background-color: var(--player-bg-menu);
border-radius: var(--player-radius-lg);
box-shadow: var(--player-shadow-lg);
min-width: 300px;
max-height: 400px;
overflow: hidden;
z-index: var(--player-z-menu);
animation: slideUp var(--player-transition-normal) ease;
backdrop-filter: blur(10px);
}
.settings-menu-header {
padding: var(--player-spacing-md) var(--player-spacing-lg);
border-bottom: 1px solid var(--player-divider);
display: flex;
align-items: center;
gap: var(--player-spacing-sm);
position: relative;
}
.settings-menu-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--player-text);
flex: 1;
}
.settings-back-button {
background: none;
border: none;
color: var(--player-text);
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--player-radius-sm);
transition: background-color var(--player-transition-fast) ease;
margin-left: -8px;
}
.settings-back-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Main Options (Two-level menu) */
.settings-main-options {
display: flex;
flex-direction: column;
}
.settings-main-option {
display: flex;
align-items: center;
gap: var(--player-spacing-md);
padding: var(--player-spacing-md) var(--player-spacing-lg);
background: none;
border: none;
color: var(--player-text);
cursor: pointer;
transition: background-color var(--player-transition-fast) ease;
text-align: left;
width: 100%;
}
.settings-main-option:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.settings-main-option-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background-color: rgba(239, 68, 68, 0.1);
border-radius: var(--player-radius-md);
}
.settings-main-option-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-main-option-label {
font-size: 15px;
font-weight: 500;
color: var(--player-text);
}
.settings-main-option-value {
font-size: 13px;
color: var(--player-text-secondary);
}
.settings-main-option-arrow {
font-size: 24px;
color: var(--player-text-secondary);
font-weight: 300;
}
/* Submenu Options */
.settings-options {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
}
.settings-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--player-spacing-md) var(--player-spacing-lg);
background: none;
border: none;
color: var(--player-text);
font-size: 15px;
cursor: pointer;
transition: background-color var(--player-transition-fast) ease;
text-align: left;
}
.settings-option:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.settings-option.active {
color: var(--player-primary);
background-color: rgba(239, 68, 68, 0.1);
}
.settings-option span {
flex: 1;
}
/* Empty state message */
.settings-empty-state {
padding: var(--player-spacing-xl) var(--player-spacing-lg);
text-align: center;
color: var(--player-text-muted);
font-size: 14px;
}
/* Scrollbar styling */
.settings-options::-webkit-scrollbar {
width: 6px;
}
.settings-options::-webkit-scrollbar-track {
background: transparent;
}
.settings-options::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.settings-options::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
}
@media (max-width: 640px) {
.settings-menu {
min-width: 280px;
max-height: 360px;
}
.settings-main-option {
padding: var(--player-spacing-sm) var(--player-spacing-md);
}
.settings-main-option-icon {
width: 32px;
height: 32px;
}
.settings-option {
padding: var(--player-spacing-sm) var(--player-spacing-md);
}
.settings-options {
max-height: 280px;
}
}
+201
View File
@@ -0,0 +1,201 @@
import React, { useEffect, useRef, useState } from 'react'
import { usePlayerContext } from '../../contexts/PlayerContext'
import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon } from '../../icons'
import type { AudioTrack } from '../../types'
import './SettingsMenu.css'
interface SettingsMenuProps {
subtitles?: Array<{ src: string; lang: string; label: string }>
audioTracks?: AudioTrack[]
}
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio'
export const SettingsMenu: React.FC<SettingsMenuProps> = ({ subtitles = [], audioTracks = [] }) => {
const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext()
const menuRef = useRef<HTMLDivElement>(null)
const [currentView, setCurrentView] = useState<MenuView>('main')
const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
// Close menu when clicking outside
useEffect(() => {
if (!uiState.settingsOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
toggleSettings()
setCurrentView('main')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [uiState.settingsOpen, toggleSettings])
// Reset to main view when menu closes
useEffect(() => {
if (!uiState.settingsOpen) {
setCurrentView('main')
}
}, [uiState.settingsOpen])
const goBack = () => {
setCurrentView('main')
}
if (!uiState.settingsOpen) return null
return (
<div ref={menuRef} className="settings-menu">
{/* Main Menu */}
{currentView === 'main' && (
<>
<div className="settings-menu-header">
<h3>Ayarlar</h3>
</div>
<div className="settings-main-options">
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
<div className="settings-main-option-icon">
<SpeedIcon size={20} color="var(--player-text)" />
</div>
<div className="settings-main-option-content">
<span className="settings-main-option-label">Hız</span>
<span className="settings-main-option-value">
{videoState.playbackRate === 1 ? 'Normal' : `${videoState.playbackRate}x`}
</span>
</div>
<div className="settings-main-option-arrow"></div>
</button>
<button className="settings-main-option" onClick={() => setCurrentView('subtitles')}>
<div className="settings-main-option-icon">
<SubtitlesIcon size={20} color="var(--player-text)" />
</div>
<div className="settings-main-option-content">
<span className="settings-main-option-label">Altyazı</span>
<span className="settings-main-option-value">
{settings.subtitle ? settings.subtitle.label : 'Kapalı'}
</span>
</div>
<div className="settings-main-option-arrow"></div>
</button>
{audioTracks.length > 0 && (
<button className="settings-main-option" onClick={() => setCurrentView('audio')}>
<div className="settings-main-option-icon">
<AudioIcon size={20} color="var(--player-text)" />
</div>
<div className="settings-main-option-content">
<span className="settings-main-option-label">Ses</span>
<span className="settings-main-option-value">
{settings.audioTrack ? settings.audioTrack.name : 'Varsayılan'}
</span>
</div>
<div className="settings-main-option-arrow"></div>
</button>
)}
</div>
</>
)}
{/* Speed Submenu */}
{currentView === 'speed' && (
<>
<div className="settings-menu-header">
<button className="settings-back-button" onClick={goBack}>
</button>
<h3>Oynatma Hızı</h3>
</div>
<div className="settings-options">
{playbackRates.map((rate) => (
<button
key={rate}
className={`settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
onClick={() => {
setPlaybackRate(rate)
setTimeout(() => goBack(), 150)
}}
>
<span>{rate === 1 ? 'Normal' : `${rate}x`}</span>
{videoState.playbackRate === rate && <CheckIcon size={16} color="var(--player-primary)" />}
</button>
))}
</div>
</>
)}
{/* Subtitles Submenu */}
{currentView === 'subtitles' && (
<>
<div className="settings-menu-header">
<button className="settings-back-button" onClick={goBack}>
</button>
<h3>Altyazı</h3>
</div>
<div className="settings-options">
<button
className={`settings-option ${!settings.subtitle ? 'active' : ''}`}
onClick={() => {
setSubtitle(null)
setTimeout(() => goBack(), 150)
}}
>
<span>Kapalı</span>
{!settings.subtitle && <CheckIcon size={16} color="var(--player-primary)" />}
</button>
{subtitles.length > 0 ? (
subtitles.map((subtitle) => (
<button
key={subtitle.lang}
className={`settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
onClick={() => {
setSubtitle(subtitle)
setTimeout(() => goBack(), 150)
}}
>
<span>{subtitle.label}</span>
{settings.subtitle?.lang === subtitle.lang && <CheckIcon size={16} color="var(--player-primary)" />}
</button>
))
) : (
<div className="settings-empty-state">
<span>Altyazı mevcut değil</span>
</div>
)}
</div>
</>
)}
{/* Audio Submenu */}
{currentView === 'audio' && (
<>
<div className="settings-menu-header">
<button className="settings-back-button" onClick={goBack}>
</button>
<h3>Ses</h3>
</div>
<div className="settings-options">
{audioTracks.map((track) => (
<button
key={track.language}
className={`settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
onClick={() => {
setAudioTrack(track)
setTimeout(() => goBack(), 150)
}}
>
<span>{track.name}</span>
{settings.audioTrack?.language === track.language && <CheckIcon size={16} color="var(--player-primary)" />}
</button>
))}
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,26 @@
.loading-spinner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.3);
z-index: var(--player-z-loading);
pointer-events: none;
}
.loading-spinner {
animation: fadeIn var(--player-transition-normal) ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,13 @@
import React from 'react'
import { LoadingIcon } from '../../icons'
import './LoadingSpinner.css'
export const LoadingSpinner: React.FC = () => {
return (
<div className="loading-spinner-overlay">
<div className="loading-spinner">
<LoadingIcon size={48} color="var(--player-primary)" />
</div>
</div>
)
}