feat: add animated image and audio player support
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,10 +1067,21 @@ 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">
|
||||
{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"
|
||||
@@ -1041,6 +1118,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
)}
|
||||
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
||||
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||
<div className="sp-subtitle-stack">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user