From fa66472c74dd4724e964f9ce9d2b0baa8419a489 Mon Sep 17 00:00:00 2001 From: hibna Date: Fri, 13 Feb 2026 05:35:27 +0300 Subject: [PATCH] feat: add optional subtitle style editor with live preview --- CHANGELOG.md | 16 + README.md | 195 ++++++------ examples/App.css | 22 +- examples/App.tsx | 58 +++- package-lock.json | 4 +- package.json | 2 +- src/components/VideoElement.tsx | 79 +++-- src/components/VideoPlayer.tsx | 65 +++- src/components/menus/SettingsMenu.css | 184 ++++++++++- src/components/menus/SettingsMenu.test.tsx | 30 ++ src/components/menus/SettingsMenu.tsx | 338 ++++++++++++++++++++- src/contexts/PlayerContext.tsx | 182 ++++++++++- src/i18n/index.ts | 96 ++++-- src/index.ts | 8 +- src/types/index.ts | 13 + 15 files changed, 1102 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 406301f..2a7fd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [3.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 diff --git a/README.md b/README.md index 8596435..eac92ad 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,17 @@ 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 ⚠️ | -| **TypeScript Native** | **Yes** ✅ | Types ⚠️ | Partial ⚠️ | Types ⚠️ | -| **HLS Support** | **Yes** ✅ | Yes ✅ | Yes ✅ | No ❌ | -| **Quality Switching** | **Yes** ✅ | Yes ✅ | Limited ⚠️ | No ❌ | -| **Touch Gestures** | **15+** ✅ | Limited ⚠️ | No ❌ | Limited ⚠️ | -| **Keyboard Shortcuts** | **15+** ✅ | ~8 ⚠️ | Basic ⚠️ | ~10 ⚠️ | -| **i18n Support** | **Yes** ✅ | Yes ✅ | No ❌ | Yes ✅ | +| 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 ⚠️ | +| **TypeScript Native** | **Yes** ✅ | Types ⚠️ | Partial ⚠️ | Types ⚠️ | +| **HLS Support** | **Yes** ✅ | Yes ✅ | Yes ✅ | No ❌ | +| **Quality Switching** | **Yes** ✅ | Yes ✅ | Limited ⚠️ | No ❌ | +| **Touch Gestures** | **15+** ✅ | Limited ⚠️ | No ❌ | Limited ⚠️ | +| **Keyboard Shortcuts** | **15+** ✅ | ~8 ⚠️ | Basic ⚠️ | ~10 ⚠️ | +| **i18n Support** | **Yes** ✅ | Yes ✅ | No ❌ | Yes ✅ | ### Key Advantages @@ -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 ( - - ) + return } ``` @@ -193,22 +195,32 @@ function App() { /> ``` -### HLS Streaming +### Subtitle Style Editor (Optional) ```tsx ``` +`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 + +``` + ### Force Protocol (Override Auto Detection) ```tsx - + ``` ### IPTV Streaming @@ -244,9 +256,9 @@ function App() { ```tsx ``` @@ -358,83 +370,91 @@ 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 | -| `autoplay` | `boolean` | `false` | Auto-play video on load | -| `loop` | `boolean` | `false` | Loop video playback | -| `muted` | `boolean` | `false` | Start muted | -| `volume` | `number` | - | Initial volume (0-1) | -| `playbackRate` | `number` | - | Playback speed (0.25, 0.5, 1, 1.5, 2, etc.) | -| `currentTime` | `number` | - | Initial playback position in seconds | -| `crossOrigin` | `'' \| 'anonymous' \| 'use-credentials'` | - | Sets the video `crossOrigin` attribute | -| `preload` | `'none' \| 'metadata' \| 'auto'` | `'metadata'` | Sets the video preload strategy | -| `playsInline` | `boolean` | `true` | Enables inline playback on mobile browsers | -| `controlsList` | `string` | - | Passes `controlsList` attribute to the video element | -| `controls` | `boolean` | `true` | Show player controls | -| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks | -| `subtitleStyle` | `SubtitleStyle` | - | Custom subtitle text/background style | -| `subtitlePosition` | `'top' \| 'center' \| 'bottom'` | `'bottom'` | Subtitle vertical placement | -| `subtitleOffset` | `number \| string` | - | Subtitle offset (`px` if number) | -| `theme` | `PlayerTheme` | - | Custom theme colors | -| `language` | `string` | `'en'` | UI language ('en' or 'tr') | -| `keyboardShortcuts` | `boolean` | `true` | Enable keyboard shortcuts | -| `pictureInPicture` | `boolean` | `true` | Enable PIP button | -| `className` | `string` | - | Custom CSS class | -| `style` | `CSSProperties` | - | Inline styles | +| 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 | +| `autoplay` | `boolean` | `false` | Auto-play video on load | +| `loop` | `boolean` | `false` | Loop video playback | +| `muted` | `boolean` | `false` | Start muted | +| `volume` | `number` | - | Initial volume (0-1) | +| `playbackRate` | `number` | - | Playback speed (0.25, 0.5, 1, 1.5, 2, etc.) | +| `currentTime` | `number` | - | Initial playback position in seconds | +| `crossOrigin` | `'' \| 'anonymous' \| 'use-credentials'` | - | Sets the video `crossOrigin` attribute | +| `preload` | `'none' \| 'metadata' \| 'auto'` | `'metadata'` | Sets the video preload strategy | +| `playsInline` | `boolean` | `true` | Enables inline playback on mobile browsers | +| `controlsList` | `string` | - | Passes `controlsList` attribute to the video element | +| `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 | +| `language` | `string` | `'en'` | UI language ('en' or 'tr') | +| `keyboardShortcuts` | `boolean` | `true` | Enable keyboard shortcuts | +| `pictureInPicture` | `boolean` | `true` | Enable PIP button | +| `className` | `string` | - | Custom CSS class | +| `style` | `CSSProperties` | - | Inline styles | #### Event Handlers -| Prop | Type | Description | -|------|------|-------------| -| `onPlay` | `() => void` | Fired when playback starts | -| `onPause` | `() => void` | Fired when playback pauses | -| `onEnded` | `() => void` | Fired when playback ends | -| `onTimeUpdate` | `(currentTime: number) => void` | Fired during playback with current time | -| `onVolumeChange` | `(volume: number) => void` | Fired when volume changes | -| `onError` | `(error: Error) => void` | Fired on playback error | -| `onLoadedMetadata` | `() => void` | Fired when video metadata is loaded | -| `onSeeking` | `() => void` | Fired when seeking starts | -| `onSeeked` | `() => void` | Fired when seeking completes | -| `onProgress` | `(buffered: number) => void` | Fired during download progress | -| `onDurationChange` | `(duration: number) => void` | Fired when duration changes | -| `onRateChange` | `(playbackRate: number) => void` | Fired when playback rate changes | -| `onFullscreenChange` | `(isFullscreen: boolean) => void` | Fired when fullscreen state changes | -| `onPictureInPictureChange` | `(isPip: boolean) => void` | Fired when PIP state changes | -| `onWaiting` | `() => void` | Fired when buffering starts | -| `onCanPlay` | `() => void` | Fired when enough data is available to play | +| Prop | Type | Description | +| -------------------------- | --------------------------------- | ------------------------------------------- | +| `onPlay` | `() => void` | Fired when playback starts | +| `onPause` | `() => void` | Fired when playback pauses | +| `onEnded` | `() => void` | Fired when playback ends | +| `onTimeUpdate` | `(currentTime: number) => void` | Fired during playback with current time | +| `onVolumeChange` | `(volume: number) => void` | Fired when volume changes | +| `onError` | `(error: Error) => void` | Fired on playback error | +| `onLoadedMetadata` | `() => void` | Fired when video metadata is loaded | +| `onSeeking` | `() => void` | Fired when seeking starts | +| `onSeeked` | `() => void` | Fired when seeking completes | +| `onProgress` | `(buffered: number) => void` | Fired during download progress | +| `onDurationChange` | `(duration: number) => void` | Fired when duration changes | +| `onRateChange` | `(playbackRate: number) => void` | Fired when playback rate changes | +| `onFullscreenChange` | `(isFullscreen: boolean) => void` | Fired when fullscreen state changes | +| `onPictureInPictureChange` | `(isPip: boolean) => void` | Fired when PIP state changes | +| `onWaiting` | `() => void` | Fired when buffering starts | +| `onCanPlay` | `() => void` | Fired when enough data is available to play | ### 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 | -| `onReset` | `() => void` | Called when retry/reset is triggered | -| `resetKeys` | `readonly unknown[]` | Resets boundary when any key changes | +| 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 | +| `onReset` | `() => void` | Called when retry/reset is triggered | +| `resetKeys` | `readonly unknown[]` | Resets boundary when any key changes | ### SubtitleTrack ```typescript interface SubtitleTrack { - src: string // Subtitle file URL (.vtt or .srt) - lang: string // Language code (e.g., 'en', 'tr') - label: string // Display label + src: string // Subtitle file URL (.vtt or .srt) + lang: string // Language code (e.g., 'en', 'tr') + label: string // Display label default?: boolean // Set as default subtitle } ``` +```typescript +interface SubtitleStyleEditorConfig { + enabled?: boolean // Enables subtitle style editor in settings + storageKey?: string // localStorage key (default: 'source-player-subtitle-style') +} +``` + ### PlayerTheme ```typescript interface PlayerTheme { - primaryColor?: string // Primary color (default: #ef4444) - accentColor?: string // Accent/hover color (default: #dc2626) - backgroundColor?: string // Background color (default: #000000) - textColor?: string // Text color (default: #ffffff) + primaryColor?: string // Primary color (default: #ef4444) + accentColor?: string // Accent/hover color (default: #dc2626) + backgroundColor?: string // Background color (default: #000000) + textColor?: string // Text color (default: #ffffff) } ``` @@ -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 diff --git a/examples/App.css b/examples/App.css index dc85021..8cc886d 100644 --- a/examples/App.css +++ b/examples/App.css @@ -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; } diff --git a/examples/App.tsx b/examples/App.tsx index 60c7da5..a157cf3 100644 --- a/examples/App.tsx +++ b/examples/App.tsx @@ -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} /> - + @@ -79,14 +96,30 @@ function App() {

⌨️ Keyboard Shortcuts

    -
  • Space or K - Play/Pause
  • -
  • / - Seek 5s
  • -
  • J / L - Seek 10s
  • -
  • / - Volume
  • -
  • M - Mute/Unmute
  • -
  • F - Fullscreen
  • -
  • P - Picture-in-Picture
  • -
  • 0-9 - Jump to %
  • +
  • + Space or K - Play/Pause +
  • +
  • + / - Seek 5s +
  • +
  • + J / L - Seek 10s +
  • +
  • + / - Volume +
  • +
  • + M - Mute/Unmute +
  • +
  • + F - Fullscreen +
  • +
  • + P - Picture-in-Picture +
  • +
  • + 0-9 - Jump to % +
@@ -118,6 +151,7 @@ function App() {
  • HLS streaming support
  • HTTP Range Request (MP4)
  • Subtitles (VTT, SRT)
  • +
  • Subtitle style editor + live preview
  • Multiple audio tracks
  • Playback speed control
  • Quality selector
  • diff --git a/package-lock.json b/package-lock.json index be4765d..174fcf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2999953..0938fe8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 1d0f8ab..cf74bb1 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -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 = ({ onQualityLevelsLoaded, onSubtitleTracksLoaded, }) => { - const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext() + const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = + usePlayerContext() const lastClickTimeRef = React.useRef(0) const hasPlayedRef = React.useRef(false) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) @@ -190,39 +194,49 @@ export const VideoElement: React.FC = ({ const subtitleBlobUrlsRef = React.useRef([]) const subtitleAnimationFrameRef = React.useRef(null) + const effectiveSubtitleStyle = React.useMemo( + () => ({ + ...(subtitleStyle || {}), + ...(settings.subtitleStyle || {}), + }), + [subtitleStyle, settings.subtitleStyle] + ) + const subtitleCueStyle = React.useMemo(() => { 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(() => { const style: React.CSSProperties = {} @@ -302,7 +316,14 @@ export const VideoElement: React.FC = ({ } 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 = ({ // 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 = ({ // 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 = ({ 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 = ({ 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 = ({ ) } - diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 240be0a..890112e 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -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 )} {children && ( -
    +
    {children}
    )} @@ -295,6 +338,7 @@ export const VideoPlayer = forwardRef( controls = true, subtitles = [], subtitleStyle, + subtitleStyleEditor, subtitlePosition, subtitleOffset, theme, @@ -338,6 +382,10 @@ export const VideoPlayer = forwardRef( const [audioTracks, setAudioTracks] = useState([]) const [qualities, setQualities] = useState([]) const [hlsSubtitles, setHlsSubtitles] = useState([]) + const subtitleStyleEditorConfig = useMemo( + () => resolveSubtitleStyleEditorConfig(subtitleStyleEditor), + [subtitleStyleEditor] + ) const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => { setAudioTracks(tracks) @@ -358,7 +406,16 @@ export const VideoPlayer = forwardRef( onError?.(error) }} > - + 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; } } diff --git a/src/components/menus/SettingsMenu.test.tsx b/src/components/menus/SettingsMenu.test.tsx index 8943947..a383202 100644 --- a/src/components/menus/SettingsMenu.test.tsx +++ b/src/components/menus/SettingsMenu.test.tsx @@ -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() + + 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, + }) + ) + }) }) diff --git a/src/components/menus/SettingsMenu.tsx b/src/components/menus/SettingsMenu.tsx index 176b031..5559f28 100644 --- a/src/components/menus/SettingsMenu.tsx +++ b/src/components/menus/SettingsMenu.tsx @@ -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 = ({ subtitles = [], @@ -25,15 +124,81 @@ export const SettingsMenu: React.FC = ({ settings, setPlaybackRate, setSubtitle, + setSubtitleStyle, + saveSubtitleStyle, + revertSubtitleStyle, setAudioTrack, setQuality, toggleSettings, + subtitleStyleEditorEnabled, translations, } = usePlayerContext() const menuRef = useRef(null) const [currentView, setCurrentView] = useState('main') + const [subtitleStyleDraft, setSubtitleStyleDraft] = useState( + 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( + () => ({ + 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) => { + 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 = ({ 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 = ({ 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 = ({
    {translations.speed} - {videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`} + {videoState.playbackRate === 1 + ? translations.normal + : `${videoState.playbackRate}x`}
    @@ -140,7 +313,7 @@ export const SettingsMenu: React.FC = ({

    {translations.speed}

    -
    +
    {playbackRates.map((rate) => ( ))}
    @@ -167,7 +342,7 @@ export const SettingsMenu: React.FC = ({

    {translations.subtitles}

    -
    +
    )) ) : ( @@ -197,6 +374,138 @@ export const SettingsMenu: React.FC = ({ {translations.noSubtitlesAvailable}
    )} + {subtitleStyleEditorEnabled && ( + + )} +
    + + )} + + {currentView === 'subtitleStyle' && ( + <> +
    + +

    {translations.subtitleStyle}

    +
    +
    +
    + {translations.preview} +
    +
    + {STYLE_PREVIEW_TEXT} +
    +
    +
    + + + + + + + + + + + +
    + + + +
    )} @@ -210,7 +519,7 @@ export const SettingsMenu: React.FC = ({

    {translations.audioTrack}

    -
    +
    {audioTracks.map((track) => (

    {translations.quality}

    -
    +