feat: add optional subtitle style editor with live preview
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.1.0] - 2026-02-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added optional subtitle style editor via `subtitleStyleEditor` prop (`boolean | SubtitleStyleEditorConfig`).
|
||||||
|
- Added in-player subtitle style controls under `Settings > Subtitles` with real-time preview (font size, font weight, text/background colors, background opacity).
|
||||||
|
- Added explicit `Save`, `Cancel`, and `Reset` actions for subtitle style editing.
|
||||||
|
- Added subtitle style persistence to `localStorage` (saved only on `Save`) with configurable storage key.
|
||||||
|
- Added new i18n keys for subtitle style editor UI in English and Turkish.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated player context settings to include runtime subtitle style state (`setSubtitleStyle`, `saveSubtitleStyle`, `revertSubtitleStyle`).
|
||||||
|
- Updated subtitle renderer to use context-managed effective subtitle style, enabling live preview updates from settings.
|
||||||
|
- Updated example app with a feature toggle for subtitle style editor and demo persistence key.
|
||||||
|
|
||||||
## [3.0.1] - 2026-02-13
|
## [3.0.1] - 2026-02-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
## 🏆 Why Choose This Player?
|
## 🏆 Why Choose This Player?
|
||||||
|
|
||||||
| Feature | @source/player | video.js | react-player | plyr |
|
| Feature | @source/player | video.js | react-player | plyr |
|
||||||
|---------|---------------------|----------|--------------|------|
|
| ------------------------- | ---------------------------- | ---------- | ------------ | ---------- |
|
||||||
| **Bundle Size (gzipped)** | **~18KB JS + ~3.5KB CSS** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ |
|
| **Bundle Size (gzipped)** | **~18KB JS + ~3.5KB CSS** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ |
|
||||||
| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ |
|
| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ |
|
||||||
| **React (Web)** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
|
| **React (Web)** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
|
||||||
@@ -37,6 +37,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### 🎮 Core Playback
|
### 🎮 Core Playback
|
||||||
|
|
||||||
- ▶️ Play/Pause controls
|
- ▶️ Play/Pause controls
|
||||||
- ⏭️ Seek/scrub with progress bar
|
- ⏭️ Seek/scrub with progress bar
|
||||||
- 🔊 Volume control with slider
|
- 🔊 Volume control with slider
|
||||||
@@ -45,6 +46,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
- 🖼️ Custom poster/thumbnail
|
- 🖼️ Custom poster/thumbnail
|
||||||
|
|
||||||
### 🎨 Modern UI
|
### 🎨 Modern UI
|
||||||
|
|
||||||
- Clean, minimalist design with red theme
|
- Clean, minimalist design with red theme
|
||||||
- Smooth animations and transitions
|
- Smooth animations and transitions
|
||||||
- Auto-hiding controls
|
- Auto-hiding controls
|
||||||
@@ -54,6 +56,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
- Center play button
|
- Center play button
|
||||||
|
|
||||||
### ⌨️ Keyboard Shortcuts
|
### ⌨️ Keyboard Shortcuts
|
||||||
|
|
||||||
- `Space` or `K` - Play/Pause
|
- `Space` or `K` - Play/Pause
|
||||||
- `←` / `→` - Seek 5 seconds
|
- `←` / `→` - Seek 5 seconds
|
||||||
- `J` / `L` - Seek 10 seconds
|
- `J` / `L` - Seek 10 seconds
|
||||||
@@ -66,6 +69,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
- Shortcuts only work for the currently active/focused player instance
|
- Shortcuts only work for the currently active/focused player instance
|
||||||
|
|
||||||
### 📱 Touch Gestures
|
### 📱 Touch Gestures
|
||||||
|
|
||||||
- **Tap** - Play/Pause
|
- **Tap** - Play/Pause
|
||||||
- **Double tap left** - Rewind 10 seconds
|
- **Double tap left** - Rewind 10 seconds
|
||||||
- **Double tap right** - Forward 10 seconds
|
- **Double tap right** - Forward 10 seconds
|
||||||
@@ -73,10 +77,12 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
|||||||
- **Swipe up/down** - Volume control
|
- **Swipe up/down** - Volume control
|
||||||
|
|
||||||
### 🚀 Advanced Features
|
### 🚀 Advanced Features
|
||||||
|
|
||||||
- **HLS Streaming** - Automatic HLS.js integration for .m3u8 files
|
- **HLS Streaming** - Automatic HLS.js integration for .m3u8 files
|
||||||
- **IPTV Support** - MPEG-TS (.ts) streams for IPTV services
|
- **IPTV Support** - MPEG-TS (.ts) streams for IPTV services
|
||||||
- **HTTP Range Request** - Progressive download for large MP4 files
|
- **HTTP Range Request** - Progressive download for large MP4 files
|
||||||
- **Subtitles** - WebVTT and SRT support
|
- **Subtitles** - WebVTT and SRT support
|
||||||
|
- **Subtitle Style Editor** - Optional in-player style controls with live preview and local persistence
|
||||||
- **Multiple Audio Tracks** - Switch between different audio streams
|
- **Multiple Audio Tracks** - Switch between different audio streams
|
||||||
- **Picture-in-Picture** - Native browser PIP support
|
- **Picture-in-Picture** - Native browser PIP support
|
||||||
- **Fullscreen** - Native fullscreen API
|
- **Fullscreen** - Native fullscreen API
|
||||||
@@ -113,6 +119,7 @@ yarn add @source/player
|
|||||||
> **Note:** This package requires `react` (>=18) and `react-dom` (>=18) at runtime but does **not** list them as `peerDependencies` to avoid install conflicts with private registries. Make sure your project already has React installed.
|
> **Note:** This package requires `react` (>=18) and `react-dom` (>=18) at runtime but does **not** list them as `peerDependencies` to avoid install conflicts with private registries. Make sure your project already has React installed.
|
||||||
|
|
||||||
> **Streaming libraries (optional):** HLS, FLV and MPEG-TS streaming libraries are loaded automatically from CDN when needed. If you prefer to bundle them locally, install them separately:
|
> **Streaming libraries (optional):** HLS, FLV and MPEG-TS streaming libraries are loaded automatically from CDN when needed. If you prefer to bundle them locally, install them separately:
|
||||||
|
>
|
||||||
> ```bash
|
> ```bash
|
||||||
> npm install hls.js # HLS (.m3u8) streams
|
> npm install hls.js # HLS (.m3u8) streams
|
||||||
> npm install flv.js # FLV/RTMP streams
|
> npm install flv.js # FLV/RTMP streams
|
||||||
@@ -139,12 +146,7 @@ import { VideoPlayer } from '@source/player'
|
|||||||
import '@source/player/styles.css'
|
import '@source/player/styles.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return <VideoPlayer src="https://example.com/video.mp4" poster="https://example.com/poster.jpg" />
|
||||||
<VideoPlayer
|
|
||||||
src="https://example.com/video.mp4"
|
|
||||||
poster="https://example.com/poster.jpg"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -193,22 +195,32 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### HLS Streaming
|
### Subtitle Style Editor (Optional)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src="https://example.com/stream/playlist.m3u8"
|
src="https://example.com/video.mp4"
|
||||||
autoplay={false}
|
subtitles={[{ src: '/subtitles/en.vtt', lang: 'en', label: 'English', default: true }]}
|
||||||
|
subtitleStyleEditor={{
|
||||||
|
enabled: true,
|
||||||
|
storageKey: 'my-app-player-subtitle-style',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`enabled: true` adds a subtitle style editor in `Settings > Subtitles`.
|
||||||
|
Changes are previewed in real-time and saved to `localStorage` only when the user presses `Save`.
|
||||||
|
|
||||||
|
### HLS Streaming
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer src="https://example.com/stream/playlist.m3u8" autoplay={false} />
|
||||||
|
```
|
||||||
|
|
||||||
### Force Protocol (Override Auto Detection)
|
### Force Protocol (Override Auto Detection)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<VideoPlayer
|
<VideoPlayer src="https://cdn.example.com/video" protocol="hls" />
|
||||||
src="https://cdn.example.com/video"
|
|
||||||
protocol="hls"
|
|
||||||
/>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### IPTV Streaming
|
### IPTV Streaming
|
||||||
@@ -359,7 +371,7 @@ video-player/
|
|||||||
#### Basic Props
|
#### Basic Props
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
| Prop | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
| --------------------- | ------------------------------------------------------------- | ------------ | -------------------------------------------------------------- |
|
||||||
| `src` | `string` | **required** | Video source URL (MP4, WebM, HLS, IPTV .ts) |
|
| `src` | `string` | **required** | Video source URL (MP4, WebM, HLS, IPTV .ts) |
|
||||||
| `poster` | `string` | - | Poster image URL |
|
| `poster` | `string` | - | Poster image URL |
|
||||||
| `protocol` | `'auto' \| 'native' \| 'hls' \| 'rtmp' \| 'dash' \| 'mpegts'` | `'auto'` | Force playback engine instead of URL auto-detection |
|
| `protocol` | `'auto' \| 'native' \| 'hls' \| 'rtmp' \| 'dash' \| 'mpegts'` | `'auto'` | Force playback engine instead of URL auto-detection |
|
||||||
@@ -376,6 +388,7 @@ video-player/
|
|||||||
| `controls` | `boolean` | `true` | Show player controls |
|
| `controls` | `boolean` | `true` | Show player controls |
|
||||||
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
||||||
| `subtitleStyle` | `SubtitleStyle` | - | Custom subtitle text/background style |
|
| `subtitleStyle` | `SubtitleStyle` | - | Custom subtitle text/background style |
|
||||||
|
| `subtitleStyleEditor` | `boolean \| SubtitleStyleEditorConfig` | `false` | Optional subtitle style editor UI and localStorage persistence |
|
||||||
| `subtitlePosition` | `'top' \| 'center' \| 'bottom'` | `'bottom'` | Subtitle vertical placement |
|
| `subtitlePosition` | `'top' \| 'center' \| 'bottom'` | `'bottom'` | Subtitle vertical placement |
|
||||||
| `subtitleOffset` | `number \| string` | - | Subtitle offset (`px` if number) |
|
| `subtitleOffset` | `number \| string` | - | Subtitle offset (`px` if number) |
|
||||||
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
||||||
@@ -388,7 +401,7 @@ video-player/
|
|||||||
#### Event Handlers
|
#### Event Handlers
|
||||||
|
|
||||||
| Prop | Type | Description |
|
| Prop | Type | Description |
|
||||||
|------|------|-------------|
|
| -------------------------- | --------------------------------- | ------------------------------------------- |
|
||||||
| `onPlay` | `() => void` | Fired when playback starts |
|
| `onPlay` | `() => void` | Fired when playback starts |
|
||||||
| `onPause` | `() => void` | Fired when playback pauses |
|
| `onPause` | `() => void` | Fired when playback pauses |
|
||||||
| `onEnded` | `() => void` | Fired when playback ends |
|
| `onEnded` | `() => void` | Fired when playback ends |
|
||||||
@@ -409,7 +422,7 @@ video-player/
|
|||||||
### PlayerErrorBoundary Props
|
### PlayerErrorBoundary Props
|
||||||
|
|
||||||
| Prop | Type | Description |
|
| Prop | Type | Description |
|
||||||
|------|------|-------------|
|
| ----------- | ------------------------------------------------------------- | -------------------------------------- |
|
||||||
| `children` | `ReactNode` | Wrapped player/content tree |
|
| `children` | `ReactNode` | Wrapped player/content tree |
|
||||||
| `fallback` | `ReactNode \| (error: Error, retry: () => void) => ReactNode` | Optional custom fallback UI |
|
| `fallback` | `ReactNode \| (error: Error, retry: () => void) => ReactNode` | Optional custom fallback UI |
|
||||||
| `onError` | `(error: Error, errorInfo: React.ErrorInfo) => void` | Called when render errors are captured |
|
| `onError` | `(error: Error, errorInfo: React.ErrorInfo) => void` | Called when render errors are captured |
|
||||||
@@ -427,6 +440,13 @@ interface SubtitleTrack {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SubtitleStyleEditorConfig {
|
||||||
|
enabled?: boolean // Enables subtitle style editor in settings
|
||||||
|
storageKey?: string // localStorage key (default: 'source-player-subtitle-style')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### PlayerTheme
|
### PlayerTheme
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -457,6 +477,7 @@ interface PlayerTheme {
|
|||||||
## 🔧 Technical Details
|
## 🔧 Technical Details
|
||||||
|
|
||||||
### Native Browser APIs Used
|
### Native Browser APIs Used
|
||||||
|
|
||||||
- HTML5 Video API
|
- HTML5 Video API
|
||||||
- Fullscreen API
|
- Fullscreen API
|
||||||
- Picture-in-Picture API
|
- Picture-in-Picture API
|
||||||
@@ -468,18 +489,21 @@ interface PlayerTheme {
|
|||||||
### Streaming Support
|
### Streaming Support
|
||||||
|
|
||||||
**MP4/WebM (Progressive Download)**
|
**MP4/WebM (Progressive Download)**
|
||||||
|
|
||||||
- Uses HTTP Range Requests
|
- Uses HTTP Range Requests
|
||||||
- Browser automatically chunks the download
|
- Browser automatically chunks the download
|
||||||
- No additional library needed
|
- No additional library needed
|
||||||
- Works with any standard web server that supports Range headers
|
- Works with any standard web server that supports Range headers
|
||||||
|
|
||||||
**HLS (.m3u8)**
|
**HLS (.m3u8)**
|
||||||
|
|
||||||
- Automatically detects HLS sources
|
- Automatically detects HLS sources
|
||||||
- Lazy loads hls.js library when needed
|
- Lazy loads hls.js library when needed
|
||||||
- Safari has native HLS support (no library needed)
|
- Safari has native HLS support (no library needed)
|
||||||
- Adaptive bitrate streaming
|
- Adaptive bitrate streaming
|
||||||
|
|
||||||
### Performance Optimizations
|
### Performance Optimizations
|
||||||
|
|
||||||
- Lazy loading of HLS.js with CDN fallback
|
- Lazy loading of HLS.js with CDN fallback
|
||||||
- CSS-only animations
|
- CSS-only animations
|
||||||
- Debounced control hiding
|
- Debounced control hiding
|
||||||
@@ -489,6 +513,7 @@ interface PlayerTheme {
|
|||||||
- Polyfills for older browser support
|
- Polyfills for older browser support
|
||||||
|
|
||||||
### Error Handling & Reliability
|
### Error Handling & Reliability
|
||||||
|
|
||||||
- **CORS Detection**: Automatically detects and reports CORS issues with helpful error messages
|
- **CORS Detection**: Automatically detects and reports CORS issues with helpful error messages
|
||||||
- **HLS.js Fallback**: If npm package fails to load, automatically falls back to CDN
|
- **HLS.js Fallback**: If npm package fails to load, automatically falls back to CDN
|
||||||
- **Memory Management**: Proper cleanup of HLS instances to prevent memory leaks
|
- **Memory Management**: Proper cleanup of HLS instances to prevent memory leaks
|
||||||
|
|||||||
+20
-2
@@ -5,8 +5,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
font-family:
|
||||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||||
|
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #0f172a;
|
background-color: #0f172a;
|
||||||
@@ -70,6 +71,9 @@ body {
|
|||||||
.controls-section {
|
.controls-section {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input {
|
.url-input {
|
||||||
@@ -122,6 +126,20 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input[type='checkbox'] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.features-section {
|
.features-section {
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-12
@@ -6,6 +6,7 @@ import './App.css'
|
|||||||
function App() {
|
function App() {
|
||||||
const [videoUrl, setVideoUrl] = useState('')
|
const [videoUrl, setVideoUrl] = useState('')
|
||||||
const [useDemo, setUseDemo] = useState(true)
|
const [useDemo, setUseDemo] = useState(true)
|
||||||
|
const [subtitleStyleEditorEnabled, setSubtitleStyleEditorEnabled] = useState(true)
|
||||||
|
|
||||||
// Demo video URLs (you can replace with your own)
|
// Demo video URLs (you can replace with your own)
|
||||||
const demoVideoUrl = '/player/ses.mp4'
|
const demoVideoUrl = '/player/ses.mp4'
|
||||||
@@ -37,6 +38,17 @@ function App() {
|
|||||||
src={currentVideoUrl}
|
src={currentVideoUrl}
|
||||||
poster={useDemo ? demoPoster : undefined}
|
poster={useDemo ? demoPoster : undefined}
|
||||||
subtitles={demoSubtitles}
|
subtitles={demoSubtitles}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: '#0f0f0f',
|
||||||
|
backgroundOpacity: 0.78,
|
||||||
|
}}
|
||||||
|
subtitleStyleEditor={{
|
||||||
|
enabled: subtitleStyleEditorEnabled,
|
||||||
|
storageKey: 'source-player-example-subtitle-style',
|
||||||
|
}}
|
||||||
keyboardShortcuts={true}
|
keyboardShortcuts={true}
|
||||||
pictureInPicture={true}
|
pictureInPicture={true}
|
||||||
theme={{
|
theme={{
|
||||||
@@ -63,13 +75,18 @@ function App() {
|
|||||||
onChange={(e) => setVideoUrl(e.target.value)}
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
disabled={useDemo}
|
disabled={useDemo}
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={() => setUseDemo(!useDemo)} className={useDemo ? 'active' : ''}>
|
||||||
onClick={() => setUseDemo(!useDemo)}
|
|
||||||
className={useDemo ? 'active' : ''}
|
|
||||||
>
|
|
||||||
{useDemo ? 'Using Demo' : 'Use Demo'}
|
{useDemo ? 'Using Demo' : 'Use Demo'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="toggle-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={subtitleStyleEditorEnabled}
|
||||||
|
onChange={(event) => setSubtitleStyleEditorEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Enable subtitle style editor (with localStorage)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,14 +96,30 @@ function App() {
|
|||||||
<div className="feature">
|
<div className="feature">
|
||||||
<h3>⌨️ Keyboard Shortcuts</h3>
|
<h3>⌨️ Keyboard Shortcuts</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><kbd>Space</kbd> or <kbd>K</kbd> - Play/Pause</li>
|
<li>
|
||||||
<li><kbd>←</kbd> / <kbd>→</kbd> - Seek 5s</li>
|
<kbd>Space</kbd> or <kbd>K</kbd> - Play/Pause
|
||||||
<li><kbd>J</kbd> / <kbd>L</kbd> - Seek 10s</li>
|
</li>
|
||||||
<li><kbd>↑</kbd> / <kbd>↓</kbd> - Volume</li>
|
<li>
|
||||||
<li><kbd>M</kbd> - Mute/Unmute</li>
|
<kbd>←</kbd> / <kbd>→</kbd> - Seek 5s
|
||||||
<li><kbd>F</kbd> - Fullscreen</li>
|
</li>
|
||||||
<li><kbd>P</kbd> - Picture-in-Picture</li>
|
<li>
|
||||||
<li><kbd>0-9</kbd> - Jump to %</li>
|
<kbd>J</kbd> / <kbd>L</kbd> - Seek 10s
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<kbd>↑</kbd> / <kbd>↓</kbd> - Volume
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<kbd>M</kbd> - Mute/Unmute
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<kbd>F</kbd> - Fullscreen
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<kbd>P</kbd> - Picture-in-Picture
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<kbd>0-9</kbd> - Jump to %
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +151,7 @@ function App() {
|
|||||||
<li>HLS streaming support</li>
|
<li>HLS streaming support</li>
|
||||||
<li>HTTP Range Request (MP4)</li>
|
<li>HTTP Range Request (MP4)</li>
|
||||||
<li>Subtitles (VTT, SRT)</li>
|
<li>Subtitles (VTT, SRT)</li>
|
||||||
|
<li>Subtitle style editor + live preview</li>
|
||||||
<li>Multiple audio tracks</li>
|
<li>Multiple audio tracks</li>
|
||||||
<li>Playback speed control</li>
|
<li>Playback speed control</li>
|
||||||
<li>Quality selector</li>
|
<li>Quality selector</li>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "3.0.1",
|
"version": "3.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "3.0.1",
|
"version": "3.1.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.0.1",
|
"version": "3.1.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",
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ interface VideoElementProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stripCueMarkup = (text: string): string => {
|
const stripCueMarkup = (text: string): string => {
|
||||||
return text.replace(/<[^>]+>/g, '').replace(/\r/g, '').trim()
|
return text
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toCssLength = (value?: number | string): string | undefined => {
|
const toCssLength = (value?: number | string): string | undefined => {
|
||||||
@@ -179,7 +182,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onQualityLevelsLoaded,
|
onQualityLevelsLoaded,
|
||||||
onSubtitleTracksLoaded,
|
onSubtitleTracksLoaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } =
|
||||||
|
usePlayerContext()
|
||||||
const lastClickTimeRef = React.useRef<number>(0)
|
const lastClickTimeRef = React.useRef<number>(0)
|
||||||
const hasPlayedRef = React.useRef(false)
|
const hasPlayedRef = React.useRef(false)
|
||||||
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||||
@@ -190,39 +194,49 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
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 effectiveSubtitleStyle = React.useMemo<SubtitleStyle>(
|
||||||
|
() => ({
|
||||||
|
...(subtitleStyle || {}),
|
||||||
|
...(settings.subtitleStyle || {}),
|
||||||
|
}),
|
||||||
|
[subtitleStyle, settings.subtitleStyle]
|
||||||
|
)
|
||||||
|
|
||||||
const subtitleCueStyle = React.useMemo<React.CSSProperties>(() => {
|
const subtitleCueStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
const style: React.CSSProperties = {}
|
const style: React.CSSProperties = {}
|
||||||
|
|
||||||
if (subtitleStyle?.fontFamily) {
|
if (effectiveSubtitleStyle.fontFamily) {
|
||||||
style.fontFamily = subtitleStyle.fontFamily
|
style.fontFamily = effectiveSubtitleStyle.fontFamily
|
||||||
}
|
}
|
||||||
if (subtitleStyle?.fontSize !== undefined) {
|
if (effectiveSubtitleStyle.fontSize !== undefined) {
|
||||||
style.fontSize =
|
style.fontSize =
|
||||||
typeof subtitleStyle.fontSize === 'number' ? `${subtitleStyle.fontSize}px` : subtitleStyle.fontSize
|
typeof effectiveSubtitleStyle.fontSize === 'number'
|
||||||
|
? `${effectiveSubtitleStyle.fontSize}px`
|
||||||
|
: effectiveSubtitleStyle.fontSize
|
||||||
}
|
}
|
||||||
if (subtitleStyle?.fontWeight !== undefined) {
|
if (effectiveSubtitleStyle.fontWeight !== undefined) {
|
||||||
style.fontWeight = subtitleStyle.fontWeight
|
style.fontWeight = effectiveSubtitleStyle.fontWeight
|
||||||
}
|
}
|
||||||
if (subtitleStyle?.color) {
|
if (effectiveSubtitleStyle.color) {
|
||||||
style.color = subtitleStyle.color
|
style.color = effectiveSubtitleStyle.color
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasBackgroundColor = typeof subtitleStyle?.backgroundColor === 'string'
|
const hasBackgroundColor = typeof effectiveSubtitleStyle.backgroundColor === 'string'
|
||||||
const hasBackgroundOpacity = typeof subtitleStyle?.backgroundOpacity === 'number'
|
const hasBackgroundOpacity = typeof effectiveSubtitleStyle.backgroundOpacity === 'number'
|
||||||
|
|
||||||
if (hasBackgroundColor && hasBackgroundOpacity) {
|
if (hasBackgroundColor && hasBackgroundOpacity) {
|
||||||
style.backgroundColor = toRgbaWithOpacity(
|
style.backgroundColor = toRgbaWithOpacity(
|
||||||
subtitleStyle.backgroundColor as string,
|
effectiveSubtitleStyle.backgroundColor as string,
|
||||||
subtitleStyle.backgroundOpacity as number
|
effectiveSubtitleStyle.backgroundOpacity as number
|
||||||
)
|
)
|
||||||
} else if (hasBackgroundColor) {
|
} else if (hasBackgroundColor) {
|
||||||
style.backgroundColor = subtitleStyle?.backgroundColor
|
style.backgroundColor = effectiveSubtitleStyle.backgroundColor
|
||||||
} else if (hasBackgroundOpacity) {
|
} else if (hasBackgroundOpacity) {
|
||||||
style.backgroundColor = `rgba(15, 15, 15, ${clampOpacity(subtitleStyle?.backgroundOpacity as number)})`
|
style.backgroundColor = `rgba(15, 15, 15, ${clampOpacity(effectiveSubtitleStyle.backgroundOpacity as number)})`
|
||||||
}
|
}
|
||||||
|
|
||||||
return style
|
return style
|
||||||
}, [subtitleStyle])
|
}, [effectiveSubtitleStyle])
|
||||||
|
|
||||||
const subtitleOverlayStyle = React.useMemo<React.CSSProperties>(() => {
|
const subtitleOverlayStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
const style: React.CSSProperties = {}
|
const style: React.CSSProperties = {}
|
||||||
@@ -302,7 +316,14 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoadedMetadata?.()
|
onLoadedMetadata?.()
|
||||||
}, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle, settings.subtitle])
|
}, [
|
||||||
|
videoRef,
|
||||||
|
setVideoState,
|
||||||
|
onLoadedMetadata,
|
||||||
|
processedSubtitles,
|
||||||
|
setSubtitle,
|
||||||
|
settings.subtitle,
|
||||||
|
])
|
||||||
|
|
||||||
const handleDurationChange = useCallback(() => {
|
const handleDurationChange = useCallback(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -310,7 +331,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
|
|
||||||
// Re-check if this is a live broadcast when duration changes
|
// Re-check if this is a live broadcast when duration changes
|
||||||
const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0
|
const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0
|
||||||
logger.log('[VideoElement] Duration changed. Is live broadcast?', isLiveBroadcast, 'duration:', video.duration)
|
logger.log(
|
||||||
|
'[VideoElement] Duration changed. Is live broadcast?',
|
||||||
|
isLiveBroadcast,
|
||||||
|
'duration:',
|
||||||
|
video.duration
|
||||||
|
)
|
||||||
|
|
||||||
setVideoState((prev) => ({
|
setVideoState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -506,7 +532,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
// Fetch and convert SRT to VTT
|
// Fetch and convert SRT to VTT
|
||||||
const response = await fetch(subtitle.src)
|
const response = await fetch(subtitle.src)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch subtitle: ${response.status} ${response.statusText}`)
|
throw new Error(
|
||||||
|
`Failed to fetch subtitle: ${response.status} ${response.statusText}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const srtContent = await response.text()
|
const srtContent = await response.text()
|
||||||
|
|
||||||
@@ -737,7 +765,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const corsMessage = getCORSErrorMessage(src)
|
const corsMessage = getCORSErrorMessage(src)
|
||||||
error = new Error(corsMessage)
|
error = new Error(corsMessage)
|
||||||
} else {
|
} else {
|
||||||
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
error =
|
||||||
|
err instanceof Error
|
||||||
|
? err
|
||||||
|
: new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancelled) return
|
if (isCancelled) return
|
||||||
@@ -848,10 +879,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
return quality.levelIndex === settings.quality.levelIndex
|
return quality.levelIndex === settings.quality.levelIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (typeof settings.quality?.height === 'number' && typeof quality.height === 'number') {
|
||||||
typeof settings.quality?.height === 'number' &&
|
|
||||||
typeof quality.height === 'number'
|
|
||||||
) {
|
|
||||||
return quality.height === settings.quality.height
|
return quality.height === settings.quality.height
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1027,4 +1055,3 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
|||||||
import { VideoElement } from './VideoElement'
|
import { VideoElement } from './VideoElement'
|
||||||
import { ControlsLayer } from './ControlsLayer'
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
import { PlayerErrorBoundary } from './ErrorBoundary'
|
import { PlayerErrorBoundary } from './ErrorBoundary'
|
||||||
import type { VideoPlayerProps, VideoPlayerHandle, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
import type {
|
||||||
|
VideoPlayerProps,
|
||||||
|
VideoPlayerHandle,
|
||||||
|
AudioTrack,
|
||||||
|
VideoQuality,
|
||||||
|
SubtitleTrack,
|
||||||
|
} from '../types'
|
||||||
import { initializePolyfills } from '../utils/polyfills'
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
import './VideoPlayer.css'
|
import './VideoPlayer.css'
|
||||||
@@ -40,6 +46,24 @@ const parseAspectRatio = (ratio: VideoPlayerProps['aspectRatio']): string => {
|
|||||||
return map[ratio] || '56.25%'
|
return map[ratio] || '56.25%'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SUBTITLE_STYLE_STORAGE_KEY = 'source-player-subtitle-style'
|
||||||
|
|
||||||
|
const resolveSubtitleStyleEditorConfig = (
|
||||||
|
subtitleStyleEditor: VideoPlayerProps['subtitleStyleEditor']
|
||||||
|
): { enabled: boolean; storageKey: string } => {
|
||||||
|
if (typeof subtitleStyleEditor === 'boolean') {
|
||||||
|
return {
|
||||||
|
enabled: subtitleStyleEditor,
|
||||||
|
storageKey: DEFAULT_SUBTITLE_STYLE_STORAGE_KEY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: subtitleStyleEditor?.enabled ?? false,
|
||||||
|
storageKey: subtitleStyleEditor?.storageKey?.trim() || DEFAULT_SUBTITLE_STYLE_STORAGE_KEY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoPlayerContentProps extends VideoPlayerProps {
|
interface VideoPlayerContentProps extends VideoPlayerProps {
|
||||||
audioTracks: AudioTrack[]
|
audioTracks: AudioTrack[]
|
||||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||||
@@ -141,7 +165,18 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
togglePictureInPicture,
|
togglePictureInPicture,
|
||||||
setPlaybackRate,
|
setPlaybackRate,
|
||||||
}),
|
}),
|
||||||
[videoRef, containerRef, play, pause, seek, setVolume, toggleMute, toggleFullscreen, togglePictureInPicture, setPlaybackRate]
|
[
|
||||||
|
videoRef,
|
||||||
|
containerRef,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
setPlaybackRate,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
|
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
|
||||||
@@ -265,7 +300,15 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children && (
|
{children && (
|
||||||
<div className="sp-video-player-overlay" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 'var(--player-z-controls)' as React.CSSProperties['zIndex'] }}>
|
<div
|
||||||
|
className="sp-video-player-overlay"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 'var(--player-z-controls)' as React.CSSProperties['zIndex'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ pointerEvents: 'auto' }}>{children}</div>
|
<div style={{ pointerEvents: 'auto' }}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -295,6 +338,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
controls = true,
|
controls = true,
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
|
subtitleStyleEditor,
|
||||||
subtitlePosition,
|
subtitlePosition,
|
||||||
subtitleOffset,
|
subtitleOffset,
|
||||||
theme,
|
theme,
|
||||||
@@ -338,6 +382,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
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[]>([])
|
||||||
|
const subtitleStyleEditorConfig = useMemo(
|
||||||
|
() => resolveSubtitleStyleEditorConfig(subtitleStyleEditor),
|
||||||
|
[subtitleStyleEditor]
|
||||||
|
)
|
||||||
|
|
||||||
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
||||||
setAudioTracks(tracks)
|
setAudioTracks(tracks)
|
||||||
@@ -358,7 +406,16 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
onError?.(error)
|
onError?.(error)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
<PlayerProvider
|
||||||
|
initialVolume={volume}
|
||||||
|
initialMuted={muted}
|
||||||
|
initialPlaybackRate={playbackRate}
|
||||||
|
initialSubtitleStyle={subtitleStyle}
|
||||||
|
subtitleStyleEditorEnabled={subtitleStyleEditorConfig.enabled}
|
||||||
|
subtitleStyleStorageKey={subtitleStyleEditorConfig.storageKey}
|
||||||
|
language={language}
|
||||||
|
customTranslations={customTranslations}
|
||||||
|
>
|
||||||
<VideoPlayerContent
|
<VideoPlayerContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 12px);
|
bottom: calc(100% + 12px);
|
||||||
right: 0;
|
right: 0;
|
||||||
min-width: 260px;
|
min-width: 280px;
|
||||||
max-height: 360px;
|
max-height: 420px;
|
||||||
background: var(--player-surface);
|
background: var(--player-surface);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: var(--player-radius);
|
border-radius: var(--player-radius);
|
||||||
@@ -40,7 +40,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: var(--player-radius-sm);
|
border-radius: var(--player-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color var(--player-transition-fast) ease,
|
transition:
|
||||||
|
color var(--player-transition-fast) ease,
|
||||||
background-color var(--player-transition-fast) ease;
|
background-color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,8 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--player-text);
|
color: var(--player-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--player-transition-fast) ease,
|
transition:
|
||||||
|
background-color var(--player-transition-fast) ease,
|
||||||
color var(--player-transition-fast) ease;
|
color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
.sp-settings-options {
|
.sp-settings-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 280px;
|
max-height: 340px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +126,8 @@
|
|||||||
color: var(--player-text);
|
color: var(--player-text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--player-transition-fast) ease,
|
transition:
|
||||||
|
background-color var(--player-transition-fast) ease,
|
||||||
color var(--player-transition-fast) ease;
|
color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +140,160 @@
|
|||||||
background-color: rgba(239, 68, 68, 0.14);
|
background-color: rgba(239, 68, 68, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sp-settings-option span {
|
.sp-settings-option > span:first-child {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sp-settings-option-chip {
|
||||||
|
margin-left: var(--player-spacing-md);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-editor {
|
||||||
|
padding: var(--player-spacing-md);
|
||||||
|
gap: var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-preview-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--player-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-preview-stage {
|
||||||
|
min-height: 92px;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: linear-gradient(165deg, rgba(0, 0, 0, 0.65), rgba(23, 23, 23, 0.94));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-preview-cue {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
padding: 0.35em 0.75em;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-control-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--player-spacing-xs);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-control-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--player-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-control-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-control-input-group input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--player-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-control-value {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-color-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-color-row input[type='color'] {
|
||||||
|
appearance: none;
|
||||||
|
width: 34px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-color-row input[type='color']::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-color-row input[type='color']::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--player-radius-sm) - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
margin-top: var(--player-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--player-text);
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--player-transition-fast) ease,
|
||||||
|
background-color var(--player-transition-fast) ease,
|
||||||
|
color var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action.primary {
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: var(--player-primary);
|
||||||
|
background: var(--player-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action.primary:hover {
|
||||||
|
border-color: var(--player-primary-hover);
|
||||||
|
background: var(--player-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action.muted {
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.sp-settings-empty-state {
|
.sp-settings-empty-state {
|
||||||
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -168,7 +321,7 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.sp-settings-menu {
|
.sp-settings-menu {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
max-height: 320px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sp-settings-main-option,
|
.sp-settings-main-option,
|
||||||
@@ -177,6 +330,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sp-settings-options {
|
.sp-settings-options {
|
||||||
max-height: 240px;
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-editor {
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
padding: var(--player-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-preview-stage {
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-settings-style-action {
|
||||||
|
padding: 7px 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,21 +57,36 @@ describe('SettingsMenu', () => {
|
|||||||
settings: {
|
settings: {
|
||||||
quality: null,
|
quality: null,
|
||||||
subtitle: null,
|
subtitle: null,
|
||||||
|
subtitleStyle: {},
|
||||||
audioTrack: null,
|
audioTrack: null,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
},
|
},
|
||||||
setPlaybackRate: vi.fn(),
|
setPlaybackRate: vi.fn(),
|
||||||
setSubtitle: vi.fn(),
|
setSubtitle: vi.fn(),
|
||||||
|
setSubtitleStyle: vi.fn(),
|
||||||
|
saveSubtitleStyle: vi.fn(),
|
||||||
|
revertSubtitleStyle: vi.fn(),
|
||||||
setAudioTrack: vi.fn(),
|
setAudioTrack: vi.fn(),
|
||||||
setQuality: vi.fn(),
|
setQuality: vi.fn(),
|
||||||
toggleSettings: vi.fn(),
|
toggleSettings: vi.fn(),
|
||||||
|
subtitleStyleEditorEnabled: true,
|
||||||
translations: {
|
translations: {
|
||||||
noSubtitlesAvailable: 'No subtitles available',
|
noSubtitlesAvailable: 'No subtitles available',
|
||||||
subtitles: 'Subtitles',
|
subtitles: 'Subtitles',
|
||||||
|
subtitleStyle: 'Subtitle Style',
|
||||||
|
preview: 'Preview',
|
||||||
|
save: 'Save',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
reset: 'Reset',
|
||||||
off: 'Off',
|
off: 'Off',
|
||||||
auto: 'Auto',
|
auto: 'Auto',
|
||||||
quality: 'Quality',
|
quality: 'Quality',
|
||||||
speed: 'Speed',
|
speed: 'Speed',
|
||||||
|
fontSize: 'Font Size',
|
||||||
|
fontWeight: 'Font Weight',
|
||||||
|
textColor: 'Text Color',
|
||||||
|
backgroundColor: 'Background Color',
|
||||||
|
backgroundOpacity: 'Background Opacity',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
audioTrack: 'Audio Track',
|
audioTrack: 'Audio Track',
|
||||||
@@ -130,4 +145,19 @@ describe('SettingsMenu', () => {
|
|||||||
fireEvent.click(screen.getByText('720p'))
|
fireEvent.click(screen.getByText('720p'))
|
||||||
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
|
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saves subtitle style from subtitle style editor', () => {
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Subtitles'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Subtitle Style/i }))
|
||||||
|
fireEvent.change(screen.getByLabelText('Font Size'), { target: { value: '30' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
expect(contextState.value.saveSubtitleStyle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
fontSize: 30,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon, QualityIcon } from '../../icons'
|
import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon, QualityIcon } from '../../icons'
|
||||||
import type { AudioTrack, VideoQuality } from '../../types'
|
import type { AudioTrack, SubtitleStyle, VideoQuality } from '../../types'
|
||||||
import './SettingsMenu.css'
|
import './SettingsMenu.css'
|
||||||
|
|
||||||
interface SettingsMenuProps {
|
interface SettingsMenuProps {
|
||||||
@@ -11,7 +11,106 @@ interface SettingsMenuProps {
|
|||||||
playbackRates?: number[]
|
playbackRates?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' | 'quality'
|
type MenuView = 'main' | 'speed' | 'subtitles' | 'subtitleStyle' | 'audio' | 'quality'
|
||||||
|
|
||||||
|
interface EditableSubtitleStyle extends SubtitleStyle {
|
||||||
|
fontSize: number
|
||||||
|
fontWeight: number
|
||||||
|
color: string
|
||||||
|
backgroundColor: string
|
||||||
|
backgroundOpacity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SUBTITLE_STYLE: EditableSubtitleStyle = {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: '#0f0f0f',
|
||||||
|
backgroundOpacity: 0.78,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_PREVIEW_TEXT = 'Subtitle preview text'
|
||||||
|
|
||||||
|
const clampOpacity = (value: number): number => {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.max(0, Math.min(1, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRgbaWithOpacity = (color: string, opacity: number): string => {
|
||||||
|
const normalizedOpacity = clampOpacity(opacity)
|
||||||
|
const trimmed = color.trim()
|
||||||
|
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
const hex = trimmed.slice(1)
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
const expand = (value: string) => value + value
|
||||||
|
let r = 0
|
||||||
|
let g = 0
|
||||||
|
let b = 0
|
||||||
|
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
r = parseInt(expand(hex[0]), 16)
|
||||||
|
g = parseInt(expand(hex[1]), 16)
|
||||||
|
b = parseInt(expand(hex[2]), 16)
|
||||||
|
} else if (hex.length === 6 || hex.length === 8) {
|
||||||
|
r = parseInt(hex.slice(0, 2), 16)
|
||||||
|
g = parseInt(hex.slice(2, 4), 16)
|
||||||
|
b = parseInt(hex.slice(4, 6), 16)
|
||||||
|
} else {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${normalizedOpacity})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgbMatch = trimmed.match(/^rgba?\(([^)]+)\)$/i)
|
||||||
|
if (rgbMatch) {
|
||||||
|
const channels = rgbMatch[1]
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (channels.length >= 3) {
|
||||||
|
const [r, g, b] = channels
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${normalizedOpacity})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEditableSubtitleStyle = (subtitleStyle: SubtitleStyle): EditableSubtitleStyle => ({
|
||||||
|
...DEFAULT_SUBTITLE_STYLE,
|
||||||
|
fontSize:
|
||||||
|
typeof subtitleStyle.fontSize === 'number' && Number.isFinite(subtitleStyle.fontSize)
|
||||||
|
? subtitleStyle.fontSize
|
||||||
|
: DEFAULT_SUBTITLE_STYLE.fontSize,
|
||||||
|
fontWeight:
|
||||||
|
typeof subtitleStyle.fontWeight === 'number' && Number.isFinite(subtitleStyle.fontWeight)
|
||||||
|
? subtitleStyle.fontWeight
|
||||||
|
: DEFAULT_SUBTITLE_STYLE.fontWeight,
|
||||||
|
color:
|
||||||
|
typeof subtitleStyle.color === 'string' && subtitleStyle.color.trim().length > 0
|
||||||
|
? subtitleStyle.color
|
||||||
|
: DEFAULT_SUBTITLE_STYLE.color,
|
||||||
|
backgroundColor:
|
||||||
|
typeof subtitleStyle.backgroundColor === 'string' &&
|
||||||
|
subtitleStyle.backgroundColor.trim().length > 0
|
||||||
|
? subtitleStyle.backgroundColor
|
||||||
|
: DEFAULT_SUBTITLE_STYLE.backgroundColor,
|
||||||
|
backgroundOpacity:
|
||||||
|
typeof subtitleStyle.backgroundOpacity === 'number' &&
|
||||||
|
Number.isFinite(subtitleStyle.backgroundOpacity)
|
||||||
|
? clampOpacity(subtitleStyle.backgroundOpacity)
|
||||||
|
: DEFAULT_SUBTITLE_STYLE.backgroundOpacity,
|
||||||
|
fontFamily:
|
||||||
|
typeof subtitleStyle.fontFamily === 'string' && subtitleStyle.fontFamily.trim().length > 0
|
||||||
|
? subtitleStyle.fontFamily
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
@@ -25,15 +124,81 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
settings,
|
settings,
|
||||||
setPlaybackRate,
|
setPlaybackRate,
|
||||||
setSubtitle,
|
setSubtitle,
|
||||||
|
setSubtitleStyle,
|
||||||
|
saveSubtitleStyle,
|
||||||
|
revertSubtitleStyle,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
setQuality,
|
setQuality,
|
||||||
toggleSettings,
|
toggleSettings,
|
||||||
|
subtitleStyleEditorEnabled,
|
||||||
translations,
|
translations,
|
||||||
} = usePlayerContext()
|
} = usePlayerContext()
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentView, setCurrentView] = useState<MenuView>('main')
|
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||||
|
const [subtitleStyleDraft, setSubtitleStyleDraft] = useState<EditableSubtitleStyle>(
|
||||||
|
toEditableSubtitleStyle(settings.subtitleStyle)
|
||||||
|
)
|
||||||
|
|
||||||
const playbackRates = playbackRatesProp ?? [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
const playbackRates = playbackRatesProp ?? [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||||
|
const subtitleStyleSummary = useMemo(
|
||||||
|
() => toEditableSubtitleStyle(settings.subtitleStyle),
|
||||||
|
[settings.subtitleStyle]
|
||||||
|
)
|
||||||
|
const subtitlePreviewStyle = useMemo<React.CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
fontSize: `${subtitleStyleDraft.fontSize}px`,
|
||||||
|
fontWeight: subtitleStyleDraft.fontWeight,
|
||||||
|
color: subtitleStyleDraft.color,
|
||||||
|
backgroundColor: toRgbaWithOpacity(
|
||||||
|
subtitleStyleDraft.backgroundColor,
|
||||||
|
subtitleStyleDraft.backgroundOpacity
|
||||||
|
),
|
||||||
|
fontFamily: subtitleStyleDraft.fontFamily,
|
||||||
|
}),
|
||||||
|
[subtitleStyleDraft]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openSubtitleStyleEditor = useCallback(() => {
|
||||||
|
const initialDraft = toEditableSubtitleStyle(settings.subtitleStyle)
|
||||||
|
setSubtitleStyleDraft(initialDraft)
|
||||||
|
setSubtitleStyle(initialDraft)
|
||||||
|
setCurrentView('subtitleStyle')
|
||||||
|
}, [setSubtitleStyle, settings.subtitleStyle])
|
||||||
|
|
||||||
|
const updateSubtitleStyleDraft = useCallback(
|
||||||
|
(nextPartial: Partial<EditableSubtitleStyle>) => {
|
||||||
|
setSubtitleStyleDraft((previousDraft) => {
|
||||||
|
const nextDraft: EditableSubtitleStyle = {
|
||||||
|
...previousDraft,
|
||||||
|
...nextPartial,
|
||||||
|
backgroundOpacity: clampOpacity(
|
||||||
|
typeof nextPartial.backgroundOpacity === 'number'
|
||||||
|
? nextPartial.backgroundOpacity
|
||||||
|
: previousDraft.backgroundOpacity
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubtitleStyle(nextDraft)
|
||||||
|
return nextDraft
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setSubtitleStyle]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubtitleStyleCancel = useCallback(() => {
|
||||||
|
revertSubtitleStyle()
|
||||||
|
setCurrentView('subtitles')
|
||||||
|
}, [revertSubtitleStyle])
|
||||||
|
|
||||||
|
const handleSubtitleStyleSave = useCallback(() => {
|
||||||
|
saveSubtitleStyle(subtitleStyleDraft)
|
||||||
|
setCurrentView('subtitles')
|
||||||
|
}, [saveSubtitleStyle, subtitleStyleDraft])
|
||||||
|
|
||||||
|
const handleSubtitleStyleReset = useCallback(() => {
|
||||||
|
setSubtitleStyleDraft(DEFAULT_SUBTITLE_STYLE)
|
||||||
|
setSubtitleStyle(DEFAULT_SUBTITLE_STYLE)
|
||||||
|
}, [setSubtitleStyle])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +206,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
if (currentView === 'subtitleStyle') {
|
||||||
|
revertSubtitleStyle()
|
||||||
|
}
|
||||||
toggleSettings()
|
toggleSettings()
|
||||||
setCurrentView('main')
|
setCurrentView('main')
|
||||||
}
|
}
|
||||||
@@ -48,14 +216,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [uiState.settingsOpen, toggleSettings])
|
}, [currentView, revertSubtitleStyle, toggleSettings, uiState.settingsOpen])
|
||||||
|
|
||||||
// Reset to main view when menu closes
|
// Reset to main view when menu closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uiState.settingsOpen) {
|
if (!uiState.settingsOpen) {
|
||||||
|
if (currentView === 'subtitleStyle') {
|
||||||
|
revertSubtitleStyle()
|
||||||
|
}
|
||||||
setCurrentView('main')
|
setCurrentView('main')
|
||||||
}
|
}
|
||||||
}, [uiState.settingsOpen])
|
}, [currentView, revertSubtitleStyle, uiState.settingsOpen])
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
setCurrentView('main')
|
setCurrentView('main')
|
||||||
@@ -94,7 +265,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
<div className="sp-settings-main-option-content">
|
<div className="sp-settings-main-option-content">
|
||||||
<span className="sp-settings-main-option-label">{translations.speed}</span>
|
<span className="sp-settings-main-option-label">{translations.speed}</span>
|
||||||
<span className="sp-settings-main-option-value">
|
<span className="sp-settings-main-option-value">
|
||||||
{videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`}
|
{videoState.playbackRate === 1
|
||||||
|
? translations.normal
|
||||||
|
: `${videoState.playbackRate}x`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-settings-main-option-arrow">›</div>
|
<div className="sp-settings-main-option-arrow">›</div>
|
||||||
@@ -140,7 +313,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<h3>{translations.speed}</h3>
|
<h3>{translations.speed}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-sp-settings-options">
|
<div className="sp-settings-options">
|
||||||
{playbackRates.map((rate) => (
|
{playbackRates.map((rate) => (
|
||||||
<button
|
<button
|
||||||
key={rate}
|
key={rate}
|
||||||
@@ -151,7 +324,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{rate === 1 ? translations.normal : `${rate}x`}</span>
|
<span>{rate === 1 ? translations.normal : `${rate}x`}</span>
|
||||||
{videoState.playbackRate === rate && <CheckIcon size={16} color="var(--player-primary)" />}
|
{videoState.playbackRate === rate && (
|
||||||
|
<CheckIcon size={16} color="var(--player-primary)" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +342,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<h3>{translations.subtitles}</h3>
|
<h3>{translations.subtitles}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-sp-settings-options">
|
<div className="sp-settings-options">
|
||||||
<button
|
<button
|
||||||
className={`sp-settings-option ${!settings.subtitle ? 'active' : ''}`}
|
className={`sp-settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -189,7 +364,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{subtitle.label}</span>
|
<span>{subtitle.label}</span>
|
||||||
{settings.subtitle?.lang === subtitle.lang && <CheckIcon size={16} color="var(--player-primary)" />}
|
{settings.subtitle?.lang === subtitle.lang && (
|
||||||
|
<CheckIcon size={16} color="var(--player-primary)" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -197,6 +374,138 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
<span>{translations.noSubtitlesAvailable}</span>
|
<span>{translations.noSubtitlesAvailable}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{subtitleStyleEditorEnabled && (
|
||||||
|
<button className="sp-settings-option" onClick={openSubtitleStyleEditor}>
|
||||||
|
<span>{translations.subtitleStyle}</span>
|
||||||
|
<span className="sp-settings-option-chip">{subtitleStyleSummary.fontSize}px</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentView === 'subtitleStyle' && (
|
||||||
|
<>
|
||||||
|
<div className="sp-settings-menu-header">
|
||||||
|
<button className="sp-settings-back-button" onClick={handleSubtitleStyleCancel}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3>{translations.subtitleStyle}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sp-settings-options sp-settings-style-editor">
|
||||||
|
<div className="sp-settings-style-preview">
|
||||||
|
<span className="sp-settings-style-preview-label">{translations.preview}</span>
|
||||||
|
<div className="sp-settings-style-preview-stage">
|
||||||
|
<div className="sp-settings-style-preview-cue" style={subtitlePreviewStyle}>
|
||||||
|
{STYLE_PREVIEW_TEXT}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="sp-settings-control-row">
|
||||||
|
<span className="sp-settings-control-label">{translations.fontSize}</span>
|
||||||
|
<div className="sp-settings-control-input-group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label={translations.fontSize}
|
||||||
|
min={14}
|
||||||
|
max={48}
|
||||||
|
step={1}
|
||||||
|
value={subtitleStyleDraft.fontSize}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSubtitleStyleDraft({
|
||||||
|
fontSize: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="sp-settings-control-value">{subtitleStyleDraft.fontSize}px</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="sp-settings-control-row">
|
||||||
|
<span className="sp-settings-control-label">{translations.fontWeight}</span>
|
||||||
|
<div className="sp-settings-control-input-group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label={translations.fontWeight}
|
||||||
|
min={300}
|
||||||
|
max={800}
|
||||||
|
step={100}
|
||||||
|
value={subtitleStyleDraft.fontWeight}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSubtitleStyleDraft({
|
||||||
|
fontWeight: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="sp-settings-control-value">{subtitleStyleDraft.fontWeight}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="sp-settings-control-row sp-settings-color-row">
|
||||||
|
<span className="sp-settings-control-label">{translations.textColor}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label={translations.textColor}
|
||||||
|
value={subtitleStyleDraft.color}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSubtitleStyleDraft({
|
||||||
|
color: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="sp-settings-control-row sp-settings-color-row">
|
||||||
|
<span className="sp-settings-control-label">{translations.backgroundColor}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label={translations.backgroundColor}
|
||||||
|
value={subtitleStyleDraft.backgroundColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSubtitleStyleDraft({
|
||||||
|
backgroundColor: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="sp-settings-control-row">
|
||||||
|
<span className="sp-settings-control-label">{translations.backgroundOpacity}</span>
|
||||||
|
<div className="sp-settings-control-input-group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label={translations.backgroundOpacity}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={subtitleStyleDraft.backgroundOpacity}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSubtitleStyleDraft({
|
||||||
|
backgroundOpacity: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="sp-settings-control-value">
|
||||||
|
{Math.round(subtitleStyleDraft.backgroundOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="sp-settings-style-actions">
|
||||||
|
<button className="sp-settings-style-action muted" onClick={handleSubtitleStyleReset}>
|
||||||
|
{translations.reset}
|
||||||
|
</button>
|
||||||
|
<button className="sp-settings-style-action" onClick={handleSubtitleStyleCancel}>
|
||||||
|
{translations.cancel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sp-settings-style-action primary"
|
||||||
|
onClick={handleSubtitleStyleSave}
|
||||||
|
>
|
||||||
|
{translations.save}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -210,7 +519,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<h3>{translations.audioTrack}</h3>
|
<h3>{translations.audioTrack}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-sp-settings-options">
|
<div className="sp-settings-options">
|
||||||
{audioTracks.map((track) => (
|
{audioTracks.map((track) => (
|
||||||
<button
|
<button
|
||||||
key={track.language}
|
key={track.language}
|
||||||
@@ -239,7 +548,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<h3>{translations.quality}</h3>
|
<h3>{translations.quality}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-sp-settings-options">
|
<div className="sp-settings-options">
|
||||||
<button
|
<button
|
||||||
className={`sp-settings-option ${!settings.quality ? 'active' : ''}`}
|
className={`sp-settings-option ${!settings.quality ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -255,7 +564,10 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
if (typeof settings.quality?.levelIndex === 'number') {
|
if (typeof settings.quality?.levelIndex === 'number') {
|
||||||
return settings.quality.levelIndex === quality.levelIndex
|
return settings.quality.levelIndex === quality.levelIndex
|
||||||
}
|
}
|
||||||
if (typeof settings.quality?.height === 'number' && typeof quality.height === 'number') {
|
if (
|
||||||
|
typeof settings.quality?.height === 'number' &&
|
||||||
|
typeof quality.height === 'number'
|
||||||
|
) {
|
||||||
return settings.quality.height === quality.height
|
return settings.quality.height === quality.height
|
||||||
}
|
}
|
||||||
return settings.quality?.label === quality.label
|
return settings.quality?.label === quality.label
|
||||||
|
|||||||
@@ -1,10 +1,99 @@
|
|||||||
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
import React, {
|
||||||
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
import type {
|
||||||
|
PlayerContextValue,
|
||||||
|
VideoState,
|
||||||
|
UIState,
|
||||||
|
PlayerSettings,
|
||||||
|
AudioTrack,
|
||||||
|
SubtitleStyle,
|
||||||
|
} from '../types'
|
||||||
import type { Translations } from '../i18n'
|
import type { Translations } from '../i18n'
|
||||||
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
||||||
|
|
||||||
type SelectedQuality = PlayerSettings['quality']
|
type SelectedQuality = PlayerSettings['quality']
|
||||||
type SelectedSubtitle = PlayerSettings['subtitle']
|
type SelectedSubtitle = PlayerSettings['subtitle']
|
||||||
|
type SelectedSubtitleStyle = PlayerSettings['subtitleStyle']
|
||||||
|
|
||||||
|
const DEFAULT_SUBTITLE_STYLE_STORAGE_KEY = 'source-player-subtitle-style'
|
||||||
|
|
||||||
|
const sanitizeSubtitleStyle = (subtitleStyle?: SubtitleStyle | null): SubtitleStyle => {
|
||||||
|
if (!subtitleStyle || typeof subtitleStyle !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: SubtitleStyle = {}
|
||||||
|
|
||||||
|
if (typeof subtitleStyle.fontFamily === 'string' && subtitleStyle.fontFamily.trim().length > 0) {
|
||||||
|
normalized.fontFamily = subtitleStyle.fontFamily.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof subtitleStyle.fontSize === 'number' &&
|
||||||
|
Number.isFinite(subtitleStyle.fontSize) &&
|
||||||
|
subtitleStyle.fontSize > 0
|
||||||
|
) {
|
||||||
|
normalized.fontSize = subtitleStyle.fontSize
|
||||||
|
} else if (
|
||||||
|
typeof subtitleStyle.fontSize === 'string' &&
|
||||||
|
subtitleStyle.fontSize.trim().length > 0
|
||||||
|
) {
|
||||||
|
normalized.fontSize = subtitleStyle.fontSize.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof subtitleStyle.fontWeight === 'number' &&
|
||||||
|
Number.isFinite(subtitleStyle.fontWeight) &&
|
||||||
|
subtitleStyle.fontWeight > 0
|
||||||
|
) {
|
||||||
|
normalized.fontWeight = subtitleStyle.fontWeight
|
||||||
|
} else if (
|
||||||
|
typeof subtitleStyle.fontWeight === 'string' &&
|
||||||
|
subtitleStyle.fontWeight.trim().length > 0
|
||||||
|
) {
|
||||||
|
normalized.fontWeight = subtitleStyle.fontWeight.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof subtitleStyle.color === 'string' && subtitleStyle.color.trim().length > 0) {
|
||||||
|
normalized.color = subtitleStyle.color.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof subtitleStyle.backgroundColor === 'string' &&
|
||||||
|
subtitleStyle.backgroundColor.trim().length > 0
|
||||||
|
) {
|
||||||
|
normalized.backgroundColor = subtitleStyle.backgroundColor.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof subtitleStyle.backgroundOpacity === 'number' &&
|
||||||
|
Number.isFinite(subtitleStyle.backgroundOpacity)
|
||||||
|
) {
|
||||||
|
normalized.backgroundOpacity = Math.max(0, Math.min(1, subtitleStyle.backgroundOpacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStoredSubtitleStyle = (storageKey: string): SubtitleStyle => {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(storageKey)
|
||||||
|
if (!rawValue) return {}
|
||||||
|
const parsed = JSON.parse(rawValue) as SubtitleStyle
|
||||||
|
return sanitizeSubtitleStyle(parsed)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PlayerContextType extends PlayerContextValue {
|
interface PlayerContextType extends PlayerContextValue {
|
||||||
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
||||||
@@ -28,6 +117,9 @@ interface PlayerProviderProps {
|
|||||||
initialVolume?: number
|
initialVolume?: number
|
||||||
initialMuted?: boolean
|
initialMuted?: boolean
|
||||||
initialPlaybackRate?: number
|
initialPlaybackRate?: number
|
||||||
|
initialSubtitleStyle?: SubtitleStyle
|
||||||
|
subtitleStyleEditorEnabled?: boolean
|
||||||
|
subtitleStyleStorageKey?: string
|
||||||
language?: string
|
language?: string
|
||||||
customTranslations?: Partial<Translations>
|
customTranslations?: Partial<Translations>
|
||||||
}
|
}
|
||||||
@@ -37,11 +129,44 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|||||||
initialVolume = 1,
|
initialVolume = 1,
|
||||||
initialMuted = false,
|
initialMuted = false,
|
||||||
initialPlaybackRate = 1,
|
initialPlaybackRate = 1,
|
||||||
|
initialSubtitleStyle,
|
||||||
|
subtitleStyleEditorEnabled = false,
|
||||||
|
subtitleStyleStorageKey = DEFAULT_SUBTITLE_STYLE_STORAGE_KEY,
|
||||||
language,
|
language,
|
||||||
customTranslations,
|
customTranslations,
|
||||||
}) => {
|
}) => {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const normalizedSubtitleStorageKey =
|
||||||
|
subtitleStyleStorageKey.trim().length > 0
|
||||||
|
? subtitleStyleStorageKey.trim()
|
||||||
|
: DEFAULT_SUBTITLE_STYLE_STORAGE_KEY
|
||||||
|
const initialSubtitleFontFamily = initialSubtitleStyle?.fontFamily
|
||||||
|
const initialSubtitleFontSize = initialSubtitleStyle?.fontSize
|
||||||
|
const initialSubtitleFontWeight = initialSubtitleStyle?.fontWeight
|
||||||
|
const initialSubtitleColor = initialSubtitleStyle?.color
|
||||||
|
const initialSubtitleBackgroundColor = initialSubtitleStyle?.backgroundColor
|
||||||
|
const initialSubtitleBackgroundOpacity = initialSubtitleStyle?.backgroundOpacity
|
||||||
|
|
||||||
|
const normalizedInitialSubtitleStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
sanitizeSubtitleStyle({
|
||||||
|
fontFamily: initialSubtitleFontFamily,
|
||||||
|
fontSize: initialSubtitleFontSize,
|
||||||
|
fontWeight: initialSubtitleFontWeight,
|
||||||
|
color: initialSubtitleColor,
|
||||||
|
backgroundColor: initialSubtitleBackgroundColor,
|
||||||
|
backgroundOpacity: initialSubtitleBackgroundOpacity,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
initialSubtitleBackgroundColor,
|
||||||
|
initialSubtitleBackgroundOpacity,
|
||||||
|
initialSubtitleColor,
|
||||||
|
initialSubtitleFontFamily,
|
||||||
|
initialSubtitleFontSize,
|
||||||
|
initialSubtitleFontWeight,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
// Get translations based on language prop or browser language, merged with custom translations
|
// Get translations based on language prop or browser language, merged with custom translations
|
||||||
const baseTranslations = getTranslations(language || detectBrowserLanguage())
|
const baseTranslations = getTranslations(language || detectBrowserLanguage())
|
||||||
@@ -76,9 +201,39 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|||||||
const [settings, setSettings] = useState<PlayerSettings>({
|
const [settings, setSettings] = useState<PlayerSettings>({
|
||||||
quality: null,
|
quality: null,
|
||||||
subtitle: null,
|
subtitle: null,
|
||||||
|
subtitleStyle: {
|
||||||
|
...normalizedInitialSubtitleStyle,
|
||||||
|
...(subtitleStyleEditorEnabled ? readStoredSubtitleStyle(normalizedSubtitleStorageKey) : {}),
|
||||||
|
},
|
||||||
audioTrack: null,
|
audioTrack: null,
|
||||||
playbackRate: initialPlaybackRate,
|
playbackRate: initialPlaybackRate,
|
||||||
})
|
})
|
||||||
|
const committedSubtitleStyleRef = useRef<SubtitleStyle>(settings.subtitleStyle)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedStyle = subtitleStyleEditorEnabled
|
||||||
|
? readStoredSubtitleStyle(normalizedSubtitleStorageKey)
|
||||||
|
: {}
|
||||||
|
const resolvedStyle = { ...normalizedInitialSubtitleStyle, ...storedStyle }
|
||||||
|
|
||||||
|
committedSubtitleStyleRef.current = resolvedStyle
|
||||||
|
setSettings((prev) => ({ ...prev, subtitleStyle: resolvedStyle }))
|
||||||
|
}, [normalizedInitialSubtitleStyle, normalizedSubtitleStorageKey, subtitleStyleEditorEnabled])
|
||||||
|
|
||||||
|
const persistSubtitleStyle = useCallback(
|
||||||
|
(subtitleStyle: SubtitleStyle) => {
|
||||||
|
if (!subtitleStyleEditorEnabled || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(normalizedSubtitleStorageKey, JSON.stringify(subtitleStyle))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage write errors (private mode, quota, etc.)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizedSubtitleStorageKey, subtitleStyleEditorEnabled]
|
||||||
|
)
|
||||||
|
|
||||||
// Video controls
|
// Video controls
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
@@ -171,6 +326,25 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|||||||
setSettings((prev) => ({ ...prev, subtitle }))
|
setSettings((prev) => ({ ...prev, subtitle }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setSubtitleStyle = useCallback((subtitleStyle: SelectedSubtitleStyle) => {
|
||||||
|
const normalizedStyle = sanitizeSubtitleStyle(subtitleStyle)
|
||||||
|
setSettings((prev) => ({ ...prev, subtitleStyle: normalizedStyle }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveSubtitleStyle = useCallback(
|
||||||
|
(subtitleStyle: SelectedSubtitleStyle) => {
|
||||||
|
const normalizedStyle = sanitizeSubtitleStyle(subtitleStyle)
|
||||||
|
committedSubtitleStyleRef.current = normalizedStyle
|
||||||
|
setSettings((prev) => ({ ...prev, subtitleStyle: normalizedStyle }))
|
||||||
|
persistSubtitleStyle(normalizedStyle)
|
||||||
|
},
|
||||||
|
[persistSubtitleStyle]
|
||||||
|
)
|
||||||
|
|
||||||
|
const revertSubtitleStyle = useCallback(() => {
|
||||||
|
setSettings((prev) => ({ ...prev, subtitleStyle: committedSubtitleStyleRef.current }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const setAudioTrack = useCallback((audioTrack: AudioTrack | null) => {
|
const setAudioTrack = useCallback((audioTrack: AudioTrack | null) => {
|
||||||
setSettings((prev) => ({ ...prev, audioTrack }))
|
setSettings((prev) => ({ ...prev, audioTrack }))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -198,7 +372,11 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|||||||
toggleSettings,
|
toggleSettings,
|
||||||
setQuality,
|
setQuality,
|
||||||
setSubtitle,
|
setSubtitle,
|
||||||
|
setSubtitleStyle,
|
||||||
|
saveSubtitleStyle,
|
||||||
|
revertSubtitleStyle,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
|
subtitleStyleEditorEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||||
|
|||||||
+63
-33
@@ -3,43 +3,63 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
noSubtitlesAvailable: string;
|
noSubtitlesAvailable: string
|
||||||
subtitles: string;
|
subtitles: string
|
||||||
off: string;
|
subtitleStyle: string
|
||||||
auto: string;
|
preview: string
|
||||||
quality: string;
|
save: string
|
||||||
speed: string;
|
cancel: string
|
||||||
normal: string;
|
reset: string
|
||||||
default: string;
|
off: string
|
||||||
audioTrack: string;
|
auto: string
|
||||||
settings: string;
|
quality: string
|
||||||
level: string;
|
speed: string
|
||||||
play: string;
|
fontSize: string
|
||||||
pause: string;
|
fontWeight: string
|
||||||
mute: string;
|
textColor: string
|
||||||
unmute: string;
|
backgroundColor: string
|
||||||
enterFullscreen: string;
|
backgroundOpacity: string
|
||||||
exitFullscreen: string;
|
normal: string
|
||||||
enterPictureInPicture: string;
|
default: string
|
||||||
exitPictureInPicture: string;
|
audioTrack: string
|
||||||
videoProgress: string;
|
settings: string
|
||||||
volume: string;
|
level: string
|
||||||
live: string;
|
play: string
|
||||||
|
pause: string
|
||||||
|
mute: string
|
||||||
|
unmute: string
|
||||||
|
enterFullscreen: string
|
||||||
|
exitFullscreen: string
|
||||||
|
enterPictureInPicture: string
|
||||||
|
exitPictureInPicture: string
|
||||||
|
videoProgress: string
|
||||||
|
volume: string
|
||||||
|
live: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const translations: Record<string, Translations> = {
|
export const translations: Record<string, Translations> = {
|
||||||
en: {
|
en: {
|
||||||
noSubtitlesAvailable: 'No subtitles available',
|
noSubtitlesAvailable: 'No subtitles available',
|
||||||
subtitles: 'Subtitles',
|
subtitles: 'Subtitles',
|
||||||
|
subtitleStyle: 'Subtitle Style',
|
||||||
|
preview: 'Preview',
|
||||||
|
save: 'Save',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
reset: 'Reset',
|
||||||
off: 'Off',
|
off: 'Off',
|
||||||
auto: 'Auto',
|
auto: 'Auto',
|
||||||
quality: 'Quality',
|
quality: 'Quality',
|
||||||
speed: 'Speed',
|
speed: 'Speed',
|
||||||
|
fontSize: 'Font Size',
|
||||||
|
fontWeight: 'Font Weight',
|
||||||
|
textColor: 'Text Color',
|
||||||
|
backgroundColor: 'Background Color',
|
||||||
|
backgroundOpacity: 'Background Opacity',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
audioTrack: 'Audio Track',
|
audioTrack: 'Audio Track',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
level: "Level",
|
level: 'Level',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
mute: 'Mute',
|
mute: 'Mute',
|
||||||
@@ -55,15 +75,25 @@ export const translations: Record<string, Translations> = {
|
|||||||
tr: {
|
tr: {
|
||||||
noSubtitlesAvailable: 'Altyazı mevcut değil',
|
noSubtitlesAvailable: 'Altyazı mevcut değil',
|
||||||
subtitles: 'Altyazı',
|
subtitles: 'Altyazı',
|
||||||
|
subtitleStyle: 'Altyazı Stili',
|
||||||
|
preview: 'Önizleme',
|
||||||
|
save: 'Kaydet',
|
||||||
|
cancel: 'İptal',
|
||||||
|
reset: 'Sıfırla',
|
||||||
off: 'Kapalı',
|
off: 'Kapalı',
|
||||||
auto: 'Otomatik',
|
auto: 'Otomatik',
|
||||||
quality: 'Kalite',
|
quality: 'Kalite',
|
||||||
speed: 'Hız',
|
speed: 'Hız',
|
||||||
|
fontSize: 'Yazı Boyutu',
|
||||||
|
fontWeight: 'Yazı Kalınlığı',
|
||||||
|
textColor: 'Yazı Rengi',
|
||||||
|
backgroundColor: 'Arka Plan Rengi',
|
||||||
|
backgroundOpacity: 'Arka Plan Opaklığı',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
default: 'Varsayılan',
|
default: 'Varsayılan',
|
||||||
audioTrack: 'Ses',
|
audioTrack: 'Ses',
|
||||||
settings: 'Ayarlar',
|
settings: 'Ayarlar',
|
||||||
level: "Seviye",
|
level: 'Seviye',
|
||||||
play: 'Oynat',
|
play: 'Oynat',
|
||||||
pause: 'Duraklat',
|
pause: 'Duraklat',
|
||||||
mute: 'Sesi kapat',
|
mute: 'Sesi kapat',
|
||||||
@@ -76,27 +106,27 @@ export const translations: Record<string, Translations> = {
|
|||||||
volume: 'Ses',
|
volume: 'Ses',
|
||||||
live: 'CANLI',
|
live: 'CANLI',
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getTranslations = (language: string = 'en'): Translations => {
|
export const getTranslations = (language: string = 'en'): Translations => {
|
||||||
// Try exact match first
|
// Try exact match first
|
||||||
if (translations[language]) {
|
if (translations[language]) {
|
||||||
return translations[language];
|
return translations[language]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try language code without region (e.g., "en" from "en-US")
|
// Try language code without region (e.g., "en" from "en-US")
|
||||||
const languageCode = language.split('-')[0];
|
const languageCode = language.split('-')[0]
|
||||||
if (translations[languageCode]) {
|
if (translations[languageCode]) {
|
||||||
return translations[languageCode];
|
return translations[languageCode]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to English
|
// Default to English
|
||||||
return translations.en;
|
return translations.en
|
||||||
};
|
}
|
||||||
|
|
||||||
export const detectBrowserLanguage = (): string => {
|
export const detectBrowserLanguage = (): string => {
|
||||||
if (typeof navigator !== 'undefined') {
|
if (typeof navigator !== 'undefined') {
|
||||||
return navigator.language || 'en';
|
return navigator.language || 'en'
|
||||||
|
}
|
||||||
|
return 'en'
|
||||||
}
|
}
|
||||||
return 'en';
|
|
||||||
};
|
|
||||||
|
|||||||
+7
-1
@@ -15,6 +15,7 @@ export type {
|
|||||||
VideoPlayerHandle,
|
VideoPlayerHandle,
|
||||||
SubtitleTrack,
|
SubtitleTrack,
|
||||||
SubtitleStyle,
|
SubtitleStyle,
|
||||||
|
SubtitleStyleEditorConfig,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
VideoQuality,
|
VideoQuality,
|
||||||
@@ -30,7 +31,12 @@ export type {
|
|||||||
// Utils
|
// Utils
|
||||||
export { formatTime, parseTime } from './utils/time'
|
export { formatTime, parseTime } from './utils/time'
|
||||||
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
||||||
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
export {
|
||||||
|
validateVideoURL,
|
||||||
|
getCORSErrorMessage,
|
||||||
|
isCORSError,
|
||||||
|
checkVideoCORS,
|
||||||
|
} from './utils/corsHelper'
|
||||||
export { initializePolyfills, features } from './utils/polyfills'
|
export { initializePolyfills, features } from './utils/polyfills'
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export interface SubtitleStyle {
|
|||||||
backgroundOpacity?: number
|
backgroundOpacity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubtitleStyleEditorConfig {
|
||||||
|
/** Altyazi stil editorunu etkinlestirir */
|
||||||
|
enabled?: boolean
|
||||||
|
/** localStorage anahtari */
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioTrack {
|
export interface AudioTrack {
|
||||||
name: string
|
name: string
|
||||||
language: string
|
language: string
|
||||||
@@ -105,6 +112,7 @@ export interface VideoPlayerProps {
|
|||||||
controls?: boolean
|
controls?: boolean
|
||||||
subtitles?: SubtitleTrack[]
|
subtitles?: SubtitleTrack[]
|
||||||
subtitleStyle?: SubtitleStyle
|
subtitleStyle?: SubtitleStyle
|
||||||
|
subtitleStyleEditor?: boolean | SubtitleStyleEditorConfig
|
||||||
subtitlePosition?: SubtitlePosition
|
subtitlePosition?: SubtitlePosition
|
||||||
subtitleOffset?: number | string
|
subtitleOffset?: number | string
|
||||||
theme?: PlayerTheme
|
theme?: PlayerTheme
|
||||||
@@ -188,6 +196,7 @@ export interface UIState {
|
|||||||
export interface PlayerSettings {
|
export interface PlayerSettings {
|
||||||
quality: VideoQuality | null
|
quality: VideoQuality | null
|
||||||
subtitle: SubtitleTrack | null
|
subtitle: SubtitleTrack | null
|
||||||
|
subtitleStyle: SubtitleStyle
|
||||||
audioTrack: AudioTrack | null
|
audioTrack: AudioTrack | null
|
||||||
playbackRate: number
|
playbackRate: number
|
||||||
}
|
}
|
||||||
@@ -220,7 +229,11 @@ export interface PlayerContextValue {
|
|||||||
// Settings
|
// Settings
|
||||||
setQuality: (quality: VideoQuality | null) => void
|
setQuality: (quality: VideoQuality | null) => void
|
||||||
setSubtitle: (subtitle: SubtitleTrack | null) => void
|
setSubtitle: (subtitle: SubtitleTrack | null) => void
|
||||||
|
setSubtitleStyle: (subtitleStyle: SubtitleStyle) => void
|
||||||
|
saveSubtitleStyle: (subtitleStyle: SubtitleStyle) => void
|
||||||
|
revertSubtitleStyle: () => void
|
||||||
setAudioTrack: (audioTrack: AudioTrack | null) => void
|
setAudioTrack: (audioTrack: AudioTrack | null) => void
|
||||||
|
subtitleStyleEditorEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GestureType = 'tap' | 'doubleTap' | 'swipe'
|
export type GestureType = 'tap' | 'doubleTap' | 'swipe'
|
||||||
|
|||||||
Reference in New Issue
Block a user