From 961d9ac3b9cdecd57709209736a1990f933f58b7 Mon Sep 17 00:00:00 2001 From: hibna Date: Thu, 12 Feb 2026 19:56:46 +0300 Subject: [PATCH] feat(player): replace native cues with custom subtitle renderer --- README.md | 21 +++ src/components/VideoElement.css | 180 ++++++------------ src/components/VideoElement.tsx | 317 ++++++++++++++++++++++++++------ src/components/VideoPlayer.css | 4 - src/components/VideoPlayer.tsx | 12 ++ src/index.ts | 2 + src/types/index.ts | 15 ++ 7 files changed, 362 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index ff3db7e..4b4ba44 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,24 @@ function App() { /> ``` +### Subtitle Styling (Custom Renderer) + +```tsx + +``` + ### HLS Streaming ```tsx @@ -333,6 +351,9 @@ video-player/ | `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 | diff --git a/src/components/VideoElement.css b/src/components/VideoElement.css index 16107e5..741dd64 100644 --- a/src/components/VideoElement.css +++ b/src/components/VideoElement.css @@ -25,133 +25,65 @@ display: none !important; } -::cue, -.video-element::cue { +.custom-subtitle-overlay { + 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', - '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; -} - -::-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; + 'Roboto', 'Helvetica Neue', Arial, sans-serif; + font-size: 1.4rem; + font-weight: 500; + line-height: 1.45; + letter-spacing: 0.01em; + color: #fff; + background-color: rgba(15, 15, 15, 0.78); + text-align: center; + text-shadow: none; + white-space: pre-line; + word-wrap: break-word; } @media (max-width: 640px) { - ::cue, - .video-element::cue, - ::-moz-cue, - .video-element::-moz-cue { - font-size: 1.2rem !important; + .custom-subtitle-cue { + font-size: 1.2rem; } } diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 096ff5d..4485746 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -1,6 +1,13 @@ import React, { useEffect, useCallback, useState } from 'react' 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 { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl' import { setupHlsInstance } from '../utils/hlsSetup' @@ -26,6 +33,9 @@ interface VideoElementProps { playsInline?: boolean controlsList?: string subtitles?: SubtitleTrack[] + subtitleStyle?: SubtitleStyle + subtitlePosition?: SubtitlePosition + subtitleOffset?: number | string onPlay?: () => void onPause?: () => void onEnded?: () => void @@ -51,6 +61,82 @@ interface VideoElementProps { 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 = ({ src, poster, @@ -66,6 +152,9 @@ export const VideoElement: React.FC = ({ playsInline = true, controlsList, subtitles = [], + subtitleStyle, + subtitlePosition = 'bottom', + subtitleOffset, onPlay, onPause, onEnded, @@ -97,7 +186,65 @@ export const VideoElement: React.FC = ({ const [availableQualities, setAvailableQualities] = useState([]) const [hlsSubtitles, setHlsSubtitles] = useState([]) const [processedSubtitles, setProcessedSubtitles] = useState([]) + const [activeSubtitleLines, setActiveSubtitleLines] = useState([]) const subtitleBlobUrlsRef = React.useRef([]) + const subtitleAnimationFrameRef = React.useRef(null) + + const subtitleCueStyle = React.useMemo(() => { + 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(() => { + 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 const handlePlay = useCallback(() => { @@ -146,19 +293,16 @@ export const VideoElement: React.FC = ({ isLiveBroadcast, })) - // Enable default subtitle if specified - const tracks = video.textTracks - if (tracks && processedSubtitles.length > 0) { + // Apply default subtitle selection as soon as metadata is ready + if (processedSubtitles.length > 0 && !settings.subtitle) { const defaultSubtitle = processedSubtitles.find((sub) => sub.default) 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) } } onLoadedMetadata?.() - }, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle]) + }, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle, settings.subtitle]) const handleDurationChange = useCallback(() => { const video = videoRef.current @@ -397,26 +541,14 @@ export const VideoElement: React.FC = ({ }, [subtitles, hlsSubtitles]) useEffect(() => { - const video = videoRef.current - if (!video) return if (processedSubtitles.length === 0) return if (settings.subtitle) return const defaultSubtitle = processedSubtitles.find((subtitle) => subtitle.default) 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) - break - } - } - }, [processedSubtitles, settings.subtitle, setSubtitle, videoRef]) + setSubtitle(defaultSubtitle) + }, [processedSubtitles, settings.subtitle, setSubtitle]) // Detect video protocol and setup appropriate player useEffect(() => { @@ -732,63 +864,116 @@ export const VideoElement: React.FC = ({ } 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(() => { const video = videoRef.current if (!video) return - const tracks = video.textTracks - if (!tracks || tracks.length === 0) return + const clearAnimationFrame = () => { + if (subtitleAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(subtitleAnimationFrameRef.current) + subtitleAnimationFrameRef.current = null + } + } - const enableSubtitle = () => { - // Disable all tracks first - for (let i = 0; i < tracks.length; i++) { - tracks[i].mode = 'hidden' + const tracks = video.textTracks + const tracksArray: TextTrack[] = [] + for (let i = 0; i < tracks.length; i += 1) { + tracksArray.push(tracks[i]) + } + + // Hide all native subtitle rendering + tracksArray.forEach((track) => { + track.mode = 'disabled' + }) + + if (!settings.subtitle) { + setActiveSubtitleLines([]) + clearAnimationFrame() + return + } + + 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 } - // Enable the selected subtitle track - if (settings.subtitle) { - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i] - if (track.language === settings.subtitle.lang) { - // Wait for track to have cues before showing - if (track.cues && track.cues.length > 0) { - track.mode = 'showing' - logger.log(`🔊 Enabled subtitle track: ${track.label} (${track.language})`) - 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 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) } } + + setActiveSubtitleLines((prev) => (areSubtitleLinesEqual(prev, nextLines) ? prev : nextLines)) } - // Try to enable immediately - enableSubtitle() - - // Also listen for track load events to retry - const handleTrackChange = () => { - logger.log(`🔄 Track changed, re-enabling subtitle`) - enableSubtitle() + const tick = () => { + updateActiveCues() + subtitleAnimationFrameRef.current = window.requestAnimationFrame(tick) } - for (let i = 0; i < tracks.length; i++) { - tracks[i].addEventListener('load', handleTrackChange) + 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 () => { - for (let i = 0; i < tracks.length; i++) { - tracks[i].removeEventListener('load', handleTrackChange) - } + clearAnimationFrame() + 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]) + }, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles]) return (
@@ -825,10 +1010,20 @@ export const VideoElement: React.FC = ({ src={subtitle.src} srcLang={subtitle.lang} label={subtitle.label} - default={subtitle.default} /> ))} + {settings.subtitle && activeSubtitleLines.length > 0 && ( +
+
+ {activeSubtitleLines.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ )}
) } diff --git a/src/components/VideoPlayer.css b/src/components/VideoPlayer.css index eab22a9..9b91670 100644 --- a/src/components/VideoPlayer.css +++ b/src/components/VideoPlayer.css @@ -55,7 +55,3 @@ display: none !important; } -.video-player video::-webkit-media-text-track-container, -.video-player video::-webkit-media-text-track-display { - display: block !important; -} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index c6c98b9..e39a8d0 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -66,6 +66,9 @@ const VideoPlayerContent = forwardRef( controlsList, controls = true, subtitles = [], + subtitleStyle, + subtitlePosition, + subtitleOffset, theme, language, keyboardShortcuts = true, @@ -360,6 +369,9 @@ export const VideoPlayer = forwardRef( controlsList={controlsList} controls={controls} subtitles={subtitles} + subtitleStyle={subtitleStyle} + subtitlePosition={subtitlePosition} + subtitleOffset={subtitleOffset} theme={theme} keyboardShortcuts={keyboardShortcuts} pictureInPicture={pictureInPicture} diff --git a/src/index.ts b/src/index.ts index bf4ca6f..b8a400e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ export type { VideoPlayerProps, VideoPlayerHandle, SubtitleTrack, + SubtitleStyle, + SubtitlePosition, AudioTrack, VideoQuality, PlayerTheme, diff --git a/src/types/index.ts b/src/types/index.ts index a95b7e2..58fcb92 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,18 @@ export interface SubtitleTrack { 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 { name: string language: string @@ -92,6 +104,9 @@ export interface VideoPlayerProps { controlsList?: string controls?: boolean subtitles?: SubtitleTrack[] + subtitleStyle?: SubtitleStyle + subtitlePosition?: SubtitlePosition + subtitleOffset?: number | string theme?: PlayerTheme language?: string keyboardShortcuts?: boolean