feat: add animated image and audio player support

This commit is contained in:
hibna
2026-02-14 12:54:08 +03:00
parent 39406822ae
commit ef39d20b43
16 changed files with 1505 additions and 54 deletions
+16
View File
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
## [3.2.0] - 2026-02-14
### Added
- Added animated image support to `VideoPlayer` with auto-detection for `.gif`, `.webp`, `.apng`, and `.avif` sources.
- Added `mediaType` prop to `VideoPlayer` (`'auto' | 'video' | 'animated-image'`) for explicit media mode control.
- Added new `AudioPlayer` component with custom controls, keyboard shortcuts, theme support, slots, and imperative ref handle.
- Added `mediaSource` utilities: `detectPlayerMediaType`, `isAnimatedImageSource`, and `isAudioSource`.
- Added automated tests for animated image mode and the new audio player.
### Changed
- Updated public exports to include `AudioPlayer`, new media detection utilities, and new type exports (`AudioPlayerProps`, `AudioPlayerHandle`, `VideoMediaType`, `VideoMediaTypeInput`).
- Updated README and DOCUMENTATION for animated image and audio playback usage.
- Bumped package version to `3.2.0`.
## [3.1.2] - 2026-02-13
### Changed
+125 -6
View File
@@ -1,6 +1,6 @@
# @source/player Documentation
This document reflects the current codebase in this repository (`version 3.1.0`) and replaces older, drifted documentation.
This document reflects the current codebase in this repository (`version 3.2.0`) and replaces older, drifted documentation.
## Table of contents
@@ -21,9 +21,11 @@ This document reflects the current codebase in this repository (`version 3.1.0`)
## 1. Overview
`@source/player` is a React video player library with:
`@source/player` is a React media player library with:
- Protocol-aware playback (`native`, `hls`, `rtmp/flv`, `mpegts`)
- Animated image playback (`.gif`, `.webp`, `.apng`, `.avif`) through a minimal render path
- Dedicated audio playback component for music/podcast style use-cases
- Built-in controls with settings menus (speed, quality, subtitles, subtitle styling, audio tracks)
- Modular extension points for custom controls and overlays
- Strong TypeScript API (props, handle types, context types, utility exports)
@@ -36,9 +38,11 @@ The player is split into focused modules.
### 2.1 High-level component graph
- `VideoPlayer`
- `AudioPlayer`
- `PlayerErrorBoundary`
- `PlayerProvider` (context + state + actions + i18n)
- `VideoElement` (media element, protocol setup, media events, subtitle rendering)
- `mediaSource` utils (media kind detection for video/audio/animated image)
- `ControlsLayer` (controls visibility, settings menu, keyboard/touch integration)
- `SettingsMenu` (lazy-loaded, menu subviews)
@@ -50,6 +54,11 @@ The player is split into focused modules.
- Wraps content with `PlayerErrorBoundary` and `PlayerProvider`
- Exposes imperative API via ref (`VideoPlayerHandle`)
- `AudioPlayer`
- Uses native `HTMLAudioElement` with a custom control surface
- Supports keyboard shortcuts, playback rate cycling, and metadata/artwork UI
- Exposes imperative API via ref (`AudioPlayerHandle`)
- `PlayerProvider`
- Owns central `videoState`, `uiState`, and `settings`
- Manages subtitle style draft/commit/persist lifecycle
@@ -150,6 +159,29 @@ export function App() {
</VideoPlayer>
```
### 4.4 Animated image support
```tsx
<VideoPlayer src="https://example.com/animations/loader.gif" />
```
`VideoPlayer` auto-detects animated image sources and renders them with a lightweight `<img>` path.
In this mode, control layer is hidden automatically for minimal runtime cost.
### 4.5 Dedicated audio player
```tsx
import { AudioPlayer } from '@source/player'
<AudioPlayer
src="https://example.com/audio/episode.mp3"
title="Episode 12"
subtitle="Engineering Notes"
artwork="https://example.com/audio/cover.jpg"
playbackRates={[0.75, 1, 1.25, 1.5, 2]}
/>
```
## 5. Streaming and protocol handling
### 5.1 Auto protocol detection
@@ -215,6 +247,15 @@ Important note:
- Progress bar and time display are hidden
- Live badge is shown in controls
### 5.7 Animated image detection behavior
`VideoPlayer` can detect animated image sources by extension or data MIME:
- `.gif`, `.webp`, `.apng`, `.avif`
- `data:image/gif`, `data:image/webp`, `data:image/apng`, `data:image/avif`
When detected, player uses image render path instead of protocol setup and stream engines.
## 6. Subtitles and subtitle style editor
### 6.1 Subtitle sources
@@ -397,6 +438,7 @@ You can override any key with `translations?: Partial<Translations>`.
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `src` | `string` | required | media URL |
| `mediaType` | `'auto' \| 'video' \| 'animated-image'` | `'auto'` | force image/video mode |
| `protocol` | `'auto' \| 'native' \| 'hls' \| 'rtmp' \| 'dash' \| 'mpegts'` | `'auto'` | force engine |
| `poster` | `string` | - | poster image |
| `autoplay` | `boolean` | `false` | autoplay attempt on load |
@@ -498,7 +540,76 @@ interface VideoPlayerHandle {
}
```
### 10.3 `PlayerErrorBoundaryProps`
### 10.3 `AudioPlayerProps`
#### Source and playback
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `src` | `string` | required | audio URL |
| `autoplay` | `boolean` | `false` | autoplay attempt on load |
| `loop` | `boolean` | `false` | loop playback |
| `muted` | `boolean` | `false` | initial muted state |
| `volume` | `number` | - | clamped to `0..1` |
| `playbackRate` | `number` | - | initial/current rate |
| `currentTime` | `number` | - | seeks when difference is significant |
| `preload` | `'none' \| 'metadata' \| 'auto'` | `'metadata'` | media preload hint |
| `crossOrigin` | `'' \| 'anonymous' \| 'use-credentials'` | - | CORS mode |
#### UI, metadata and composition
| Prop | Type | Default |
| --- | --- | --- |
| `controls` | `boolean` | `true` |
| `keyboardShortcuts` | `boolean` | `true` |
| `playbackRates` | `number[]` | `[0.5,0.75,1,1.25,1.5,2]` |
| `title` | `string` | - |
| `subtitle` | `string` | - |
| `artwork` | `string` | - |
| `theme` | `PlayerTheme` | - |
| `className` | `string` | `''` |
| `style` | `CSSProperties` | - |
| `children` | `ReactNode` | - |
| `controlsLeftExtra` | `ReactNode` | - |
| `controlsRightExtra` | `ReactNode` | - |
| `language` | `string` | browser language |
| `translations` | `Partial<Translations>` | - |
#### Events
| Prop | Type |
| --- | --- |
| `onPlay` | `() => void` |
| `onPause` | `() => void` |
| `onEnded` | `() => void` |
| `onTimeUpdate` | `(currentTime: number) => void` |
| `onVolumeChange` | `(volume: number) => void` |
| `onError` | `(error: Error) => void` |
| `onLoadedMetadata` | `() => void` |
| `onSeeking` | `() => void` |
| `onSeeked` | `() => void` |
| `onProgress` | `(buffered: number) => void` |
| `onDurationChange` | `(duration: number) => void` |
| `onRateChange` | `(playbackRate: number) => void` |
| `onWaiting` | `() => void` |
| `onCanPlay` | `() => void` |
### 10.4 `AudioPlayerHandle`
```ts
interface AudioPlayerHandle {
audio: HTMLAudioElement | null
container: HTMLDivElement | null
play(): void
pause(): void
seek(time: number): void
setVolume(volume: number): void
toggleMute(): void
setPlaybackRate(rate: number): void
}
```
### 10.5 `PlayerErrorBoundaryProps`
```ts
interface PlayerErrorBoundaryProps {
@@ -510,7 +621,7 @@ interface PlayerErrorBoundaryProps {
}
```
### 10.4 Core types
### 10.6 Core types
```ts
type SubtitleTrack = {
@@ -572,6 +683,7 @@ From `src/index.ts`:
- Components
- `VideoPlayer`
- `AudioPlayer`
- `PlayerErrorBoundary`
- Context
@@ -592,10 +704,13 @@ From `src/index.ts`:
- `parseSRT`, `createSubtitleBlobURL`, `fetchSubtitle`
- `validateVideoURL`, `getCORSErrorMessage`, `isCORSError`, `checkVideoCORS`
- `initializePolyfills`, `features`
- `detectPlayerMediaType`, `isAnimatedImageSource`, `isAudioSource`
- Types
- `VideoPlayerProps`, `VideoPlayerHandle`, `SubtitleTrack`, `SubtitleStyle`, `SubtitleStyleEditorConfig`
- `VideoPlayerProps`, `VideoPlayerHandle`, `AudioPlayerProps`, `AudioPlayerHandle`
- `SubtitleTrack`, `SubtitleStyle`, `SubtitleStyleEditorConfig`
- `SubtitlePosition`, `AudioTrack`, `VideoQuality`, `PlayerTheme`
- `VideoMediaType`, `VideoMediaTypeInput`
- `KeyboardShortcutConfig`, `TouchConfig`
- `VideoState`, `UIState`, `PlayerSettings`, `PlayerContextValue`
- `Translations`
@@ -654,6 +769,8 @@ For HLS/FLV/MPEG-TS setups:
- Direct RTMP URLs usually need HTTP-FLV proxying.
- DASH detection exists but playback is not implemented yet.
- MPEG-TS behavior depends on MSE support and stream/server conditions.
- Animated image mode is display-only (no timeline controls by design).
- Some browsers may restrict autoplay for audio unless muted or user-initiated.
Polyfill and feature utilities:
@@ -688,12 +805,14 @@ npm run validate:publish
### 14.2 Test coverage areas in repository
- Core rendering and props (`VideoPlayer.test.tsx`)
- Audio rendering and controls (`AudioPlayer.test.tsx`)
- Settings interactions (`SettingsMenu.test.tsx`)
- Error boundary reset and fallback behavior
- Keyboard and touch hook behavior
- Protocol detection and streaming setup utilities
- Media type detection (`mediaSource.test.ts`)
- CORS helper validation
---
If you maintain this document, update it together with changes to `src/types/index.ts`, `src/index.ts`, and `src/components/VideoPlayer.tsx` to avoid API drift.
If you maintain this document, update it together with changes to `src/types/index.ts`, `src/index.ts`, `src/components/VideoPlayer.tsx`, and `src/components/AudioPlayer.tsx` to avoid API drift.
+39 -4
View File
@@ -1,13 +1,15 @@
# @source/player
`@source/player` is a modular, highly customizable React video player library for VOD and live streaming workflows.
`@source/player` is a modular, highly customizable React media player library for VOD, live streaming, animated images, and audio playback workflows.
Current package version: `3.1.0`.
Current package version: `3.2.0`.
## Why this player
- React-first component architecture (`VideoPlayer`, `PlayerProvider`, `usePlayerContext`)
- React-first component architecture (`VideoPlayer`, `AudioPlayer`, `PlayerProvider`, `usePlayerContext`)
- Protocol-aware playback with automatic detection (`native`, `hls`, `rtmp/flv`, `mpegts`)
- Built-in animated image support (`.gif`, `.webp`, `.apng`, `.avif`) with minimal render path
- Dedicated audio player component for `.mp3`, `.wav`, `.flac`, `.m4a`, and similar formats
- Runtime loading for streaming engines (`hls.js`, `flv.js`, `mpegts.js`) with CDN fallback
- Built-in settings UI for speed, quality, audio track, subtitles, and subtitle style editor
- Slot-style customization (`children`, `controlsLeftExtra`, `controlsRightExtra`)
@@ -113,11 +115,31 @@ export function App() {
/>
```
### 6. Animated image playback (GIF / WebP / APNG / AVIF)
```tsx
<VideoPlayer src="https://cdn.example.com/loop.gif" />
```
### 7. Dedicated audio player
```tsx
import { AudioPlayer } from '@source/player'
<AudioPlayer
src="https://cdn.example.com/podcast-episode.mp3"
title="Episode 12"
subtitle="Weekly Tech Podcast"
artwork="https://cdn.example.com/episode-cover.jpg"
playbackRates={[0.75, 1, 1.25, 1.5, 2]}
/>
```
## API at a glance
Key `VideoPlayer` props:
- Playback: `src`, `protocol`, `autoplay`, `loop`, `muted`, `volume`, `playbackRate`, `currentTime`
- Playback: `src`, `mediaType`, `protocol`, `autoplay`, `loop`, `muted`, `volume`, `playbackRate`, `currentTime`
- Media element config: `crossOrigin`, `preload`, `playsInline`, `controlsList`
- UI toggles: `controls`, `keyboardShortcuts`, `pictureInPicture`
- Customization: `theme`, `className`, `style`, `aspectRatio`, `playbackRates`
@@ -127,6 +149,13 @@ Key `VideoPlayer` props:
- Localization: `language`, `translations`
- Events: `onPlay`, `onPause`, `onEnded`, `onTimeUpdate`, `onError`, `onQualityChange`, `onBufferStart`, `onBufferEnd`, `onFirstPlay`, and more
Key `AudioPlayer` props:
- Source + playback: `src`, `autoplay`, `loop`, `muted`, `volume`, `playbackRate`, `currentTime`
- UI + customization: `controls`, `keyboardShortcuts`, `playbackRates`, `theme`, `className`, `style`
- Metadata + slots: `title`, `subtitle`, `artwork`, `children`, `controlsLeftExtra`, `controlsRightExtra`
- Localization + events: `language`, `translations`, `onPlay`, `onPause`, `onTimeUpdate`, `onError`, and more
## Imperative control via ref
```tsx
@@ -151,6 +180,7 @@ export function PlayerWithRef() {
```ts
import {
AudioPlayer,
PlayerErrorBoundary,
PlayerProvider,
usePlayerContext,
@@ -165,6 +195,9 @@ import {
checkVideoCORS,
initializePolyfills,
features,
detectPlayerMediaType,
isAnimatedImageSource,
isAudioSource,
getTranslations,
detectBrowserLanguage,
} from '@source/player'
@@ -175,7 +208,9 @@ import {
For complete details, see `DOCUMENTATION.md`:
- Full `VideoPlayerProps` and event reference
- Full `AudioPlayerProps` and event reference
- Streaming architecture (HLS/RTMP-FLV/MPEG-TS)
- Animated image detection and render behavior
- Subtitle rendering and style editor internals
- Theme tokens and CSS variable mapping
- Keyboard and touch behavior
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@source/player",
"version": "3.1.2",
"version": "3.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@source/player",
"version": "3.1.2",
"version": "3.2.0",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.38.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@source/player",
"version": "3.1.2",
"version": "3.2.0",
"description": "Modern, feature-rich video player library for React",
"type": "module",
"main": "./dist/video-player.umd.cjs",
+193
View File
@@ -0,0 +1,193 @@
.sp-audio-player {
position: relative;
display: flex;
flex-direction: column;
gap: var(--player-spacing-md);
width: 100%;
max-width: 100%;
padding: var(--player-spacing-md);
border-radius: var(--player-radius);
background: linear-gradient(160deg, rgba(0, 0, 0, 0.78), rgba(22, 22, 22, 0.94));
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--player-text);
font-family: var(--player-font-family, 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
}
.sp-audio-player *,
.sp-audio-player *::before,
.sp-audio-player *::after {
box-sizing: border-box;
}
.sp-audio-player audio {
display: none;
}
.sp-audio-header {
display: flex;
align-items: center;
gap: var(--player-spacing-md);
min-height: 48px;
}
.sp-audio-artwork {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: var(--player-radius-sm);
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
}
.sp-audio-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sp-audio-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-audio-subtitle {
font-size: 12px;
color: var(--player-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-audio-header-extra {
margin-left: auto;
}
.sp-audio-progress-wrap {
position: relative;
width: 100%;
height: 18px;
display: flex;
align-items: center;
}
.sp-audio-progress-track {
position: absolute;
left: 0;
right: 0;
height: 4px;
border-radius: var(--player-radius-full);
background: var(--player-progress-bg);
overflow: hidden;
pointer-events: none;
}
.sp-audio-progress-buffered {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: var(--player-progress-buffered);
}
.sp-audio-progress-played {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: linear-gradient(90deg, var(--player-primary), var(--player-primary-hover));
}
.sp-audio-progress-input {
width: 100%;
height: 18px;
margin: 0;
opacity: 0;
cursor: pointer;
}
.sp-audio-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--player-spacing-md);
}
.sp-audio-controls-left,
.sp-audio-controls-right {
display: flex;
align-items: center;
gap: var(--player-spacing-sm);
}
.sp-audio-controls-center {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--player-text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sp-audio-volume-slider {
width: 88px;
accent-color: var(--player-primary);
}
.sp-audio-speed-button {
gap: 6px;
font-size: 12px;
color: var(--player-text-secondary);
}
.sp-audio-speed-button span {
min-width: 34px;
text-align: left;
}
.sp-audio-loading-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--player-primary), transparent);
animation: sp-audio-loading-slide 1.3s ease-in-out infinite;
}
@keyframes sp-audio-loading-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@media (max-width: 768px) {
.sp-audio-player {
padding: var(--player-spacing-sm);
gap: var(--player-spacing-sm);
}
.sp-audio-controls {
flex-wrap: wrap;
justify-content: flex-start;
}
.sp-audio-controls-center {
width: 100%;
justify-content: flex-end;
}
.sp-audio-volume-slider {
width: 74px;
}
}
+97
View File
@@ -0,0 +1,97 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AudioPlayer } from './AudioPlayer'
describe('AudioPlayer', () => {
const defaultProps = {
src: 'https://example.com/audio.mp3',
}
it('renders audio player container and audio element', () => {
const { container } = render(<AudioPlayer {...defaultProps} />)
expect(container.querySelector('.sp-audio-player')).toBeInTheDocument()
expect(container.querySelector('audio')).toBeInTheDocument()
})
it('renders artwork and metadata when provided', () => {
const { container, getByText } = render(
<AudioPlayer
{...defaultProps}
artwork="https://example.com/cover.jpg"
title="Sample Track"
subtitle="Artist Name"
/>
)
expect(container.querySelector('.sp-audio-artwork')).toBeInTheDocument()
expect(getByText('Sample Track')).toBeInTheDocument()
expect(getByText('Artist Name')).toBeInTheDocument()
})
it('calls onPlay callback when play event fires', async () => {
const onPlay = vi.fn()
const { container } = render(<AudioPlayer {...defaultProps} onPlay={onPlay} />)
const audio = container.querySelector('audio') as HTMLAudioElement
act(() => {
fireEvent.play(audio)
})
await waitFor(() => {
expect(onPlay).toHaveBeenCalled()
})
})
it('cycles playback rate from controls', async () => {
const onRateChange = vi.fn()
const { container } = render(
<AudioPlayer
{...defaultProps}
playbackRates={[1, 1.5, 2]}
onRateChange={onRateChange}
/>
)
const speedButton = container.querySelector('.sp-audio-speed-button') as HTMLButtonElement
const audio = container.querySelector('audio') as HTMLAudioElement
act(() => {
fireEvent.click(speedButton)
fireEvent.rateChange(audio)
})
await waitFor(() => {
expect(onRateChange).toHaveBeenCalled()
})
})
it('seeks when progress slider changes', () => {
const { container } = render(<AudioPlayer {...defaultProps} />)
const audio = container.querySelector('audio') as HTMLAudioElement
let currentTime = 20
Object.defineProperty(audio, 'duration', {
configurable: true,
get: () => 180,
})
Object.defineProperty(audio, 'currentTime', {
configurable: true,
get: () => currentTime,
set: (value: number) => {
currentTime = value
},
})
act(() => {
fireEvent.loadedMetadata(audio)
})
const progressInput = container.querySelector('.sp-audio-progress-input') as HTMLInputElement
act(() => {
fireEvent.change(progressInput, { target: { value: '45' } })
})
expect(currentTime).toBe(45)
})
})
+661
View File
@@ -0,0 +1,661 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { PlayIcon, PauseIcon, VolumeUpIcon, VolumeDownIcon, VolumeMuteIcon, SpeedIcon } from '../icons'
import { formatTime } from '../utils/time'
import { getTranslations, detectBrowserLanguage } from '../i18n'
import type { AudioPlayerHandle, AudioPlayerProps } from '../types'
import '../styles/variables.css'
import './controls/ControlButton.css'
import './AudioPlayer.css'
interface AudioPlayerState {
playing: boolean
currentTime: number
duration: number
buffered: number
volume: number
muted: boolean
playbackRate: number
loading: boolean
error: Error | null
seeking: boolean
}
const DEFAULT_PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2]
const KEYBOARD_SEEK_STEP_SECONDS = 5
const KEYBOARD_VOLUME_STEP = 0.1
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value))
const normalizePlaybackRates = (rates?: number[]): number[] => {
const source = Array.isArray(rates) && rates.length > 0 ? rates : DEFAULT_PLAYBACK_RATES
const normalized = Array.from(new Set(source.filter((rate) => Number.isFinite(rate) && rate > 0)))
if (!normalized.includes(1)) {
normalized.push(1)
}
return normalized.sort((a, b) => a - b)
}
export const AudioPlayer = forwardRef<AudioPlayerHandle, AudioPlayerProps>(
(
{
src,
artwork,
title,
subtitle,
autoplay = false,
loop = false,
muted = false,
volume,
playbackRate,
currentTime: initialCurrentTime,
crossOrigin,
preload = 'metadata',
controls = true,
keyboardShortcuts = true,
theme,
language,
className = '',
style,
playbackRates: playbackRatesProp,
translations: customTranslations,
children,
controlsLeftExtra,
controlsRightExtra,
onPlay,
onPause,
onEnded,
onTimeUpdate,
onVolumeChange,
onError,
onLoadedMetadata,
onSeeking,
onSeeked,
onProgress,
onDurationChange,
onRateChange,
onWaiting,
onCanPlay,
},
ref
) => {
const audioRef = React.useRef<HTMLAudioElement | null>(null)
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [isActivePlayer, setIsActivePlayer] = useState(false)
const [audioState, setAudioState] = useState<AudioPlayerState>({
playing: false,
currentTime: 0,
duration: 0,
buffered: 0,
volume: volume === undefined ? 1 : clamp01(volume),
muted,
playbackRate: playbackRate && playbackRate > 0 ? playbackRate : 1,
loading: true,
error: null,
seeking: false,
})
const playbackRates = useMemo(() => normalizePlaybackRates(playbackRatesProp), [playbackRatesProp])
const translations = useMemo(() => {
const baseTranslations = getTranslations(language || detectBrowserLanguage())
return customTranslations ? { ...baseTranslations, ...customTranslations } : baseTranslations
}, [customTranslations, language])
const themedStyle = useMemo<React.CSSProperties>(() => {
const cssVariables: Record<string, string> = {}
if (theme?.primaryColor) {
cssVariables['--player-primary'] = theme.primaryColor
}
if (theme?.accentColor) {
cssVariables['--player-primary-hover'] = theme.accentColor
}
if (theme?.backgroundColor) {
cssVariables['--player-bg'] = theme.backgroundColor
}
if (theme?.textColor) {
cssVariables['--player-text'] = theme.textColor
}
if (theme?.fontFamily) {
cssVariables['--player-font-family'] = theme.fontFamily
}
if (theme?.borderRadius !== undefined) {
cssVariables['--player-radius'] =
typeof theme.borderRadius === 'number' ? `${theme.borderRadius}px` : theme.borderRadius
}
if (theme?.controlsBackground) {
cssVariables['--player-surface'] = theme.controlsBackground
}
if (theme?.textSecondaryColor) {
cssVariables['--player-text-secondary'] = theme.textSecondaryColor
}
if (theme?.textMutedColor) {
cssVariables['--player-text-muted'] = theme.textMutedColor
}
if (Object.keys(cssVariables).length === 0) {
return style || {}
}
return {
...cssVariables,
...(style || {}),
} as React.CSSProperties
}, [style, theme])
const play = useCallback(() => {
const audio = audioRef.current
if (!audio) return
void audio.play().catch(() => undefined)
}, [])
const pause = useCallback(() => {
audioRef.current?.pause()
}, [])
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => undefined)
} else {
audio.pause()
}
}, [])
const seek = useCallback((time: number) => {
const audio = audioRef.current
if (!audio || !Number.isFinite(time)) return
const safeDuration = Number.isFinite(audio.duration) ? audio.duration : 0
const clampedTime = Math.max(0, Math.min(safeDuration, time))
audio.currentTime = clampedTime
setAudioState((prev) => ({ ...prev, currentTime: clampedTime }))
}, [])
const setVolume = useCallback((nextVolume: number) => {
const audio = audioRef.current
if (!audio || !Number.isFinite(nextVolume)) return
const clampedVolume = clamp01(nextVolume)
audio.volume = clampedVolume
if (clampedVolume > 0 && audio.muted) {
audio.muted = false
}
setAudioState((prev) => ({ ...prev, volume: clampedVolume, muted: audio.muted }))
}, [])
const toggleMute = useCallback(() => {
const audio = audioRef.current
if (!audio) return
audio.muted = !audio.muted
setAudioState((prev) => ({ ...prev, muted: audio.muted }))
}, [])
const setPlaybackRateValue = useCallback((nextRate: number) => {
const audio = audioRef.current
if (!audio || !Number.isFinite(nextRate) || nextRate <= 0) return
audio.playbackRate = nextRate
setAudioState((prev) => ({ ...prev, playbackRate: nextRate }))
}, [])
const cyclePlaybackRate = useCallback(() => {
const currentIndex = playbackRates.findIndex((rate) => rate === audioState.playbackRate)
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % playbackRates.length : 0
const nextRate = playbackRates[nextIndex] ?? 1
setPlaybackRateValue(nextRate)
}, [audioState.playbackRate, playbackRates, setPlaybackRateValue])
useImperativeHandle(
ref,
() => ({
audio: audioRef.current,
container: containerRef.current,
play,
pause,
seek,
setVolume,
toggleMute,
setPlaybackRate: setPlaybackRateValue,
}),
[play, pause, seek, setVolume, toggleMute, setPlaybackRateValue]
)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
setAudioState((prev) => ({
...prev,
playing: false,
currentTime: 0,
duration: 0,
buffered: 0,
loading: true,
error: null,
seeking: false,
}))
audio.load()
}, [src])
useEffect(() => {
const audio = audioRef.current
if (!audio || volume === undefined) return
const clampedVolume = clamp01(volume)
if (audio.volume !== clampedVolume) {
audio.volume = clampedVolume
}
}, [volume])
useEffect(() => {
const audio = audioRef.current
if (!audio || playbackRate === undefined || !Number.isFinite(playbackRate) || playbackRate <= 0) {
return
}
if (audio.playbackRate !== playbackRate) {
audio.playbackRate = playbackRate
}
}, [playbackRate])
useEffect(() => {
const audio = audioRef.current
if (!audio || initialCurrentTime === undefined || !Number.isFinite(initialCurrentTime)) return
if (Math.abs(audio.currentTime - initialCurrentTime) > 1) {
audio.currentTime = initialCurrentTime
}
}, [initialCurrentTime])
useEffect(() => {
if (!keyboardShortcuts) {
setIsActivePlayer(false)
return
}
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
const container = containerRef.current
if (!container) return
const target = e.target as Node | null
const isInsidePlayer = !!target && container.contains(target)
setIsActivePlayer(isInsidePlayer)
if (isInsidePlayer && document.activeElement !== container) {
container.focus({ preventScroll: true })
}
}
const handleFocusIn = (e: FocusEvent) => {
const container = containerRef.current
if (!container) return
const target = e.target as Node | null
setIsActivePlayer(!!target && container.contains(target))
}
const handleWindowBlur = () => {
setIsActivePlayer(false)
}
const container = containerRef.current
if (container && container.contains(document.activeElement)) {
setIsActivePlayer(true)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown, { passive: true })
document.addEventListener('focusin', handleFocusIn)
window.addEventListener('blur', handleWindowBlur)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
document.removeEventListener('focusin', handleFocusIn)
window.removeEventListener('blur', handleWindowBlur)
}
}, [keyboardShortcuts])
useEffect(() => {
if (!keyboardShortcuts) return
const handleKeyDown = (e: KeyboardEvent) => {
const container = containerRef.current
if (container && !isActivePlayer) return
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target instanceof HTMLElement && e.target.isContentEditable)
) {
return
}
const key = e.key.toLowerCase()
switch (key) {
case ' ':
case 'k':
e.preventDefault()
togglePlay()
break
case 'arrowleft':
e.preventDefault()
seek(Math.max(0, audioState.currentTime - KEYBOARD_SEEK_STEP_SECONDS))
break
case 'arrowright':
e.preventDefault()
seek(Math.min(audioState.duration, audioState.currentTime + KEYBOARD_SEEK_STEP_SECONDS))
break
case 'arrowup':
e.preventDefault()
setVolume(audioState.volume + KEYBOARD_VOLUME_STEP)
break
case 'arrowdown':
e.preventDefault()
setVolume(audioState.volume - KEYBOARD_VOLUME_STEP)
break
case 'm':
e.preventDefault()
toggleMute()
break
case '0':
case 'home':
e.preventDefault()
seek(0)
break
case 'end':
e.preventDefault()
seek(audioState.duration)
break
default:
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
audioState.currentTime,
audioState.duration,
audioState.volume,
isActivePlayer,
keyboardShortcuts,
seek,
setVolume,
toggleMute,
togglePlay,
])
const handlePlay = useCallback(() => {
setAudioState((prev) => ({ ...prev, playing: true, loading: false }))
onPlay?.()
}, [onPlay])
const handlePause = useCallback(() => {
setAudioState((prev) => ({ ...prev, playing: false }))
onPause?.()
}, [onPause])
const handleTimeUpdate = useCallback(() => {
const audio = audioRef.current
if (!audio) return
const buffered =
audio.buffered.length > 0 ? audio.buffered.end(audio.buffered.length - 1) : audioState.buffered
setAudioState((prev) => ({
...prev,
currentTime: audio.currentTime,
buffered,
}))
onTimeUpdate?.(audio.currentTime)
}, [audioState.buffered, onTimeUpdate])
const handleLoadedMetadata = useCallback(() => {
const audio = audioRef.current
if (!audio) return
setAudioState((prev) => ({
...prev,
duration: Number.isFinite(audio.duration) ? audio.duration : 0,
volume: audio.volume,
muted: audio.muted,
playbackRate: audio.playbackRate,
loading: false,
error: null,
}))
onLoadedMetadata?.()
}, [onLoadedMetadata])
const handleDurationChange = useCallback(() => {
const audio = audioRef.current
if (!audio) return
const nextDuration = Number.isFinite(audio.duration) ? audio.duration : 0
setAudioState((prev) => ({ ...prev, duration: nextDuration }))
onDurationChange?.(nextDuration)
}, [onDurationChange])
const handleVolumeChange = useCallback(() => {
const audio = audioRef.current
if (!audio) return
setAudioState((prev) => ({ ...prev, volume: audio.volume, muted: audio.muted }))
onVolumeChange?.(audio.volume)
}, [onVolumeChange])
const handleSeeking = useCallback(() => {
setAudioState((prev) => ({ ...prev, seeking: true }))
onSeeking?.()
}, [onSeeking])
const handleSeeked = useCallback(() => {
setAudioState((prev) => ({ ...prev, seeking: false }))
onSeeked?.()
}, [onSeeked])
const handleWaiting = useCallback(() => {
setAudioState((prev) => ({ ...prev, loading: true }))
onWaiting?.()
}, [onWaiting])
const handleCanPlay = useCallback(() => {
setAudioState((prev) => ({ ...prev, loading: false }))
onCanPlay?.()
}, [onCanPlay])
const handleProgress = useCallback(() => {
const audio = audioRef.current
if (!audio || audio.buffered.length === 0) return
const buffered = audio.buffered.end(audio.buffered.length - 1)
setAudioState((prev) => ({ ...prev, buffered }))
onProgress?.(buffered)
}, [onProgress])
const handleRateChange = useCallback(() => {
const audio = audioRef.current
if (!audio) return
setAudioState((prev) => ({ ...prev, playbackRate: audio.playbackRate }))
onRateChange?.(audio.playbackRate)
}, [onRateChange])
const handleEnded = useCallback(() => {
setAudioState((prev) => ({ ...prev, playing: false }))
onEnded?.()
}, [onEnded])
const handleError = useCallback(() => {
const audio = audioRef.current
if (!audio || !audio.error) return
const error = new Error(audio.error.message || 'Audio playback error')
setAudioState((prev) => ({ ...prev, error, loading: false, playing: false }))
onError?.(error)
}, [onError])
const handleProgressChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value)
if (!Number.isFinite(value)) return
seek(value)
},
[seek]
)
const handleVolumeSlider = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value)
if (!Number.isFinite(value)) return
setVolume(value)
},
[setVolume]
)
const progressPercent =
audioState.duration > 0 ? (audioState.currentTime / audioState.duration) * 100 : 0
const bufferedPercent =
audioState.duration > 0 ? (Math.min(audioState.buffered, audioState.duration) / audioState.duration) * 100 : 0
const VolumeIcon =
audioState.muted || audioState.volume === 0
? VolumeMuteIcon
: audioState.volume > 0.5
? VolumeUpIcon
: VolumeDownIcon
const speedLabel = audioState.playbackRate === 1 ? translations.normal : `${audioState.playbackRate}x`
const playPauseLabel = audioState.playing ? translations.pause : translations.play
const muteLabel = audioState.muted ? translations.unmute : translations.mute
const progressMax = audioState.duration > 0 ? audioState.duration : 0
return (
<div
ref={containerRef}
className={`sp-audio-player ${className}`}
style={themedStyle}
tabIndex={0}
>
<audio
ref={audioRef}
src={src}
autoPlay={autoplay}
loop={loop}
muted={muted}
crossOrigin={crossOrigin}
preload={preload}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onProgress={handleProgress}
onRateChange={handleRateChange}
onEnded={handleEnded}
onError={handleError}
/>
{(artwork || title || subtitle || children) && (
<div className="sp-audio-header">
{artwork && (
<img
src={artwork}
alt={title ? `${title} artwork` : 'Artwork'}
className="sp-audio-artwork"
loading="lazy"
decoding="async"
/>
)}
<div className="sp-audio-meta">
{title && <span className="sp-audio-title">{title}</span>}
{subtitle && <span className="sp-audio-subtitle">{subtitle}</span>}
</div>
{children && <div className="sp-audio-header-extra">{children}</div>}
</div>
)}
{controls && (
<>
<div className="sp-audio-progress-wrap">
<div className="sp-audio-progress-track">
<div className="sp-audio-progress-buffered" style={{ width: `${bufferedPercent}%` }} />
<div className="sp-audio-progress-played" style={{ width: `${progressPercent}%` }} />
</div>
<input
type="range"
min={0}
max={progressMax}
step={0.01}
value={Math.min(audioState.currentTime, progressMax)}
onChange={handleProgressChange}
className="sp-audio-progress-input"
aria-label={translations.videoProgress}
/>
</div>
<div className="sp-audio-controls">
<div className="sp-audio-controls-left">
<button
className="sp-control-button sp-audio-play-button"
onClick={togglePlay}
aria-label={playPauseLabel}
title={`${playPauseLabel} (Space)`}
>
{audioState.playing ? (
<PauseIcon size={22} color="var(--player-text)" />
) : (
<PlayIcon size={22} color="var(--player-text)" />
)}
</button>
<button
className="sp-control-button sp-audio-volume-button"
onClick={toggleMute}
aria-label={muteLabel}
title={`${muteLabel} (M)`}
>
<VolumeIcon size={20} color="var(--player-text)" />
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={audioState.muted ? 0 : audioState.volume}
onChange={handleVolumeSlider}
className="sp-audio-volume-slider"
aria-label={translations.volume}
/>
{controlsLeftExtra}
</div>
<div className="sp-audio-controls-center">
<span className="sp-audio-time-current">{formatTime(audioState.currentTime)}</span>
<span className="sp-audio-time-separator">/</span>
<span className="sp-audio-time-duration">{formatTime(audioState.duration)}</span>
</div>
<div className="sp-audio-controls-right">
{controlsRightExtra}
<button
className="sp-control-button sp-audio-speed-button"
onClick={cyclePlaybackRate}
aria-label={translations.speed}
title={translations.speed}
>
<SpeedIcon size={20} color="var(--player-text)" />
<span>{speedLabel}</span>
</button>
</div>
</div>
</>
)}
{audioState.loading && <div className="sp-audio-loading-bar" />}
</div>
)
}
)
AudioPlayer.displayName = 'AudioPlayer'
+8
View File
@@ -15,6 +15,14 @@
background-color: #000;
}
.sp-animated-image-element {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background-color: #000;
}
.sp-video-element::-webkit-media-controls,
.sp-video-element::-webkit-media-controls-enclosure,
.sp-video-element::-webkit-media-controls-panel {
+117 -39
View File
@@ -7,6 +7,7 @@ import type {
VideoProtocol,
SubtitleStyle,
SubtitlePosition,
VideoMediaType,
} from '../types'
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
@@ -20,6 +21,7 @@ import './VideoElement.css'
interface VideoElementProps {
src: string
mediaType?: VideoMediaType
poster?: string
protocol?: 'auto' | VideoProtocol
autoplay?: boolean
@@ -142,6 +144,7 @@ const areSubtitleLinesEqual = (a: string[], b: string[]): boolean => {
export const VideoElement: React.FC<VideoElementProps> = ({
src,
mediaType = 'video',
poster,
protocol = 'auto',
autoplay = false,
@@ -193,6 +196,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const [activeSubtitleLines, setActiveSubtitleLines] = useState<string[]>([])
const subtitleBlobUrlsRef = React.useRef<string[]>([])
const subtitleAnimationFrameRef = React.useRef<number | null>(null)
const hasAnimatedImageLoadedRef = React.useRef(false)
const effectiveSubtitleStyle = React.useMemo<SubtitleStyle>(
() => ({
@@ -424,6 +428,36 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError?.(error)
}, [videoRef, setVideoState, onError, src])
const handleAnimatedImageLoad = useCallback(() => {
hasAnimatedImageLoadedRef.current = true
setVideoState((prev) => ({
...prev,
playing: true,
currentTime: 0,
duration: 0,
buffered: 0,
loading: false,
error: null,
seeking: false,
isLiveBroadcast: false,
}))
if (!hasPlayedRef.current) {
hasPlayedRef.current = true
onFirstPlay?.()
}
onLoadedMetadata?.()
onCanPlay?.()
onBufferEnd?.()
}, [setVideoState, onFirstPlay, onLoadedMetadata, onCanPlay, onBufferEnd])
const handleAnimatedImageError = useCallback(() => {
const error = new Error(`Animated image error: Failed to load source ${src}`)
setVideoState((prev) => ({ ...prev, error, loading: false, playing: false }))
onError?.(error)
}, [onError, setVideoState, src])
// Handle double-click on video for fullscreen toggle
const handleVideoClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
@@ -443,6 +477,27 @@ export const VideoElement: React.FC<VideoElementProps> = ({
[toggleFullscreen]
)
useEffect(() => {
if (mediaType !== 'animated-image') return
hasAnimatedImageLoadedRef.current = false
hasPlayedRef.current = false
setVideoState((prev) => ({
...prev,
playing: false,
currentTime: 0,
duration: 0,
buffered: 0,
loading: true,
error: null,
seeking: false,
isLiveBroadcast: false,
}))
setActiveSubtitleLines([])
onBufferStart?.()
}, [mediaType, src, setVideoState, onBufferStart])
// Handle fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
@@ -509,6 +564,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
// Process subtitles - convert SRT to VTT blob URLs and merge with HLS subtitles
useEffect(() => {
if (mediaType === 'animated-image') {
setProcessedSubtitles([])
return
}
let cancelled = false
// Clean up old blob URLs
@@ -566,9 +626,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
subtitleBlobUrlsRef.current = []
}
}, [subtitles, hlsSubtitles])
}, [subtitles, hlsSubtitles, mediaType])
useEffect(() => {
if (mediaType === 'animated-image') return
if (processedSubtitles.length === 0) return
if (settings.subtitle) return
@@ -576,10 +637,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
if (!defaultSubtitle) return
setSubtitle(defaultSubtitle)
}, [processedSubtitles, settings.subtitle, setSubtitle])
}, [processedSubtitles, settings.subtitle, setSubtitle, mediaType])
// Detect video protocol and setup appropriate player
useEffect(() => {
if (mediaType === 'animated-image') return
const video = videoRef.current
if (!video) return
let isCancelled = false
@@ -794,6 +857,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
src,
protocol,
autoplay,
mediaType,
videoRef,
handleError,
setVideoState,
@@ -896,6 +960,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
// Custom subtitle renderer based on active TextTrack cues
useEffect(() => {
if (mediaType === 'animated-image') return
const video = videoRef.current
if (!video) return
@@ -1001,46 +1067,58 @@ export const VideoElement: React.FC<VideoElementProps> = ({
video.removeEventListener('timeupdate', handleSeek)
video.removeEventListener('ended', handlePause)
}
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles, mediaType])
return (
<div className="sp-video-container">
<video
ref={videoRef}
className="sp-video-element"
poster={poster}
loop={loop}
muted={muted}
crossOrigin={crossOrigin}
playsInline={playsInline}
preload={preload}
controlsList={controlsList}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onProgress={handleProgress}
onRateChange={handleRateChange}
onEnded={handleEnded}
onError={handleError}
onClick={handleVideoClick}
>
{processedSubtitles.map((subtitle, index) => (
<track
key={index}
kind="subtitles"
src={subtitle.src}
srcLang={subtitle.lang}
label={subtitle.label}
/>
))}
</video>
{mediaType === 'animated-image' ? (
<img
src={src}
className="sp-animated-image-element"
alt=""
loading="eager"
decoding="async"
onLoad={handleAnimatedImageLoad}
onError={handleAnimatedImageError}
/>
) : (
<video
ref={videoRef}
className="sp-video-element"
poster={poster}
loop={loop}
muted={muted}
crossOrigin={crossOrigin}
playsInline={playsInline}
preload={preload}
controlsList={controlsList}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onProgress={handleProgress}
onRateChange={handleRateChange}
onEnded={handleEnded}
onError={handleError}
onClick={handleVideoClick}
>
{processedSubtitles.map((subtitle, index) => (
<track
key={index}
kind="subtitles"
src={subtitle.src}
srcLang={subtitle.lang}
label={subtitle.label}
/>
))}
</video>
)}
{settings.subtitle && activeSubtitleLines.length > 0 && (
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
<div className="sp-subtitle-stack">
+14
View File
@@ -125,6 +125,20 @@ describe('VideoPlayer', () => {
// Error handling is tested separately in integration tests
})
it('renders animated images with img element in auto mode', () => {
const { container } = render(<VideoPlayer src="https://example.com/loop.gif" />)
const image = container.querySelector('.sp-animated-image-element')
const video = container.querySelector('video')
expect(image).toBeInTheDocument()
expect(video).not.toBeInTheDocument()
})
it('hides controls for animated image media', () => {
const { container } = render(<VideoPlayer src="https://example.com/loop.webp" controls />)
expect(container.querySelector('.sp-controls-layer')).not.toBeInTheDocument()
})
it('hides controls when controls prop is false', () => {
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
const controls = container.querySelector('.controls')
+15 -2
View File
@@ -9,8 +9,10 @@ import type {
AudioTrack,
VideoQuality,
SubtitleTrack,
VideoMediaType,
} from '../types'
import { initializePolyfills } from '../utils/polyfills'
import { detectPlayerMediaType } from '../utils/mediaSource'
import '../styles/variables.css'
import './VideoPlayer.css'
@@ -65,6 +67,7 @@ const resolveSubtitleStyleEditorConfig = (
}
interface VideoPlayerContentProps extends VideoPlayerProps {
mediaType: VideoMediaType
audioTracks: AudioTrack[]
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[]
@@ -77,6 +80,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
(
{
src,
mediaType,
poster,
protocol = 'auto',
autoplay = false,
@@ -180,6 +184,8 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
)
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
const isAnimatedImage = mediaType === 'animated-image'
const effectiveControls = controls && !isAnimatedImage
const themedStyle = useMemo<React.CSSProperties>(() => {
const cssVariables: Record<string, string> = {}
@@ -238,12 +244,13 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
return (
<div
ref={containerRef}
className={`sp-video-player ${controlsHiddenClass} ${className}`}
className={`sp-video-player ${isAnimatedImage ? 'sp-video-player-image' : ''} ${controlsHiddenClass} ${className}`}
style={themedStyle}
tabIndex={0}
>
<VideoElement
src={src}
mediaType={mediaType}
poster={poster}
protocol={protocol}
autoplay={autoplay}
@@ -284,7 +291,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
/>
{controls && (
{effectiveControls && (
<ControlsLayer
keyboardShortcuts={keyboardShortcuts}
keyboardShortcutConfig={keyboardShortcutConfig}
@@ -323,6 +330,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
(
{
src,
mediaType = 'auto',
poster,
protocol = 'auto',
autoplay = false,
@@ -379,6 +387,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
},
ref
) => {
const resolvedMediaType = useMemo<VideoMediaType>(() => {
const detectedMediaType = detectPlayerMediaType(src, mediaType)
return detectedMediaType === 'animated-image' ? 'animated-image' : 'video'
}, [src, mediaType])
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
const [qualities, setQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
@@ -419,6 +431,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
<VideoPlayerContent
ref={ref}
src={src}
mediaType={resolvedMediaType}
poster={poster}
protocol={protocol}
autoplay={autoplay}
+6
View File
@@ -1,5 +1,6 @@
// Main component
export { VideoPlayer } from './components/VideoPlayer'
export { AudioPlayer } from './components/AudioPlayer'
export { PlayerErrorBoundary } from './components/ErrorBoundary'
export type {
PlayerErrorBoundaryProps,
@@ -13,12 +14,16 @@ export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
export type {
VideoPlayerProps,
VideoPlayerHandle,
AudioPlayerProps,
AudioPlayerHandle,
SubtitleTrack,
SubtitleStyle,
SubtitleStyleEditorConfig,
SubtitlePosition,
AudioTrack,
VideoQuality,
VideoMediaType,
VideoMediaTypeInput,
PlayerTheme,
KeyboardShortcutConfig,
TouchConfig,
@@ -38,6 +43,7 @@ export {
checkVideoCORS,
} from './utils/corsHelper'
export { initializePolyfills, features } from './utils/polyfills'
export { detectPlayerMediaType, isAnimatedImageSource, isAudioSource } from './utils/mediaSource'
// i18n
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
+65
View File
@@ -2,6 +2,8 @@ import type { CSSProperties, MutableRefObject, ReactNode } from 'react'
import type { Translations } from '../i18n'
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
export type VideoMediaType = 'video' | 'animated-image'
export type VideoMediaTypeInput = VideoMediaType | 'auto'
export interface SubtitleTrack {
src: string
@@ -97,6 +99,7 @@ export interface VideoPlayerHandle {
export interface VideoPlayerProps {
src: string
mediaType?: VideoMediaTypeInput
poster?: string
protocol?: 'auto' | VideoProtocol
autoplay?: boolean
@@ -169,6 +172,68 @@ export interface VideoPlayerProps {
onFirstPlay?: () => void
}
export interface AudioPlayerHandle {
/** Audio HTML elementi */
audio: HTMLAudioElement | null
/** Player container elementi */
container: HTMLDivElement | null
play: () => void
pause: () => void
seek: (time: number) => void
setVolume: (volume: number) => void
toggleMute: () => void
setPlaybackRate: (rate: number) => void
}
export interface AudioPlayerProps {
src: string
artwork?: string
title?: string
subtitle?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
volume?: number
playbackRate?: number
currentTime?: number
crossOrigin?: '' | 'anonymous' | 'use-credentials'
preload?: 'none' | 'metadata' | 'auto'
controls?: boolean
keyboardShortcuts?: boolean
theme?: PlayerTheme
language?: string
className?: string
style?: CSSProperties
/** Oynatma hızı seçenekleri (varsayılan: [0.5, 0.75, 1, 1.25, 1.5, 2]) */
playbackRates?: number[]
/** Özel çeviri metinleri */
translations?: Partial<Translations>
// Slot prop'ları
/** Player üzerine yerleştirilecek overlay içeriği */
children?: ReactNode
/** Kontrol çubuğu sol tarafına eklenecek butonlar */
controlsLeftExtra?: ReactNode
/** Kontrol çubuğu sağ tarafına eklenecek butonlar */
controlsRightExtra?: ReactNode
// Event callbacks
onPlay?: () => void
onPause?: () => void
onEnded?: () => void
onTimeUpdate?: (currentTime: number) => void
onVolumeChange?: (volume: number) => void
onError?: (error: Error) => void
onLoadedMetadata?: () => void
onSeeking?: () => void
onSeeked?: () => void
onProgress?: (buffered: number) => void
onDurationChange?: (duration: number) => void
onRateChange?: (playbackRate: number) => void
onWaiting?: () => void
onCanPlay?: () => void
}
export interface VideoState {
playing: boolean
currentTime: number
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { detectPlayerMediaType, isAnimatedImageSource, isAudioSource } from './mediaSource'
describe('mediaSource', () => {
describe('isAnimatedImageSource', () => {
it('detects GIF by extension', () => {
expect(isAnimatedImageSource('https://example.com/loop.gif')).toBe(true)
})
it('detects animated WEBP with query string', () => {
expect(isAnimatedImageSource('/assets/anim.webp?size=large')).toBe(true)
})
it('detects data URI animated image', () => {
expect(isAnimatedImageSource('data:image/gif;base64,R0lGODlhAQABAIAAAAUEBA==')).toBe(true)
})
it('returns false for non-animated image formats', () => {
expect(isAnimatedImageSource('https://example.com/poster.jpg')).toBe(false)
})
})
describe('isAudioSource', () => {
it('detects MP3 by extension', () => {
expect(isAudioSource('https://example.com/song.mp3')).toBe(true)
})
it('detects WAV with hash', () => {
expect(isAudioSource('/audio/test.wav#preview')).toBe(true)
})
it('detects audio data URI', () => {
expect(isAudioSource('data:audio/wav;base64,UklGRpQAAABXQVZFZm10')).toBe(true)
})
it('returns false for mp4', () => {
expect(isAudioSource('https://example.com/video.mp4')).toBe(false)
})
})
describe('detectPlayerMediaType', () => {
it('returns requested type when explicitly set', () => {
expect(detectPlayerMediaType('https://example.com/video.mp4', 'animated-image')).toBe(
'animated-image'
)
})
it('detects animated-image automatically', () => {
expect(detectPlayerMediaType('https://example.com/a.gif')).toBe('animated-image')
})
it('detects audio automatically', () => {
expect(detectPlayerMediaType('https://example.com/track.flac')).toBe('audio')
})
it('defaults to video', () => {
expect(detectPlayerMediaType('https://example.com/video.mp4')).toBe('video')
})
})
})
+86
View File
@@ -0,0 +1,86 @@
export type PlayerMediaType = 'video' | 'audio' | 'animated-image'
export type PlayerMediaTypeInput = PlayerMediaType | 'auto'
const ANIMATED_IMAGE_EXTENSIONS = new Set(['gif', 'apng', 'webp', 'avif'])
const AUDIO_EXTENSIONS = new Set([
'mp3',
'wav',
'ogg',
'oga',
'm4a',
'aac',
'flac',
'opus',
'weba',
])
const getNormalizedPath = (src: string): string => {
if (!src) return ''
try {
if (typeof window !== 'undefined') {
return new URL(src, window.location.href).pathname.toLowerCase()
}
return new URL(src).pathname.toLowerCase()
} catch {
const normalized = src.split('?')[0]?.split('#')[0] ?? src
return normalized.toLowerCase()
}
}
const getExtension = (src: string): string => {
const path = getNormalizedPath(src)
const lastDot = path.lastIndexOf('.')
if (lastDot < 0 || lastDot === path.length - 1) return ''
return path.slice(lastDot + 1)
}
const startsWithDataMime = (src: string, mimePrefix: string): boolean =>
src.toLowerCase().startsWith(`data:${mimePrefix}`)
export const isAnimatedImageSource = (src: string): boolean => {
if (!src) return false
if (
startsWithDataMime(src, 'image/gif') ||
startsWithDataMime(src, 'image/apng') ||
startsWithDataMime(src, 'image/webp') ||
startsWithDataMime(src, 'image/avif')
) {
return true
}
const extension = getExtension(src)
return ANIMATED_IMAGE_EXTENSIONS.has(extension)
}
export const isAudioSource = (src: string): boolean => {
if (!src) return false
if (startsWithDataMime(src, 'audio/')) {
return true
}
const extension = getExtension(src)
return AUDIO_EXTENSIONS.has(extension)
}
export const detectPlayerMediaType = (
src: string,
requestedType: PlayerMediaTypeInput = 'auto'
): PlayerMediaType => {
if (requestedType !== 'auto') {
return requestedType
}
if (isAnimatedImageSource(src)) {
return 'animated-image'
}
if (isAudioSource(src)) {
return 'audio'
}
return 'video'
}