diff --git a/CHANGELOG.md b/CHANGELOG.md index 293c86f..cc66953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f5439e0..abc07b7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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() { ``` +### 4.4 Animated image support + +```tsx + +``` + +`VideoPlayer` auto-detects animated image sources and renders them with a lightweight `` path. +In this mode, control layer is hidden automatically for minimal runtime cost. + +### 4.5 Dedicated audio player + +```tsx +import { AudioPlayer } from '@source/player' + + +``` + ## 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`. | 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` | - | + +#### 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. diff --git a/README.md b/README.md index 947260e..4087e19 100644 --- a/README.md +++ b/README.md @@ -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 + +``` + +### 7. Dedicated audio player + +```tsx +import { AudioPlayer } from '@source/player' + + +``` + ## 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 diff --git a/package-lock.json b/package-lock.json index a72486c..e670a56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c2633b1..3b116cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AudioPlayer.css b/src/components/AudioPlayer.css new file mode 100644 index 0000000..ee4b515 --- /dev/null +++ b/src/components/AudioPlayer.css @@ -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; + } +} diff --git a/src/components/AudioPlayer.test.tsx b/src/components/AudioPlayer.test.tsx new file mode 100644 index 0000000..ce79972 --- /dev/null +++ b/src/components/AudioPlayer.test.tsx @@ -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() + expect(container.querySelector('.sp-audio-player')).toBeInTheDocument() + expect(container.querySelector('audio')).toBeInTheDocument() + }) + + it('renders artwork and metadata when provided', () => { + const { container, getByText } = render( + + ) + + 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() + + 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( + + ) + + 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() + 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) + }) +}) diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx new file mode 100644 index 0000000..c01070a --- /dev/null +++ b/src/components/AudioPlayer.tsx @@ -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( + ( + { + 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(null) + const containerRef = React.useRef(null) + const [isActivePlayer, setIsActivePlayer] = useState(false) + const [audioState, setAudioState] = useState({ + 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(() => { + const cssVariables: Record = {} + + 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) => { + const value = Number(event.target.value) + if (!Number.isFinite(value)) return + seek(value) + }, + [seek] + ) + + const handleVolumeSlider = useCallback( + (event: React.ChangeEvent) => { + 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 ( +
+