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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user