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
@@ -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>
)
}