feat: add optional subtitle style editor with live preview

This commit is contained in:
hibna
2026-02-13 05:35:27 +03:00
parent 69d7706967
commit fa66472c74
15 changed files with 1102 additions and 190 deletions
+16
View File
@@ -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
+42 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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",
+53 -26
View File
@@ -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>
)
}
+61 -4
View File
@@ -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}
+175 -9
View File
@@ -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,
})
)
})
})
+325 -13
View File
@@ -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
+180 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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'