feat(player): replace native cues with custom subtitle renderer
This commit is contained in:
@@ -157,6 +157,24 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Subtitle Styling (Custom Renderer)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/video.mp4"
|
||||||
|
subtitles={[{ src: '/subtitles/en.vtt', lang: 'en', label: 'English', default: true }]}
|
||||||
|
subtitlePosition="bottom"
|
||||||
|
subtitleOffset={72}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
color: '#f8fafc',
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
backgroundOpacity: 0.72,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
### HLS Streaming
|
### HLS Streaming
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -333,6 +351,9 @@ video-player/
|
|||||||
| `controlsList` | `string` | - | Passes `controlsList` attribute to the video element |
|
| `controlsList` | `string` | - | Passes `controlsList` attribute to the video element |
|
||||||
| `controls` | `boolean` | `true` | Show player controls |
|
| `controls` | `boolean` | `true` | Show player controls |
|
||||||
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
||||||
|
| `subtitleStyle` | `SubtitleStyle` | - | Custom subtitle text/background style |
|
||||||
|
| `subtitlePosition` | `'top' \| 'center' \| 'bottom'` | `'bottom'` | Subtitle vertical placement |
|
||||||
|
| `subtitleOffset` | `number \| string` | - | Subtitle offset (`px` if number) |
|
||||||
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
||||||
| `language` | `string` | `'en'` | UI language ('en' or 'tr') |
|
| `language` | `string` | `'en'` | UI language ('en' or 'tr') |
|
||||||
| `keyboardShortcuts` | `boolean` | `true` | Enable keyboard shortcuts |
|
| `keyboardShortcuts` | `boolean` | `true` | Enable keyboard shortcuts |
|
||||||
|
|||||||
+56
-124
@@ -25,133 +25,65 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
::cue,
|
.custom-subtitle-overlay {
|
||||||
.video-element::cue {
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--player-z-subtitle);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-subtitle-overlay.bottom {
|
||||||
|
bottom: var(--player-subtitle-bottom);
|
||||||
|
transition: bottom var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player.controls-hidden .custom-subtitle-overlay.bottom {
|
||||||
|
bottom: var(--player-subtitle-bottom-hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-subtitle-overlay.top {
|
||||||
|
top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-subtitle-overlay.center {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-subtitle-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(92%, 1200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-subtitle-cue {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.35em 0.75em;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
|
'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||||
font-size: 1.4rem !important;
|
font-size: 1.4rem;
|
||||||
font-weight: 500 !important;
|
font-weight: 500;
|
||||||
line-height: 1.45 !important;
|
line-height: 1.45;
|
||||||
letter-spacing: 0.01em !important;
|
letter-spacing: 0.01em;
|
||||||
color: #ffffff !important;
|
color: #fff;
|
||||||
background-color: rgba(15, 15, 15, 0.78) !important;
|
background-color: rgba(15, 15, 15, 0.78);
|
||||||
padding: 0.35em 0.75em !important;
|
text-align: center;
|
||||||
border-radius: var(--player-radius-sm) !important;
|
text-shadow: none;
|
||||||
text-shadow: none !important;
|
white-space: pre-line;
|
||||||
white-space: pre-line !important;
|
word-wrap: break-word;
|
||||||
}
|
|
||||||
|
|
||||||
::-moz-cue,
|
|
||||||
.video-element::-moz-cue {
|
|
||||||
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
||||||
'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
|
|
||||||
font-size: 1.4rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
line-height: 1.45 !important;
|
|
||||||
letter-spacing: 0.01em !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
background-color: rgba(15, 15, 15, 0.78) !important;
|
|
||||||
padding: 0.35em 0.75em !important;
|
|
||||||
border-radius: var(--player-radius-sm) !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
white-space: pre-line !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::cue(b),
|
|
||||||
::cue(strong),
|
|
||||||
.video-element::cue(b),
|
|
||||||
.video-element::cue(strong),
|
|
||||||
::-moz-cue(b),
|
|
||||||
::-moz-cue(strong) {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::cue(i),
|
|
||||||
::cue(em),
|
|
||||||
.video-element::cue(i),
|
|
||||||
.video-element::cue(em),
|
|
||||||
::-moz-cue(i),
|
|
||||||
::-moz-cue(em) {
|
|
||||||
font-style: italic !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::cue(u),
|
|
||||||
.video-element::cue(u),
|
|
||||||
::-moz-cue(u) {
|
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-element::-webkit-media-text-track-container {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
bottom: var(--player-subtitle-bottom) !important;
|
|
||||||
width: 100% !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
padding: 0 18px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
z-index: var(--player-z-subtitle) !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
transition: bottom var(--player-transition-fast) ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-player.controls-hidden .video-element::-webkit-media-text-track-container {
|
|
||||||
bottom: var(--player-subtitle-bottom-hidden) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-element::-webkit-media-text-track-display {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
video::-moz-text-track-display,
|
|
||||||
.video-element::-moz-text-track-display {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
bottom: var(--player-subtitle-bottom) !important;
|
|
||||||
width: 100% !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
padding: 0 18px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
text-align: center !important;
|
|
||||||
z-index: var(--player-z-subtitle) !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
transition: bottom var(--player-transition-fast) ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-player.controls-hidden video::-moz-text-track-display,
|
|
||||||
.video-player.controls-hidden .video-element::-moz-text-track-display {
|
|
||||||
bottom: var(--player-subtitle-bottom-hidden) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-element:fullscreen::cue,
|
|
||||||
:fullscreen .video-element::cue,
|
|
||||||
::-moz-cue:fullscreen,
|
|
||||||
video:fullscreen::-moz-cue,
|
|
||||||
:fullscreen ::-moz-cue {
|
|
||||||
font-size: 1.8rem !important;
|
|
||||||
padding: 0.4em 0.9em !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
::cue,
|
.custom-subtitle-cue {
|
||||||
.video-element::cue,
|
font-size: 1.2rem;
|
||||||
::-moz-cue,
|
|
||||||
.video-element::-moz-cue {
|
|
||||||
font-size: 1.2rem !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-58
@@ -1,6 +1,13 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react'
|
import React, { useEffect, useCallback, useState } from 'react'
|
||||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
import type { SubtitleTrack, AudioTrack, VideoQuality, VideoProtocol } from '../types'
|
import type {
|
||||||
|
SubtitleTrack,
|
||||||
|
AudioTrack,
|
||||||
|
VideoQuality,
|
||||||
|
VideoProtocol,
|
||||||
|
SubtitleStyle,
|
||||||
|
SubtitlePosition,
|
||||||
|
} from '../types'
|
||||||
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
||||||
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
||||||
import { setupHlsInstance } from '../utils/hlsSetup'
|
import { setupHlsInstance } from '../utils/hlsSetup'
|
||||||
@@ -26,6 +33,9 @@ interface VideoElementProps {
|
|||||||
playsInline?: boolean
|
playsInline?: boolean
|
||||||
controlsList?: string
|
controlsList?: string
|
||||||
subtitles?: SubtitleTrack[]
|
subtitles?: SubtitleTrack[]
|
||||||
|
subtitleStyle?: SubtitleStyle
|
||||||
|
subtitlePosition?: SubtitlePosition
|
||||||
|
subtitleOffset?: number | string
|
||||||
onPlay?: () => void
|
onPlay?: () => void
|
||||||
onPause?: () => void
|
onPause?: () => void
|
||||||
onEnded?: () => void
|
onEnded?: () => void
|
||||||
@@ -51,6 +61,82 @@ interface VideoElementProps {
|
|||||||
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stripCueMarkup = (text: string): string => {
|
||||||
|
return text.replace(/<[^>]+>/g, '').replace(/\r/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCssLength = (value?: number | string): string | undefined => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return `${value}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const validHex = /^[0-9a-fA-F]+$/.test(hex)
|
||||||
|
if (!validHex) {
|
||||||
|
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 areSubtitleLinesEqual = (a: string[], b: string[]): boolean => {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export const VideoElement: React.FC<VideoElementProps> = ({
|
export const VideoElement: React.FC<VideoElementProps> = ({
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
@@ -66,6 +152,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
playsInline = true,
|
playsInline = true,
|
||||||
controlsList,
|
controlsList,
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
|
subtitleStyle,
|
||||||
|
subtitlePosition = 'bottom',
|
||||||
|
subtitleOffset,
|
||||||
onPlay,
|
onPlay,
|
||||||
onPause,
|
onPause,
|
||||||
onEnded,
|
onEnded,
|
||||||
@@ -97,7 +186,65 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
||||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
|
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
|
const [activeSubtitleLines, setActiveSubtitleLines] = useState<string[]>([])
|
||||||
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
||||||
|
const subtitleAnimationFrameRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
|
const subtitleCueStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
|
const style: React.CSSProperties = {}
|
||||||
|
|
||||||
|
if (subtitleStyle?.fontFamily) {
|
||||||
|
style.fontFamily = subtitleStyle.fontFamily
|
||||||
|
}
|
||||||
|
if (subtitleStyle?.fontSize !== undefined) {
|
||||||
|
style.fontSize =
|
||||||
|
typeof subtitleStyle.fontSize === 'number' ? `${subtitleStyle.fontSize}px` : subtitleStyle.fontSize
|
||||||
|
}
|
||||||
|
if (subtitleStyle?.fontWeight !== undefined) {
|
||||||
|
style.fontWeight = subtitleStyle.fontWeight
|
||||||
|
}
|
||||||
|
if (subtitleStyle?.color) {
|
||||||
|
style.color = subtitleStyle.color
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBackgroundColor = typeof subtitleStyle?.backgroundColor === 'string'
|
||||||
|
const hasBackgroundOpacity = typeof subtitleStyle?.backgroundOpacity === 'number'
|
||||||
|
|
||||||
|
if (hasBackgroundColor && hasBackgroundOpacity) {
|
||||||
|
style.backgroundColor = toRgbaWithOpacity(
|
||||||
|
subtitleStyle.backgroundColor as string,
|
||||||
|
subtitleStyle.backgroundOpacity as number
|
||||||
|
)
|
||||||
|
} else if (hasBackgroundColor) {
|
||||||
|
style.backgroundColor = subtitleStyle?.backgroundColor
|
||||||
|
} else if (hasBackgroundOpacity) {
|
||||||
|
style.backgroundColor = `rgba(15, 15, 15, ${clampOpacity(subtitleStyle?.backgroundOpacity as number)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
|
}, [subtitleStyle])
|
||||||
|
|
||||||
|
const subtitleOverlayStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
|
const style: React.CSSProperties = {}
|
||||||
|
const offset = toCssLength(subtitleOffset)
|
||||||
|
|
||||||
|
if (!offset) {
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitlePosition === 'top') {
|
||||||
|
style.top = offset
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitlePosition === 'bottom') {
|
||||||
|
style.bottom = offset
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
style.transform = `translateY(calc(-50% + ${offset}))`
|
||||||
|
return style
|
||||||
|
}, [subtitleOffset, subtitlePosition])
|
||||||
|
|
||||||
// Handle video events
|
// Handle video events
|
||||||
const handlePlay = useCallback(() => {
|
const handlePlay = useCallback(() => {
|
||||||
@@ -146,19 +293,16 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
isLiveBroadcast,
|
isLiveBroadcast,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Enable default subtitle if specified
|
// Apply default subtitle selection as soon as metadata is ready
|
||||||
const tracks = video.textTracks
|
if (processedSubtitles.length > 0 && !settings.subtitle) {
|
||||||
if (tracks && processedSubtitles.length > 0) {
|
|
||||||
const defaultSubtitle = processedSubtitles.find((sub) => sub.default)
|
const defaultSubtitle = processedSubtitles.find((sub) => sub.default)
|
||||||
if (defaultSubtitle) {
|
if (defaultSubtitle) {
|
||||||
logger.log(`🎯 Found default subtitle in metadata: ${defaultSubtitle.label}`)
|
|
||||||
// Set subtitle in context (this will trigger the useEffect that enables it)
|
|
||||||
setSubtitle(defaultSubtitle)
|
setSubtitle(defaultSubtitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadedMetadata?.()
|
onLoadedMetadata?.()
|
||||||
}, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle])
|
}, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle, settings.subtitle])
|
||||||
|
|
||||||
const handleDurationChange = useCallback(() => {
|
const handleDurationChange = useCallback(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -397,26 +541,14 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}, [subtitles, hlsSubtitles])
|
}, [subtitles, hlsSubtitles])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
|
||||||
if (!video) return
|
|
||||||
if (processedSubtitles.length === 0) return
|
if (processedSubtitles.length === 0) return
|
||||||
if (settings.subtitle) return
|
if (settings.subtitle) return
|
||||||
|
|
||||||
const defaultSubtitle = processedSubtitles.find((subtitle) => subtitle.default)
|
const defaultSubtitle = processedSubtitles.find((subtitle) => subtitle.default)
|
||||||
if (!defaultSubtitle) return
|
if (!defaultSubtitle) return
|
||||||
|
|
||||||
const tracks = video.textTracks
|
|
||||||
if (!tracks || tracks.length === 0) return
|
|
||||||
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
const track = tracks[i]
|
|
||||||
if (track.language === defaultSubtitle.lang) {
|
|
||||||
track.mode = 'showing'
|
|
||||||
setSubtitle(defaultSubtitle)
|
setSubtitle(defaultSubtitle)
|
||||||
break
|
}, [processedSubtitles, settings.subtitle, setSubtitle])
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [processedSubtitles, settings.subtitle, setSubtitle, videoRef])
|
|
||||||
|
|
||||||
// Detect video protocol and setup appropriate player
|
// Detect video protocol and setup appropriate player
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -732,63 +864,116 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setHlsQualityLevel(hlsInstance, targetLevelIndex)
|
setHlsQualityLevel(hlsInstance, targetLevelIndex)
|
||||||
}, [settings.quality, availableQualities, videoRef])
|
}, [settings.quality, availableQualities, videoRef, onQualityChange])
|
||||||
|
|
||||||
// Handle subtitle track changes
|
// Custom subtitle renderer based on active TextTrack cues
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
|
const clearAnimationFrame = () => {
|
||||||
|
if (subtitleAnimationFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(subtitleAnimationFrameRef.current)
|
||||||
|
subtitleAnimationFrameRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tracks = video.textTracks
|
const tracks = video.textTracks
|
||||||
if (!tracks || tracks.length === 0) return
|
const tracksArray: TextTrack[] = []
|
||||||
|
for (let i = 0; i < tracks.length; i += 1) {
|
||||||
const enableSubtitle = () => {
|
tracksArray.push(tracks[i])
|
||||||
// Disable all tracks first
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
tracks[i].mode = 'hidden'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable the selected subtitle track
|
// Hide all native subtitle rendering
|
||||||
if (settings.subtitle) {
|
tracksArray.forEach((track) => {
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
track.mode = 'disabled'
|
||||||
const track = tracks[i]
|
})
|
||||||
if (track.language === settings.subtitle.lang) {
|
|
||||||
// Wait for track to have cues before showing
|
if (!settings.subtitle) {
|
||||||
if (track.cues && track.cues.length > 0) {
|
setActiveSubtitleLines([])
|
||||||
track.mode = 'showing'
|
clearAnimationFrame()
|
||||||
logger.log(`🔊 Enabled subtitle track: ${track.label} (${track.language})`)
|
return
|
||||||
logger.log(` - cues available: ${track.cues.length}`)
|
|
||||||
logger.log(` - track.mode: ${track.mode}`)
|
|
||||||
} else {
|
|
||||||
logger.warn(`⚠️ Track ${track.label} has no cues yet, waiting...`)
|
|
||||||
// Track not ready yet, will be handled by load event
|
|
||||||
track.mode = 'showing'
|
|
||||||
}
|
}
|
||||||
break
|
|
||||||
|
const selectedTrack =
|
||||||
|
tracksArray.find(
|
||||||
|
(track) =>
|
||||||
|
track.language === settings.subtitle?.lang &&
|
||||||
|
(track.label === settings.subtitle?.label || !track.label)
|
||||||
|
) ||
|
||||||
|
tracksArray.find((track) => track.language === settings.subtitle?.lang) ||
|
||||||
|
tracksArray.find((track) => track.label === settings.subtitle?.label)
|
||||||
|
|
||||||
|
if (!selectedTrack) {
|
||||||
|
setActiveSubtitleLines([])
|
||||||
|
clearAnimationFrame()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedTrack.mode = 'hidden'
|
||||||
|
|
||||||
|
const updateActiveCues = () => {
|
||||||
|
const activeCues = selectedTrack.activeCues
|
||||||
|
if (!activeCues || activeCues.length === 0) {
|
||||||
|
setActiveSubtitleLines((prev) => (prev.length === 0 ? prev : []))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextLines: string[] = []
|
||||||
|
for (let i = 0; i < activeCues.length; i += 1) {
|
||||||
|
const cue = activeCues[i] as TextTrackCue & { text?: string }
|
||||||
|
const cueText = typeof cue.text === 'string' ? stripCueMarkup(cue.text) : ''
|
||||||
|
if (cueText.length > 0) {
|
||||||
|
nextLines.push(cueText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to enable immediately
|
setActiveSubtitleLines((prev) => (areSubtitleLinesEqual(prev, nextLines) ? prev : nextLines))
|
||||||
enableSubtitle()
|
|
||||||
|
|
||||||
// Also listen for track load events to retry
|
|
||||||
const handleTrackChange = () => {
|
|
||||||
logger.log(`🔄 Track changed, re-enabling subtitle`)
|
|
||||||
enableSubtitle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
const tick = () => {
|
||||||
tracks[i].addEventListener('load', handleTrackChange)
|
updateActiveCues()
|
||||||
|
subtitleAnimationFrameRef.current = window.requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLoop = () => {
|
||||||
|
if (subtitleAnimationFrameRef.current !== null) return
|
||||||
|
subtitleAnimationFrameRef.current = window.requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopLoop = () => {
|
||||||
|
clearAnimationFrame()
|
||||||
|
updateActiveCues()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlay = () => startLoop()
|
||||||
|
const handlePause = () => stopLoop()
|
||||||
|
const handleSeek = () => updateActiveCues()
|
||||||
|
|
||||||
|
selectedTrack.addEventListener('cuechange', updateActiveCues)
|
||||||
|
video.addEventListener('play', handlePlay)
|
||||||
|
video.addEventListener('pause', handlePause)
|
||||||
|
video.addEventListener('seeking', handleSeek)
|
||||||
|
video.addEventListener('seeked', handleSeek)
|
||||||
|
video.addEventListener('timeupdate', handleSeek)
|
||||||
|
video.addEventListener('ended', handlePause)
|
||||||
|
|
||||||
|
updateActiveCues()
|
||||||
|
if (!video.paused && !video.ended) {
|
||||||
|
startLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
clearAnimationFrame()
|
||||||
tracks[i].removeEventListener('load', handleTrackChange)
|
selectedTrack.removeEventListener('cuechange', updateActiveCues)
|
||||||
|
video.removeEventListener('play', handlePlay)
|
||||||
|
video.removeEventListener('pause', handlePause)
|
||||||
|
video.removeEventListener('seeking', handleSeek)
|
||||||
|
video.removeEventListener('seeked', handleSeek)
|
||||||
|
video.removeEventListener('timeupdate', handleSeek)
|
||||||
|
video.removeEventListener('ended', handlePause)
|
||||||
}
|
}
|
||||||
}
|
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
|
||||||
}, [settings.subtitle, videoRef])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
@@ -825,10 +1010,20 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
src={subtitle.src}
|
src={subtitle.src}
|
||||||
srcLang={subtitle.lang}
|
srcLang={subtitle.lang}
|
||||||
label={subtitle.label}
|
label={subtitle.label}
|
||||||
default={subtitle.default}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</video>
|
</video>
|
||||||
|
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
||||||
|
<div className={`custom-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||||
|
<div className="custom-subtitle-stack">
|
||||||
|
{activeSubtitleLines.map((line, index) => (
|
||||||
|
<div key={`${line}-${index}`} className="custom-subtitle-cue" style={subtitleCueStyle}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,3 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player video::-webkit-media-text-track-container,
|
|
||||||
.video-player video::-webkit-media-text-track-display {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
controlsList,
|
controlsList,
|
||||||
controls = true,
|
controls = true,
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
|
subtitleStyle,
|
||||||
|
subtitlePosition,
|
||||||
|
subtitleOffset,
|
||||||
theme,
|
theme,
|
||||||
keyboardShortcuts = true,
|
keyboardShortcuts = true,
|
||||||
pictureInPicture = true,
|
pictureInPicture = true,
|
||||||
@@ -218,6 +221,9 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
playsInline={playsInline}
|
playsInline={playsInline}
|
||||||
controlsList={controlsList}
|
controlsList={controlsList}
|
||||||
subtitles={subtitles}
|
subtitles={subtitles}
|
||||||
|
subtitleStyle={subtitleStyle}
|
||||||
|
subtitlePosition={subtitlePosition}
|
||||||
|
subtitleOffset={subtitleOffset}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPause={onPause}
|
onPause={onPause}
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
@@ -287,6 +293,9 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
controlsList,
|
controlsList,
|
||||||
controls = true,
|
controls = true,
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
|
subtitleStyle,
|
||||||
|
subtitlePosition,
|
||||||
|
subtitleOffset,
|
||||||
theme,
|
theme,
|
||||||
language,
|
language,
|
||||||
keyboardShortcuts = true,
|
keyboardShortcuts = true,
|
||||||
@@ -360,6 +369,9 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
controlsList={controlsList}
|
controlsList={controlsList}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
subtitles={subtitles}
|
subtitles={subtitles}
|
||||||
|
subtitleStyle={subtitleStyle}
|
||||||
|
subtitlePosition={subtitlePosition}
|
||||||
|
subtitleOffset={subtitleOffset}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
pictureInPicture={pictureInPicture}
|
pictureInPicture={pictureInPicture}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type {
|
|||||||
VideoPlayerProps,
|
VideoPlayerProps,
|
||||||
VideoPlayerHandle,
|
VideoPlayerHandle,
|
||||||
SubtitleTrack,
|
SubtitleTrack,
|
||||||
|
SubtitleStyle,
|
||||||
|
SubtitlePosition,
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
VideoQuality,
|
VideoQuality,
|
||||||
PlayerTheme,
|
PlayerTheme,
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export interface SubtitleTrack {
|
|||||||
default?: boolean
|
default?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubtitlePosition = 'top' | 'center' | 'bottom'
|
||||||
|
|
||||||
|
export interface SubtitleStyle {
|
||||||
|
fontFamily?: string
|
||||||
|
fontSize?: number | string
|
||||||
|
fontWeight?: number | string
|
||||||
|
color?: string
|
||||||
|
backgroundColor?: string
|
||||||
|
/** 0-1 arasi arka plan opakligi */
|
||||||
|
backgroundOpacity?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioTrack {
|
export interface AudioTrack {
|
||||||
name: string
|
name: string
|
||||||
language: string
|
language: string
|
||||||
@@ -92,6 +104,9 @@ export interface VideoPlayerProps {
|
|||||||
controlsList?: string
|
controlsList?: string
|
||||||
controls?: boolean
|
controls?: boolean
|
||||||
subtitles?: SubtitleTrack[]
|
subtitles?: SubtitleTrack[]
|
||||||
|
subtitleStyle?: SubtitleStyle
|
||||||
|
subtitlePosition?: SubtitlePosition
|
||||||
|
subtitleOffset?: number | string
|
||||||
theme?: PlayerTheme
|
theme?: PlayerTheme
|
||||||
language?: string
|
language?: string
|
||||||
keyboardShortcuts?: boolean
|
keyboardShortcuts?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user