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]
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -12,7 +12,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
||||
## 🏆 Why Choose This Player?
|
||||
|
||||
| Feature | @source/player | video.js | react-player | plyr |
|
||||
|---------|---------------------|----------|--------------|------|
|
||||
| ------------------------- | ---------------------------- | ---------- | ------------ | ---------- |
|
||||
| **Bundle Size (gzipped)** | **~18KB JS + ~3.5KB CSS** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ |
|
||||
| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ |
|
||||
| **React (Web)** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
|
||||
@@ -37,6 +37,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
||||
## ✨ Features
|
||||
|
||||
### 🎮 Core Playback
|
||||
|
||||
- ▶️ Play/Pause controls
|
||||
- ⏭️ Seek/scrub with progress bar
|
||||
- 🔊 Volume control with slider
|
||||
@@ -45,6 +46,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
||||
- 🖼️ Custom poster/thumbnail
|
||||
|
||||
### 🎨 Modern UI
|
||||
|
||||
- Clean, minimalist design with red theme
|
||||
- Smooth animations and transitions
|
||||
- Auto-hiding controls
|
||||
@@ -54,6 +56,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
|
||||
- Center play button
|
||||
|
||||
### ⌨️ Keyboard Shortcuts
|
||||
|
||||
- `Space` or `K` - Play/Pause
|
||||
- `←` / `→` - Seek 5 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
|
||||
|
||||
### 📱 Touch Gestures
|
||||
|
||||
- **Tap** - Play/Pause
|
||||
- **Double tap left** - Rewind 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
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
- **HLS Streaming** - Automatic HLS.js integration for .m3u8 files
|
||||
- **IPTV Support** - MPEG-TS (.ts) streams for IPTV services
|
||||
- **HTTP Range Request** - Progressive download for large MP4 files
|
||||
- **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
|
||||
- **Picture-in-Picture** - Native browser PIP support
|
||||
- **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.
|
||||
|
||||
> **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
|
||||
> npm install hls.js # HLS (.m3u8) streams
|
||||
> npm install flv.js # FLV/RTMP streams
|
||||
@@ -139,12 +146,7 @@ import { VideoPlayer } from '@source/player'
|
||||
import '@source/player/styles.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<VideoPlayer
|
||||
src="https://example.com/video.mp4"
|
||||
poster="https://example.com/poster.jpg"
|
||||
/>
|
||||
)
|
||||
return <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
|
||||
<VideoPlayer
|
||||
src="https://example.com/stream/playlist.m3u8"
|
||||
autoplay={false}
|
||||
src="https://example.com/video.mp4"
|
||||
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)
|
||||
|
||||
```tsx
|
||||
<VideoPlayer
|
||||
src="https://cdn.example.com/video"
|
||||
protocol="hls"
|
||||
/>
|
||||
<VideoPlayer src="https://cdn.example.com/video" protocol="hls" />
|
||||
```
|
||||
|
||||
### IPTV Streaming
|
||||
@@ -359,7 +371,7 @@ video-player/
|
||||
#### Basic Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| --------------------- | ------------------------------------------------------------- | ------------ | -------------------------------------------------------------- |
|
||||
| `src` | `string` | **required** | Video source URL (MP4, WebM, HLS, IPTV .ts) |
|
||||
| `poster` | `string` | - | Poster image URL |
|
||||
| `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 |
|
||||
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
||||
| `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 |
|
||||
| `subtitleOffset` | `number \| string` | - | Subtitle offset (`px` if number) |
|
||||
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
||||
@@ -388,7 +401,7 @@ video-player/
|
||||
#### Event Handlers
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| -------------------------- | --------------------------------- | ------------------------------------------- |
|
||||
| `onPlay` | `() => void` | Fired when playback starts |
|
||||
| `onPause` | `() => void` | Fired when playback pauses |
|
||||
| `onEnded` | `() => void` | Fired when playback ends |
|
||||
@@ -409,7 +422,7 @@ video-player/
|
||||
### PlayerErrorBoundary Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| ----------- | ------------------------------------------------------------- | -------------------------------------- |
|
||||
| `children` | `ReactNode` | Wrapped player/content tree |
|
||||
| `fallback` | `ReactNode \| (error: Error, retry: () => void) => ReactNode` | Optional custom fallback UI |
|
||||
| `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
|
||||
|
||||
```typescript
|
||||
@@ -457,6 +477,7 @@ interface PlayerTheme {
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Native Browser APIs Used
|
||||
|
||||
- HTML5 Video API
|
||||
- Fullscreen API
|
||||
- Picture-in-Picture API
|
||||
@@ -468,18 +489,21 @@ interface PlayerTheme {
|
||||
### Streaming Support
|
||||
|
||||
**MP4/WebM (Progressive Download)**
|
||||
|
||||
- Uses HTTP Range Requests
|
||||
- Browser automatically chunks the download
|
||||
- No additional library needed
|
||||
- Works with any standard web server that supports Range headers
|
||||
|
||||
**HLS (.m3u8)**
|
||||
|
||||
- Automatically detects HLS sources
|
||||
- Lazy loads hls.js library when needed
|
||||
- Safari has native HLS support (no library needed)
|
||||
- Adaptive bitrate streaming
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- Lazy loading of HLS.js with CDN fallback
|
||||
- CSS-only animations
|
||||
- Debounced control hiding
|
||||
@@ -489,6 +513,7 @@ interface PlayerTheme {
|
||||
- Polyfills for older browser support
|
||||
|
||||
### Error Handling & Reliability
|
||||
|
||||
- **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
|
||||
- **Memory Management**: Proper cleanup of HLS instances to prevent memory leaks
|
||||
|
||||
+20
-2
@@ -5,8 +5,9 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #0f172a;
|
||||
@@ -70,6 +71,9 @@ body {
|
||||
.controls-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
@@ -122,6 +126,20 @@ body {
|
||||
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 {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
+46
-12
@@ -6,6 +6,7 @@ import './App.css'
|
||||
function App() {
|
||||
const [videoUrl, setVideoUrl] = useState('')
|
||||
const [useDemo, setUseDemo] = useState(true)
|
||||
const [subtitleStyleEditorEnabled, setSubtitleStyleEditorEnabled] = useState(true)
|
||||
|
||||
// Demo video URLs (you can replace with your own)
|
||||
const demoVideoUrl = '/player/ses.mp4'
|
||||
@@ -37,6 +38,17 @@ function App() {
|
||||
src={currentVideoUrl}
|
||||
poster={useDemo ? demoPoster : undefined}
|
||||
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}
|
||||
pictureInPicture={true}
|
||||
theme={{
|
||||
@@ -63,13 +75,18 @@ function App() {
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={useDemo}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setUseDemo(!useDemo)}
|
||||
className={useDemo ? 'active' : ''}
|
||||
>
|
||||
<button onClick={() => setUseDemo(!useDemo)} className={useDemo ? 'active' : ''}>
|
||||
{useDemo ? 'Using Demo' : 'Use Demo'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -79,14 +96,30 @@ function App() {
|
||||
<div className="feature">
|
||||
<h3>⌨️ Keyboard Shortcuts</h3>
|
||||
<ul>
|
||||
<li><kbd>Space</kbd> or <kbd>K</kbd> - Play/Pause</li>
|
||||
<li><kbd>←</kbd> / <kbd>→</kbd> - Seek 5s</li>
|
||||
<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>
|
||||
<li>
|
||||
<kbd>Space</kbd> or <kbd>K</kbd> - Play/Pause
|
||||
</li>
|
||||
<li>
|
||||
<kbd>←</kbd> / <kbd>→</kbd> - Seek 5s
|
||||
</li>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +151,7 @@ function App() {
|
||||
<li>HLS streaming support</li>
|
||||
<li>HTTP Range Request (MP4)</li>
|
||||
<li>Subtitles (VTT, SRT)</li>
|
||||
<li>Subtitle style editor + live preview</li>
|
||||
<li>Multiple audio tracks</li>
|
||||
<li>Playback speed control</li>
|
||||
<li>Quality selector</li>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@source/player",
|
||||
"version": "3.0.1",
|
||||
"version": "3.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@source/player",
|
||||
"version": "3.0.1",
|
||||
"version": "3.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@source/player",
|
||||
"version": "3.0.1",
|
||||
"version": "3.1.0",
|
||||
"description": "Modern, feature-rich video player library for React",
|
||||
"type": "module",
|
||||
"main": "./dist/video-player.umd.cjs",
|
||||
|
||||
@@ -62,7 +62,10 @@ interface VideoElementProps {
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -179,7 +182,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
onQualityLevelsLoaded,
|
||||
onSubtitleTracksLoaded,
|
||||
}) => {
|
||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } =
|
||||
usePlayerContext()
|
||||
const lastClickTimeRef = React.useRef<number>(0)
|
||||
const hasPlayedRef = React.useRef(false)
|
||||
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||
@@ -190,39 +194,49 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
||||
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 style: React.CSSProperties = {}
|
||||
|
||||
if (subtitleStyle?.fontFamily) {
|
||||
style.fontFamily = subtitleStyle.fontFamily
|
||||
if (effectiveSubtitleStyle.fontFamily) {
|
||||
style.fontFamily = effectiveSubtitleStyle.fontFamily
|
||||
}
|
||||
if (subtitleStyle?.fontSize !== undefined) {
|
||||
if (effectiveSubtitleStyle.fontSize !== undefined) {
|
||||
style.fontSize =
|
||||
typeof subtitleStyle.fontSize === 'number' ? `${subtitleStyle.fontSize}px` : subtitleStyle.fontSize
|
||||
typeof effectiveSubtitleStyle.fontSize === 'number'
|
||||
? `${effectiveSubtitleStyle.fontSize}px`
|
||||
: effectiveSubtitleStyle.fontSize
|
||||
}
|
||||
if (subtitleStyle?.fontWeight !== undefined) {
|
||||
style.fontWeight = subtitleStyle.fontWeight
|
||||
if (effectiveSubtitleStyle.fontWeight !== undefined) {
|
||||
style.fontWeight = effectiveSubtitleStyle.fontWeight
|
||||
}
|
||||
if (subtitleStyle?.color) {
|
||||
style.color = subtitleStyle.color
|
||||
if (effectiveSubtitleStyle.color) {
|
||||
style.color = effectiveSubtitleStyle.color
|
||||
}
|
||||
|
||||
const hasBackgroundColor = typeof subtitleStyle?.backgroundColor === 'string'
|
||||
const hasBackgroundOpacity = typeof subtitleStyle?.backgroundOpacity === 'number'
|
||||
const hasBackgroundColor = typeof effectiveSubtitleStyle.backgroundColor === 'string'
|
||||
const hasBackgroundOpacity = typeof effectiveSubtitleStyle.backgroundOpacity === 'number'
|
||||
|
||||
if (hasBackgroundColor && hasBackgroundOpacity) {
|
||||
style.backgroundColor = toRgbaWithOpacity(
|
||||
subtitleStyle.backgroundColor as string,
|
||||
subtitleStyle.backgroundOpacity as number
|
||||
effectiveSubtitleStyle.backgroundColor as string,
|
||||
effectiveSubtitleStyle.backgroundOpacity as number
|
||||
)
|
||||
} else if (hasBackgroundColor) {
|
||||
style.backgroundColor = subtitleStyle?.backgroundColor
|
||||
style.backgroundColor = effectiveSubtitleStyle.backgroundColor
|
||||
} 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
|
||||
}, [subtitleStyle])
|
||||
}, [effectiveSubtitleStyle])
|
||||
|
||||
const subtitleOverlayStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
const style: React.CSSProperties = {}
|
||||
@@ -302,7 +316,14 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
}
|
||||
|
||||
onLoadedMetadata?.()
|
||||
}, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle, settings.subtitle])
|
||||
}, [
|
||||
videoRef,
|
||||
setVideoState,
|
||||
onLoadedMetadata,
|
||||
processedSubtitles,
|
||||
setSubtitle,
|
||||
settings.subtitle,
|
||||
])
|
||||
|
||||
const handleDurationChange = useCallback(() => {
|
||||
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
|
||||
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) => ({
|
||||
...prev,
|
||||
@@ -506,7 +532,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
// Fetch and convert SRT to VTT
|
||||
const response = await fetch(subtitle.src)
|
||||
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()
|
||||
|
||||
@@ -737,7 +765,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
const corsMessage = getCORSErrorMessage(src)
|
||||
error = new Error(corsMessage)
|
||||
} 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
|
||||
@@ -848,10 +879,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
return quality.levelIndex === settings.quality.levelIndex
|
||||
}
|
||||
|
||||
if (
|
||||
typeof settings.quality?.height === 'number' &&
|
||||
typeof quality.height === 'number'
|
||||
) {
|
||||
if (typeof settings.quality?.height === 'number' && typeof quality.height === 'number') {
|
||||
return quality.height === settings.quality.height
|
||||
}
|
||||
|
||||
@@ -1027,4 +1055,3 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
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 '../styles/variables.css'
|
||||
import './VideoPlayer.css'
|
||||
@@ -40,6 +46,24 @@ const parseAspectRatio = (ratio: VideoPlayerProps['aspectRatio']): string => {
|
||||
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 {
|
||||
audioTracks: AudioTrack[]
|
||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||
@@ -141,7 +165,18 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
||||
togglePictureInPicture,
|
||||
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' : ''
|
||||
@@ -265,7 +300,15 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -295,6 +338,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
controls = true,
|
||||
subtitles = [],
|
||||
subtitleStyle,
|
||||
subtitleStyleEditor,
|
||||
subtitlePosition,
|
||||
subtitleOffset,
|
||||
theme,
|
||||
@@ -338,6 +382,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||
const subtitleStyleEditorConfig = useMemo(
|
||||
() => resolveSubtitleStyleEditorConfig(subtitleStyleEditor),
|
||||
[subtitleStyleEditor]
|
||||
)
|
||||
|
||||
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
||||
setAudioTracks(tracks)
|
||||
@@ -358,7 +406,16 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
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
|
||||
ref={ref}
|
||||
src={src}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
min-width: 260px;
|
||||
max-height: 360px;
|
||||
min-width: 280px;
|
||||
max-height: 420px;
|
||||
background: var(--player-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--player-radius);
|
||||
@@ -40,7 +40,8 @@
|
||||
justify-content: center;
|
||||
border-radius: var(--player-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--player-transition-fast) ease,
|
||||
transition:
|
||||
color var(--player-transition-fast) ease,
|
||||
background-color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
@@ -65,7 +66,8 @@
|
||||
text-align: left;
|
||||
color: var(--player-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--player-transition-fast) ease,
|
||||
transition:
|
||||
background-color var(--player-transition-fast) ease,
|
||||
color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
@@ -108,7 +110,7 @@
|
||||
.sp-settings-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 280px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -124,7 +126,8 @@
|
||||
color: var(--player-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--player-transition-fast) ease,
|
||||
transition:
|
||||
background-color var(--player-transition-fast) ease,
|
||||
color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
@@ -137,10 +140,160 @@
|
||||
background-color: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.sp-settings-option span {
|
||||
.sp-settings-option > span:first-child {
|
||||
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 {
|
||||
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||
text-align: center;
|
||||
@@ -168,7 +321,7 @@
|
||||
@media (max-width: 640px) {
|
||||
.sp-settings-menu {
|
||||
min-width: 240px;
|
||||
max-height: 320px;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.sp-settings-main-option,
|
||||
@@ -177,6 +330,19 @@
|
||||
}
|
||||
|
||||
.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: {
|
||||
quality: null,
|
||||
subtitle: null,
|
||||
subtitleStyle: {},
|
||||
audioTrack: null,
|
||||
playbackRate: 1,
|
||||
},
|
||||
setPlaybackRate: vi.fn(),
|
||||
setSubtitle: vi.fn(),
|
||||
setSubtitleStyle: vi.fn(),
|
||||
saveSubtitleStyle: vi.fn(),
|
||||
revertSubtitleStyle: vi.fn(),
|
||||
setAudioTrack: vi.fn(),
|
||||
setQuality: vi.fn(),
|
||||
toggleSettings: vi.fn(),
|
||||
subtitleStyleEditorEnabled: true,
|
||||
translations: {
|
||||
noSubtitlesAvailable: 'No subtitles available',
|
||||
subtitles: 'Subtitles',
|
||||
subtitleStyle: 'Subtitle Style',
|
||||
preview: 'Preview',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
reset: 'Reset',
|
||||
off: 'Off',
|
||||
auto: 'Auto',
|
||||
quality: 'Quality',
|
||||
speed: 'Speed',
|
||||
fontSize: 'Font Size',
|
||||
fontWeight: 'Font Weight',
|
||||
textColor: 'Text Color',
|
||||
backgroundColor: 'Background Color',
|
||||
backgroundOpacity: 'Background Opacity',
|
||||
normal: 'Normal',
|
||||
default: 'Default',
|
||||
audioTrack: 'Audio Track',
|
||||
@@ -130,4 +145,19 @@ describe('SettingsMenu', () => {
|
||||
fireEvent.click(screen.getByText('720p'))
|
||||
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 { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon, QualityIcon } from '../../icons'
|
||||
import type { AudioTrack, VideoQuality } from '../../types'
|
||||
import type { AudioTrack, SubtitleStyle, VideoQuality } from '../../types'
|
||||
import './SettingsMenu.css'
|
||||
|
||||
interface SettingsMenuProps {
|
||||
@@ -11,7 +11,106 @@ interface SettingsMenuProps {
|
||||
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> = ({
|
||||
subtitles = [],
|
||||
@@ -25,15 +124,81 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
settings,
|
||||
setPlaybackRate,
|
||||
setSubtitle,
|
||||
setSubtitleStyle,
|
||||
saveSubtitleStyle,
|
||||
revertSubtitleStyle,
|
||||
setAudioTrack,
|
||||
setQuality,
|
||||
toggleSettings,
|
||||
subtitleStyleEditorEnabled,
|
||||
translations,
|
||||
} = usePlayerContext()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
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 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
|
||||
useEffect(() => {
|
||||
@@ -41,6 +206,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
if (currentView === 'subtitleStyle') {
|
||||
revertSubtitleStyle()
|
||||
}
|
||||
toggleSettings()
|
||||
setCurrentView('main')
|
||||
}
|
||||
@@ -48,14 +216,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [uiState.settingsOpen, toggleSettings])
|
||||
}, [currentView, revertSubtitleStyle, toggleSettings, uiState.settingsOpen])
|
||||
|
||||
// Reset to main view when menu closes
|
||||
useEffect(() => {
|
||||
if (!uiState.settingsOpen) {
|
||||
if (currentView === 'subtitleStyle') {
|
||||
revertSubtitleStyle()
|
||||
}
|
||||
setCurrentView('main')
|
||||
}
|
||||
}, [uiState.settingsOpen])
|
||||
}, [currentView, revertSubtitleStyle, uiState.settingsOpen])
|
||||
|
||||
const goBack = () => {
|
||||
setCurrentView('main')
|
||||
@@ -94,7 +265,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<div className="sp-settings-main-option-content">
|
||||
<span className="sp-settings-main-option-label">{translations.speed}</span>
|
||||
<span className="sp-settings-main-option-value">
|
||||
{videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`}
|
||||
{videoState.playbackRate === 1
|
||||
? translations.normal
|
||||
: `${videoState.playbackRate}x`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sp-settings-main-option-arrow">›</div>
|
||||
@@ -140,7 +313,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
</button>
|
||||
<h3>{translations.speed}</h3>
|
||||
</div>
|
||||
<div className="sp-sp-settings-options">
|
||||
<div className="sp-settings-options">
|
||||
{playbackRates.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
@@ -151,7 +324,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -167,7 +342,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
</button>
|
||||
<h3>{translations.subtitles}</h3>
|
||||
</div>
|
||||
<div className="sp-sp-settings-options">
|
||||
<div className="sp-settings-options">
|
||||
<button
|
||||
className={`sp-settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
@@ -189,7 +364,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
@@ -197,6 +374,138 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<span>{translations.noSubtitlesAvailable}</span>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -210,7 +519,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
</button>
|
||||
<h3>{translations.audioTrack}</h3>
|
||||
</div>
|
||||
<div className="sp-sp-settings-options">
|
||||
<div className="sp-settings-options">
|
||||
{audioTracks.map((track) => (
|
||||
<button
|
||||
key={track.language}
|
||||
@@ -239,7 +548,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
</button>
|
||||
<h3>{translations.quality}</h3>
|
||||
</div>
|
||||
<div className="sp-sp-settings-options">
|
||||
<div className="sp-settings-options">
|
||||
<button
|
||||
className={`sp-settings-option ${!settings.quality ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
@@ -255,7 +564,10 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
if (typeof settings.quality?.levelIndex === 'number') {
|
||||
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?.label === quality.label
|
||||
|
||||
@@ -1,10 +1,99 @@
|
||||
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
||||
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
||||
import React, {
|
||||
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 { getTranslations, detectBrowserLanguage } from '../i18n'
|
||||
|
||||
type SelectedQuality = PlayerSettings['quality']
|
||||
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 {
|
||||
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
||||
@@ -28,6 +117,9 @@ interface PlayerProviderProps {
|
||||
initialVolume?: number
|
||||
initialMuted?: boolean
|
||||
initialPlaybackRate?: number
|
||||
initialSubtitleStyle?: SubtitleStyle
|
||||
subtitleStyleEditorEnabled?: boolean
|
||||
subtitleStyleStorageKey?: string
|
||||
language?: string
|
||||
customTranslations?: Partial<Translations>
|
||||
}
|
||||
@@ -37,11 +129,44 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
initialVolume = 1,
|
||||
initialMuted = false,
|
||||
initialPlaybackRate = 1,
|
||||
initialSubtitleStyle,
|
||||
subtitleStyleEditorEnabled = false,
|
||||
subtitleStyleStorageKey = DEFAULT_SUBTITLE_STYLE_STORAGE_KEY,
|
||||
language,
|
||||
customTranslations,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement | 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
|
||||
const baseTranslations = getTranslations(language || detectBrowserLanguage())
|
||||
@@ -76,9 +201,39 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
const [settings, setSettings] = useState<PlayerSettings>({
|
||||
quality: null,
|
||||
subtitle: null,
|
||||
subtitleStyle: {
|
||||
...normalizedInitialSubtitleStyle,
|
||||
...(subtitleStyleEditorEnabled ? readStoredSubtitleStyle(normalizedSubtitleStorageKey) : {}),
|
||||
},
|
||||
audioTrack: null,
|
||||
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
|
||||
const play = useCallback(() => {
|
||||
@@ -171,6 +326,25 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
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) => {
|
||||
setSettings((prev) => ({ ...prev, audioTrack }))
|
||||
}, [])
|
||||
@@ -198,7 +372,11 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
toggleSettings,
|
||||
setQuality,
|
||||
setSubtitle,
|
||||
setSubtitleStyle,
|
||||
saveSubtitleStyle,
|
||||
revertSubtitleStyle,
|
||||
setAudioTrack,
|
||||
subtitleStyleEditorEnabled,
|
||||
}
|
||||
|
||||
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||
|
||||
+63
-33
@@ -3,43 +3,63 @@
|
||||
*/
|
||||
|
||||
export interface Translations {
|
||||
noSubtitlesAvailable: string;
|
||||
subtitles: string;
|
||||
off: string;
|
||||
auto: string;
|
||||
quality: string;
|
||||
speed: string;
|
||||
normal: string;
|
||||
default: string;
|
||||
audioTrack: string;
|
||||
settings: string;
|
||||
level: string;
|
||||
play: string;
|
||||
pause: string;
|
||||
mute: string;
|
||||
unmute: string;
|
||||
enterFullscreen: string;
|
||||
exitFullscreen: string;
|
||||
enterPictureInPicture: string;
|
||||
exitPictureInPicture: string;
|
||||
videoProgress: string;
|
||||
volume: string;
|
||||
live: string;
|
||||
noSubtitlesAvailable: string
|
||||
subtitles: string
|
||||
subtitleStyle: string
|
||||
preview: string
|
||||
save: string
|
||||
cancel: string
|
||||
reset: string
|
||||
off: string
|
||||
auto: string
|
||||
quality: string
|
||||
speed: string
|
||||
fontSize: string
|
||||
fontWeight: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
backgroundOpacity: string
|
||||
normal: string
|
||||
default: string
|
||||
audioTrack: string
|
||||
settings: string
|
||||
level: 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> = {
|
||||
en: {
|
||||
noSubtitlesAvailable: 'No subtitles available',
|
||||
subtitles: 'Subtitles',
|
||||
subtitleStyle: 'Subtitle Style',
|
||||
preview: 'Preview',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
reset: 'Reset',
|
||||
off: 'Off',
|
||||
auto: 'Auto',
|
||||
quality: 'Quality',
|
||||
speed: 'Speed',
|
||||
fontSize: 'Font Size',
|
||||
fontWeight: 'Font Weight',
|
||||
textColor: 'Text Color',
|
||||
backgroundColor: 'Background Color',
|
||||
backgroundOpacity: 'Background Opacity',
|
||||
normal: 'Normal',
|
||||
default: 'Default',
|
||||
audioTrack: 'Audio Track',
|
||||
settings: 'Settings',
|
||||
level: "Level",
|
||||
level: 'Level',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
mute: 'Mute',
|
||||
@@ -55,15 +75,25 @@ export const translations: Record<string, Translations> = {
|
||||
tr: {
|
||||
noSubtitlesAvailable: 'Altyazı mevcut değil',
|
||||
subtitles: 'Altyazı',
|
||||
subtitleStyle: 'Altyazı Stili',
|
||||
preview: 'Önizleme',
|
||||
save: 'Kaydet',
|
||||
cancel: 'İptal',
|
||||
reset: 'Sıfırla',
|
||||
off: 'Kapalı',
|
||||
auto: 'Otomatik',
|
||||
quality: 'Kalite',
|
||||
speed: 'Hız',
|
||||
fontSize: 'Yazı Boyutu',
|
||||
fontWeight: 'Yazı Kalınlığı',
|
||||
textColor: 'Yazı Rengi',
|
||||
backgroundColor: 'Arka Plan Rengi',
|
||||
backgroundOpacity: 'Arka Plan Opaklığı',
|
||||
normal: 'Normal',
|
||||
default: 'Varsayılan',
|
||||
audioTrack: 'Ses',
|
||||
settings: 'Ayarlar',
|
||||
level: "Seviye",
|
||||
level: 'Seviye',
|
||||
play: 'Oynat',
|
||||
pause: 'Duraklat',
|
||||
mute: 'Sesi kapat',
|
||||
@@ -76,27 +106,27 @@ export const translations: Record<string, Translations> = {
|
||||
volume: 'Ses',
|
||||
live: 'CANLI',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getTranslations = (language: string = 'en'): Translations => {
|
||||
// Try exact match first
|
||||
if (translations[language]) {
|
||||
return translations[language];
|
||||
return translations[language]
|
||||
}
|
||||
|
||||
// Try language code without region (e.g., "en" from "en-US")
|
||||
const languageCode = language.split('-')[0];
|
||||
const languageCode = language.split('-')[0]
|
||||
if (translations[languageCode]) {
|
||||
return translations[languageCode];
|
||||
return translations[languageCode]
|
||||
}
|
||||
|
||||
// Default to English
|
||||
return translations.en;
|
||||
};
|
||||
return translations.en
|
||||
}
|
||||
|
||||
export const detectBrowserLanguage = (): string => {
|
||||
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,
|
||||
SubtitleTrack,
|
||||
SubtitleStyle,
|
||||
SubtitleStyleEditorConfig,
|
||||
SubtitlePosition,
|
||||
AudioTrack,
|
||||
VideoQuality,
|
||||
@@ -30,7 +31,12 @@ export type {
|
||||
// Utils
|
||||
export { formatTime, parseTime } from './utils/time'
|
||||
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'
|
||||
|
||||
// i18n
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface SubtitleStyle {
|
||||
backgroundOpacity?: number
|
||||
}
|
||||
|
||||
export interface SubtitleStyleEditorConfig {
|
||||
/** Altyazi stil editorunu etkinlestirir */
|
||||
enabled?: boolean
|
||||
/** localStorage anahtari */
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
name: string
|
||||
language: string
|
||||
@@ -105,6 +112,7 @@ export interface VideoPlayerProps {
|
||||
controls?: boolean
|
||||
subtitles?: SubtitleTrack[]
|
||||
subtitleStyle?: SubtitleStyle
|
||||
subtitleStyleEditor?: boolean | SubtitleStyleEditorConfig
|
||||
subtitlePosition?: SubtitlePosition
|
||||
subtitleOffset?: number | string
|
||||
theme?: PlayerTheme
|
||||
@@ -188,6 +196,7 @@ export interface UIState {
|
||||
export interface PlayerSettings {
|
||||
quality: VideoQuality | null
|
||||
subtitle: SubtitleTrack | null
|
||||
subtitleStyle: SubtitleStyle
|
||||
audioTrack: AudioTrack | null
|
||||
playbackRate: number
|
||||
}
|
||||
@@ -220,7 +229,11 @@ export interface PlayerContextValue {
|
||||
// Settings
|
||||
setQuality: (quality: VideoQuality | null) => void
|
||||
setSubtitle: (subtitle: SubtitleTrack | null) => void
|
||||
setSubtitleStyle: (subtitleStyle: SubtitleStyle) => void
|
||||
saveSubtitleStyle: (subtitleStyle: SubtitleStyle) => void
|
||||
revertSubtitleStyle: () => void
|
||||
setAudioTrack: (audioTrack: AudioTrack | null) => void
|
||||
subtitleStyleEditorEnabled: boolean
|
||||
}
|
||||
|
||||
export type GestureType = 'tap' | 'doubleTap' | 'swipe'
|
||||
|
||||
Reference in New Issue
Block a user