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]
|
## [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
|
## [3.1.2] - 2026-02-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+125
-6
@@ -1,6 +1,6 @@
|
|||||||
# @source/player Documentation
|
# @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
|
## Table of contents
|
||||||
|
|
||||||
@@ -21,9 +21,11 @@ This document reflects the current codebase in this repository (`version 3.1.0`)
|
|||||||
|
|
||||||
## 1. Overview
|
## 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`)
|
- 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)
|
- Built-in controls with settings menus (speed, quality, subtitles, subtitle styling, audio tracks)
|
||||||
- Modular extension points for custom controls and overlays
|
- Modular extension points for custom controls and overlays
|
||||||
- Strong TypeScript API (props, handle types, context types, utility exports)
|
- 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
|
### 2.1 High-level component graph
|
||||||
|
|
||||||
- `VideoPlayer`
|
- `VideoPlayer`
|
||||||
|
- `AudioPlayer`
|
||||||
- `PlayerErrorBoundary`
|
- `PlayerErrorBoundary`
|
||||||
- `PlayerProvider` (context + state + actions + i18n)
|
- `PlayerProvider` (context + state + actions + i18n)
|
||||||
- `VideoElement` (media element, protocol setup, media events, subtitle rendering)
|
- `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)
|
- `ControlsLayer` (controls visibility, settings menu, keyboard/touch integration)
|
||||||
- `SettingsMenu` (lazy-loaded, menu subviews)
|
- `SettingsMenu` (lazy-loaded, menu subviews)
|
||||||
|
|
||||||
@@ -50,6 +54,11 @@ The player is split into focused modules.
|
|||||||
- Wraps content with `PlayerErrorBoundary` and `PlayerProvider`
|
- Wraps content with `PlayerErrorBoundary` and `PlayerProvider`
|
||||||
- Exposes imperative API via ref (`VideoPlayerHandle`)
|
- 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`
|
- `PlayerProvider`
|
||||||
- Owns central `videoState`, `uiState`, and `settings`
|
- Owns central `videoState`, `uiState`, and `settings`
|
||||||
- Manages subtitle style draft/commit/persist lifecycle
|
- Manages subtitle style draft/commit/persist lifecycle
|
||||||
@@ -150,6 +159,29 @@ export function App() {
|
|||||||
</VideoPlayer>
|
</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. Streaming and protocol handling
|
||||||
|
|
||||||
### 5.1 Auto protocol detection
|
### 5.1 Auto protocol detection
|
||||||
@@ -215,6 +247,15 @@ Important note:
|
|||||||
- Progress bar and time display are hidden
|
- Progress bar and time display are hidden
|
||||||
- Live badge is shown in controls
|
- 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. Subtitles and subtitle style editor
|
||||||
|
|
||||||
### 6.1 Subtitle sources
|
### 6.1 Subtitle sources
|
||||||
@@ -397,6 +438,7 @@ You can override any key with `translations?: Partial<Translations>`.
|
|||||||
| Prop | Type | Default | Notes |
|
| Prop | Type | Default | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `src` | `string` | required | media URL |
|
| `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 |
|
| `protocol` | `'auto' \| 'native' \| 'hls' \| 'rtmp' \| 'dash' \| 'mpegts'` | `'auto'` | force engine |
|
||||||
| `poster` | `string` | - | poster image |
|
| `poster` | `string` | - | poster image |
|
||||||
| `autoplay` | `boolean` | `false` | autoplay attempt on load |
|
| `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
|
```ts
|
||||||
interface PlayerErrorBoundaryProps {
|
interface PlayerErrorBoundaryProps {
|
||||||
@@ -510,7 +621,7 @@ interface PlayerErrorBoundaryProps {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.4 Core types
|
### 10.6 Core types
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type SubtitleTrack = {
|
type SubtitleTrack = {
|
||||||
@@ -572,6 +683,7 @@ From `src/index.ts`:
|
|||||||
|
|
||||||
- Components
|
- Components
|
||||||
- `VideoPlayer`
|
- `VideoPlayer`
|
||||||
|
- `AudioPlayer`
|
||||||
- `PlayerErrorBoundary`
|
- `PlayerErrorBoundary`
|
||||||
|
|
||||||
- Context
|
- Context
|
||||||
@@ -592,10 +704,13 @@ From `src/index.ts`:
|
|||||||
- `parseSRT`, `createSubtitleBlobURL`, `fetchSubtitle`
|
- `parseSRT`, `createSubtitleBlobURL`, `fetchSubtitle`
|
||||||
- `validateVideoURL`, `getCORSErrorMessage`, `isCORSError`, `checkVideoCORS`
|
- `validateVideoURL`, `getCORSErrorMessage`, `isCORSError`, `checkVideoCORS`
|
||||||
- `initializePolyfills`, `features`
|
- `initializePolyfills`, `features`
|
||||||
|
- `detectPlayerMediaType`, `isAnimatedImageSource`, `isAudioSource`
|
||||||
|
|
||||||
- Types
|
- Types
|
||||||
- `VideoPlayerProps`, `VideoPlayerHandle`, `SubtitleTrack`, `SubtitleStyle`, `SubtitleStyleEditorConfig`
|
- `VideoPlayerProps`, `VideoPlayerHandle`, `AudioPlayerProps`, `AudioPlayerHandle`
|
||||||
|
- `SubtitleTrack`, `SubtitleStyle`, `SubtitleStyleEditorConfig`
|
||||||
- `SubtitlePosition`, `AudioTrack`, `VideoQuality`, `PlayerTheme`
|
- `SubtitlePosition`, `AudioTrack`, `VideoQuality`, `PlayerTheme`
|
||||||
|
- `VideoMediaType`, `VideoMediaTypeInput`
|
||||||
- `KeyboardShortcutConfig`, `TouchConfig`
|
- `KeyboardShortcutConfig`, `TouchConfig`
|
||||||
- `VideoState`, `UIState`, `PlayerSettings`, `PlayerContextValue`
|
- `VideoState`, `UIState`, `PlayerSettings`, `PlayerContextValue`
|
||||||
- `Translations`
|
- `Translations`
|
||||||
@@ -654,6 +769,8 @@ For HLS/FLV/MPEG-TS setups:
|
|||||||
- Direct RTMP URLs usually need HTTP-FLV proxying.
|
- Direct RTMP URLs usually need HTTP-FLV proxying.
|
||||||
- DASH detection exists but playback is not implemented yet.
|
- DASH detection exists but playback is not implemented yet.
|
||||||
- MPEG-TS behavior depends on MSE support and stream/server conditions.
|
- 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:
|
Polyfill and feature utilities:
|
||||||
|
|
||||||
@@ -688,12 +805,14 @@ npm run validate:publish
|
|||||||
### 14.2 Test coverage areas in repository
|
### 14.2 Test coverage areas in repository
|
||||||
|
|
||||||
- Core rendering and props (`VideoPlayer.test.tsx`)
|
- Core rendering and props (`VideoPlayer.test.tsx`)
|
||||||
|
- Audio rendering and controls (`AudioPlayer.test.tsx`)
|
||||||
- Settings interactions (`SettingsMenu.test.tsx`)
|
- Settings interactions (`SettingsMenu.test.tsx`)
|
||||||
- Error boundary reset and fallback behavior
|
- Error boundary reset and fallback behavior
|
||||||
- Keyboard and touch hook behavior
|
- Keyboard and touch hook behavior
|
||||||
- Protocol detection and streaming setup utilities
|
- Protocol detection and streaming setup utilities
|
||||||
|
- Media type detection (`mediaSource.test.ts`)
|
||||||
- CORS helper validation
|
- 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
|
||||||
|
|
||||||
`@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
|
## 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`)
|
- 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
|
- 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
|
- Built-in settings UI for speed, quality, audio track, subtitles, and subtitle style editor
|
||||||
- Slot-style customization (`children`, `controlsLeftExtra`, `controlsRightExtra`)
|
- 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
|
## API at a glance
|
||||||
|
|
||||||
Key `VideoPlayer` props:
|
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`
|
- Media element config: `crossOrigin`, `preload`, `playsInline`, `controlsList`
|
||||||
- UI toggles: `controls`, `keyboardShortcuts`, `pictureInPicture`
|
- UI toggles: `controls`, `keyboardShortcuts`, `pictureInPicture`
|
||||||
- Customization: `theme`, `className`, `style`, `aspectRatio`, `playbackRates`
|
- Customization: `theme`, `className`, `style`, `aspectRatio`, `playbackRates`
|
||||||
@@ -127,6 +149,13 @@ Key `VideoPlayer` props:
|
|||||||
- Localization: `language`, `translations`
|
- Localization: `language`, `translations`
|
||||||
- Events: `onPlay`, `onPause`, `onEnded`, `onTimeUpdate`, `onError`, `onQualityChange`, `onBufferStart`, `onBufferEnd`, `onFirstPlay`, and more
|
- 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
|
## Imperative control via ref
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -151,6 +180,7 @@ export function PlayerWithRef() {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import {
|
import {
|
||||||
|
AudioPlayer,
|
||||||
PlayerErrorBoundary,
|
PlayerErrorBoundary,
|
||||||
PlayerProvider,
|
PlayerProvider,
|
||||||
usePlayerContext,
|
usePlayerContext,
|
||||||
@@ -165,6 +195,9 @@ import {
|
|||||||
checkVideoCORS,
|
checkVideoCORS,
|
||||||
initializePolyfills,
|
initializePolyfills,
|
||||||
features,
|
features,
|
||||||
|
detectPlayerMediaType,
|
||||||
|
isAnimatedImageSource,
|
||||||
|
isAudioSource,
|
||||||
getTranslations,
|
getTranslations,
|
||||||
detectBrowserLanguage,
|
detectBrowserLanguage,
|
||||||
} from '@source/player'
|
} from '@source/player'
|
||||||
@@ -175,7 +208,9 @@ import {
|
|||||||
For complete details, see `DOCUMENTATION.md`:
|
For complete details, see `DOCUMENTATION.md`:
|
||||||
|
|
||||||
- Full `VideoPlayerProps` and event reference
|
- Full `VideoPlayerProps` and event reference
|
||||||
|
- Full `AudioPlayerProps` and event reference
|
||||||
- Streaming architecture (HLS/RTMP-FLV/MPEG-TS)
|
- Streaming architecture (HLS/RTMP-FLV/MPEG-TS)
|
||||||
|
- Animated image detection and render behavior
|
||||||
- Subtitle rendering and style editor internals
|
- Subtitle rendering and style editor internals
|
||||||
- Theme tokens and CSS variable mapping
|
- Theme tokens and CSS variable mapping
|
||||||
- Keyboard and touch behavior
|
- Keyboard and touch behavior
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "3.1.2",
|
"version": "3.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "3.1.2",
|
"version": "3.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.38.0",
|
"@eslint/js": "^9.38.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "3.1.2",
|
"version": "3.2.0",
|
||||||
"description": "Modern, feature-rich video player library for React",
|
"description": "Modern, feature-rich video player library for React",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/video-player.umd.cjs",
|
"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;
|
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,
|
||||||
.sp-video-element::-webkit-media-controls-enclosure,
|
.sp-video-element::-webkit-media-controls-enclosure,
|
||||||
.sp-video-element::-webkit-media-controls-panel {
|
.sp-video-element::-webkit-media-controls-panel {
|
||||||
|
|||||||
+117
-39
@@ -7,6 +7,7 @@ import type {
|
|||||||
VideoProtocol,
|
VideoProtocol,
|
||||||
SubtitleStyle,
|
SubtitleStyle,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
VideoMediaType,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
||||||
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
||||||
@@ -20,6 +21,7 @@ import './VideoElement.css'
|
|||||||
|
|
||||||
interface VideoElementProps {
|
interface VideoElementProps {
|
||||||
src: string
|
src: string
|
||||||
|
mediaType?: VideoMediaType
|
||||||
poster?: string
|
poster?: string
|
||||||
protocol?: 'auto' | VideoProtocol
|
protocol?: 'auto' | VideoProtocol
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
@@ -142,6 +144,7 @@ const areSubtitleLinesEqual = (a: string[], b: string[]): boolean => {
|
|||||||
|
|
||||||
export const VideoElement: React.FC<VideoElementProps> = ({
|
export const VideoElement: React.FC<VideoElementProps> = ({
|
||||||
src,
|
src,
|
||||||
|
mediaType = 'video',
|
||||||
poster,
|
poster,
|
||||||
protocol = 'auto',
|
protocol = 'auto',
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
@@ -193,6 +196,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const [activeSubtitleLines, setActiveSubtitleLines] = useState<string[]>([])
|
const [activeSubtitleLines, setActiveSubtitleLines] = useState<string[]>([])
|
||||||
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
||||||
const subtitleAnimationFrameRef = React.useRef<number | null>(null)
|
const subtitleAnimationFrameRef = React.useRef<number | null>(null)
|
||||||
|
const hasAnimatedImageLoadedRef = React.useRef(false)
|
||||||
|
|
||||||
const effectiveSubtitleStyle = React.useMemo<SubtitleStyle>(
|
const effectiveSubtitleStyle = React.useMemo<SubtitleStyle>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -424,6 +428,36 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onError?.(error)
|
onError?.(error)
|
||||||
}, [videoRef, setVideoState, onError, src])
|
}, [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
|
// Handle double-click on video for fullscreen toggle
|
||||||
const handleVideoClick = useCallback(
|
const handleVideoClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||||
@@ -443,6 +477,27 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
[toggleFullscreen]
|
[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
|
// Handle fullscreen changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
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
|
// Process subtitles - convert SRT to VTT blob URLs and merge with HLS subtitles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mediaType === 'animated-image') {
|
||||||
|
setProcessedSubtitles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
// Clean up old blob URLs
|
// Clean up old blob URLs
|
||||||
@@ -566,9 +626,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
||||||
subtitleBlobUrlsRef.current = []
|
subtitleBlobUrlsRef.current = []
|
||||||
}
|
}
|
||||||
}, [subtitles, hlsSubtitles])
|
}, [subtitles, hlsSubtitles, mediaType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mediaType === 'animated-image') return
|
||||||
if (processedSubtitles.length === 0) return
|
if (processedSubtitles.length === 0) return
|
||||||
if (settings.subtitle) return
|
if (settings.subtitle) return
|
||||||
|
|
||||||
@@ -576,10 +637,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
if (!defaultSubtitle) return
|
if (!defaultSubtitle) return
|
||||||
|
|
||||||
setSubtitle(defaultSubtitle)
|
setSubtitle(defaultSubtitle)
|
||||||
}, [processedSubtitles, settings.subtitle, setSubtitle])
|
}, [processedSubtitles, settings.subtitle, setSubtitle, mediaType])
|
||||||
|
|
||||||
// Detect video protocol and setup appropriate player
|
// Detect video protocol and setup appropriate player
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mediaType === 'animated-image') return
|
||||||
|
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
@@ -794,6 +857,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
src,
|
src,
|
||||||
protocol,
|
protocol,
|
||||||
autoplay,
|
autoplay,
|
||||||
|
mediaType,
|
||||||
videoRef,
|
videoRef,
|
||||||
handleError,
|
handleError,
|
||||||
setVideoState,
|
setVideoState,
|
||||||
@@ -896,6 +960,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
|
|
||||||
// Custom subtitle renderer based on active TextTrack cues
|
// Custom subtitle renderer based on active TextTrack cues
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mediaType === 'animated-image') return
|
||||||
|
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
@@ -1001,46 +1067,58 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
video.removeEventListener('timeupdate', handleSeek)
|
video.removeEventListener('timeupdate', handleSeek)
|
||||||
video.removeEventListener('ended', handlePause)
|
video.removeEventListener('ended', handlePause)
|
||||||
}
|
}
|
||||||
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
|
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles, mediaType])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sp-video-container">
|
<div className="sp-video-container">
|
||||||
<video
|
{mediaType === 'animated-image' ? (
|
||||||
ref={videoRef}
|
<img
|
||||||
className="sp-video-element"
|
src={src}
|
||||||
poster={poster}
|
className="sp-animated-image-element"
|
||||||
loop={loop}
|
alt=""
|
||||||
muted={muted}
|
loading="eager"
|
||||||
crossOrigin={crossOrigin}
|
decoding="async"
|
||||||
playsInline={playsInline}
|
onLoad={handleAnimatedImageLoad}
|
||||||
preload={preload}
|
onError={handleAnimatedImageError}
|
||||||
controlsList={controlsList}
|
/>
|
||||||
onPlay={handlePlay}
|
) : (
|
||||||
onPause={handlePause}
|
<video
|
||||||
onTimeUpdate={handleTimeUpdate}
|
ref={videoRef}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
className="sp-video-element"
|
||||||
onDurationChange={handleDurationChange}
|
poster={poster}
|
||||||
onVolumeChange={handleVolumeChange}
|
loop={loop}
|
||||||
onSeeking={handleSeeking}
|
muted={muted}
|
||||||
onSeeked={handleSeeked}
|
crossOrigin={crossOrigin}
|
||||||
onWaiting={handleWaiting}
|
playsInline={playsInline}
|
||||||
onCanPlay={handleCanPlay}
|
preload={preload}
|
||||||
onProgress={handleProgress}
|
controlsList={controlsList}
|
||||||
onRateChange={handleRateChange}
|
onPlay={handlePlay}
|
||||||
onEnded={handleEnded}
|
onPause={handlePause}
|
||||||
onError={handleError}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onClick={handleVideoClick}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
>
|
onDurationChange={handleDurationChange}
|
||||||
{processedSubtitles.map((subtitle, index) => (
|
onVolumeChange={handleVolumeChange}
|
||||||
<track
|
onSeeking={handleSeeking}
|
||||||
key={index}
|
onSeeked={handleSeeked}
|
||||||
kind="subtitles"
|
onWaiting={handleWaiting}
|
||||||
src={subtitle.src}
|
onCanPlay={handleCanPlay}
|
||||||
srcLang={subtitle.lang}
|
onProgress={handleProgress}
|
||||||
label={subtitle.label}
|
onRateChange={handleRateChange}
|
||||||
/>
|
onEnded={handleEnded}
|
||||||
))}
|
onError={handleError}
|
||||||
</video>
|
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 && (
|
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
||||||
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||||
<div className="sp-subtitle-stack">
|
<div className="sp-subtitle-stack">
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ describe('VideoPlayer', () => {
|
|||||||
// Error handling is tested separately in integration tests
|
// 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', () => {
|
it('hides controls when controls prop is false', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
|
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
|
||||||
const controls = container.querySelector('.controls')
|
const controls = container.querySelector('.controls')
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import type {
|
|||||||
AudioTrack,
|
AudioTrack,
|
||||||
VideoQuality,
|
VideoQuality,
|
||||||
SubtitleTrack,
|
SubtitleTrack,
|
||||||
|
VideoMediaType,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { initializePolyfills } from '../utils/polyfills'
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
|
import { detectPlayerMediaType } from '../utils/mediaSource'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
import './VideoPlayer.css'
|
import './VideoPlayer.css'
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ const resolveSubtitleStyleEditorConfig = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface VideoPlayerContentProps extends VideoPlayerProps {
|
interface VideoPlayerContentProps extends VideoPlayerProps {
|
||||||
|
mediaType: VideoMediaType
|
||||||
audioTracks: AudioTrack[]
|
audioTracks: AudioTrack[]
|
||||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||||
qualities: VideoQuality[]
|
qualities: VideoQuality[]
|
||||||
@@ -77,6 +80,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
src,
|
src,
|
||||||
|
mediaType,
|
||||||
poster,
|
poster,
|
||||||
protocol = 'auto',
|
protocol = 'auto',
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
@@ -180,6 +184,8 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
)
|
)
|
||||||
|
|
||||||
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
|
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
|
||||||
|
const isAnimatedImage = mediaType === 'animated-image'
|
||||||
|
const effectiveControls = controls && !isAnimatedImage
|
||||||
const themedStyle = useMemo<React.CSSProperties>(() => {
|
const themedStyle = useMemo<React.CSSProperties>(() => {
|
||||||
const cssVariables: Record<string, string> = {}
|
const cssVariables: Record<string, string> = {}
|
||||||
|
|
||||||
@@ -238,12 +244,13 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`sp-video-player ${controlsHiddenClass} ${className}`}
|
className={`sp-video-player ${isAnimatedImage ? 'sp-video-player-image' : ''} ${controlsHiddenClass} ${className}`}
|
||||||
style={themedStyle}
|
style={themedStyle}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<VideoElement
|
<VideoElement
|
||||||
src={src}
|
src={src}
|
||||||
|
mediaType={mediaType}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
protocol={protocol}
|
protocol={protocol}
|
||||||
autoplay={autoplay}
|
autoplay={autoplay}
|
||||||
@@ -284,7 +291,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
||||||
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
||||||
/>
|
/>
|
||||||
{controls && (
|
{effectiveControls && (
|
||||||
<ControlsLayer
|
<ControlsLayer
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
keyboardShortcutConfig={keyboardShortcutConfig}
|
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||||
@@ -323,6 +330,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
src,
|
src,
|
||||||
|
mediaType = 'auto',
|
||||||
poster,
|
poster,
|
||||||
protocol = 'auto',
|
protocol = 'auto',
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
@@ -379,6 +387,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
},
|
},
|
||||||
ref
|
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 [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
@@ -419,6 +431,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
<VideoPlayerContent
|
<VideoPlayerContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
|
mediaType={resolvedMediaType}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
protocol={protocol}
|
protocol={protocol}
|
||||||
autoplay={autoplay}
|
autoplay={autoplay}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Main component
|
// Main component
|
||||||
export { VideoPlayer } from './components/VideoPlayer'
|
export { VideoPlayer } from './components/VideoPlayer'
|
||||||
|
export { AudioPlayer } from './components/AudioPlayer'
|
||||||
export { PlayerErrorBoundary } from './components/ErrorBoundary'
|
export { PlayerErrorBoundary } from './components/ErrorBoundary'
|
||||||
export type {
|
export type {
|
||||||
PlayerErrorBoundaryProps,
|
PlayerErrorBoundaryProps,
|
||||||
@@ -13,12 +14,16 @@ export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
|||||||
export type {
|
export type {
|
||||||
VideoPlayerProps,
|
VideoPlayerProps,
|
||||||
VideoPlayerHandle,
|
VideoPlayerHandle,
|
||||||
|
AudioPlayerProps,
|
||||||
|
AudioPlayerHandle,
|
||||||
SubtitleTrack,
|
SubtitleTrack,
|
||||||
SubtitleStyle,
|
SubtitleStyle,
|
||||||
SubtitleStyleEditorConfig,
|
SubtitleStyleEditorConfig,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
VideoQuality,
|
VideoQuality,
|
||||||
|
VideoMediaType,
|
||||||
|
VideoMediaTypeInput,
|
||||||
PlayerTheme,
|
PlayerTheme,
|
||||||
KeyboardShortcutConfig,
|
KeyboardShortcutConfig,
|
||||||
TouchConfig,
|
TouchConfig,
|
||||||
@@ -38,6 +43,7 @@ export {
|
|||||||
checkVideoCORS,
|
checkVideoCORS,
|
||||||
} from './utils/corsHelper'
|
} from './utils/corsHelper'
|
||||||
export { initializePolyfills, features } from './utils/polyfills'
|
export { initializePolyfills, features } from './utils/polyfills'
|
||||||
|
export { detectPlayerMediaType, isAnimatedImageSource, isAudioSource } from './utils/mediaSource'
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
|
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { CSSProperties, MutableRefObject, ReactNode } from 'react'
|
|||||||
import type { Translations } from '../i18n'
|
import type { Translations } from '../i18n'
|
||||||
|
|
||||||
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
|
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
|
||||||
|
export type VideoMediaType = 'video' | 'animated-image'
|
||||||
|
export type VideoMediaTypeInput = VideoMediaType | 'auto'
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
export interface SubtitleTrack {
|
||||||
src: string
|
src: string
|
||||||
@@ -97,6 +99,7 @@ export interface VideoPlayerHandle {
|
|||||||
|
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
src: string
|
src: string
|
||||||
|
mediaType?: VideoMediaTypeInput
|
||||||
poster?: string
|
poster?: string
|
||||||
protocol?: 'auto' | VideoProtocol
|
protocol?: 'auto' | VideoProtocol
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
@@ -169,6 +172,68 @@ export interface VideoPlayerProps {
|
|||||||
onFirstPlay?: () => void
|
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 {
|
export interface VideoState {
|
||||||
playing: boolean
|
playing: boolean
|
||||||
currentTime: number
|
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