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}
/>
-
+
+
+
{translations.preview}
+
+
+ {STYLE_PREVIEW_TEXT}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {translations.reset}
+
+
+ {translations.cancel}
+
+
+ {translations.save}
+
+
>
)}
@@ -210,7 +519,7 @@ export const SettingsMenu: React.FC = ({
{translations.audioTrack}
-
+
{audioTracks.map((track) => (
= ({
{translations.quality}
-
+
{
@@ -255,7 +564,10 @@ export const SettingsMenu: React.FC = ({
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
diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx
index 3560d18..2a381cb 100644
--- a/src/contexts/PlayerContext.tsx
+++ b/src/contexts/PlayerContext.tsx
@@ -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>
@@ -28,6 +117,9 @@ interface PlayerProviderProps {
initialVolume?: number
initialMuted?: boolean
initialPlaybackRate?: number
+ initialSubtitleStyle?: SubtitleStyle
+ subtitleStyleEditorEnabled?: boolean
+ subtitleStyleStorageKey?: string
language?: string
customTranslations?: Partial
}
@@ -37,11 +129,44 @@ export const PlayerProvider: React.FC = ({
initialVolume = 1,
initialMuted = false,
initialPlaybackRate = 1,
+ initialSubtitleStyle,
+ subtitleStyleEditorEnabled = false,
+ subtitleStyleStorageKey = DEFAULT_SUBTITLE_STYLE_STORAGE_KEY,
language,
customTranslations,
}) => {
const videoRef = useRef(null)
const containerRef = useRef(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 = ({
const [settings, setSettings] = useState({
quality: null,
subtitle: null,
+ subtitleStyle: {
+ ...normalizedInitialSubtitleStyle,
+ ...(subtitleStyleEditorEnabled ? readStoredSubtitleStyle(normalizedSubtitleStorageKey) : {}),
+ },
audioTrack: null,
playbackRate: initialPlaybackRate,
})
+ const committedSubtitleStyleRef = useRef(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 = ({
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 = ({
toggleSettings,
setQuality,
setSubtitle,
+ setSubtitleStyle,
+ saveSubtitleStyle,
+ revertSubtitleStyle,
setAudioTrack,
+ subtitleStyleEditorEnabled,
}
return {children}
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 4294d63..79624b4 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -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 = {
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 = {
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 = {
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'
+}
diff --git a/src/index.ts b/src/index.ts
index ab8d2e8..7fd6673 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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
diff --git a/src/types/index.ts b/src/types/index.ts
index 58fcb92..8368981 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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'