Add SRT/FLV/RTMP support and update documentation

Introduced Python scripts for SRT subtitle checking and fixing, and added comprehensive documentation covering advanced features such as protocol detection, subtitle/audio/quality management, keyboard shortcuts, and touch gestures. Updated local settings to allow new build and Python commands, added TypeScript definitions for FLV, and implemented RTMP/FLV protocol support in the player. Removed CHANGELOG.md and made various improvements to styles and example app.
This commit is contained in:
hibna
2025-11-03 02:35:56 +03:00
parent 42a12dfa8b
commit 36f83ff72c
26 changed files with 3833 additions and 1212 deletions
+5
View File
@@ -32,6 +32,11 @@
z-index: 1;
}
.controls-layer > .center-play-overlay,
.controls-layer > .loading-spinner-overlay {
position: absolute;
}
.controls-layer.hidden.playing {
opacity: 0;
}
+121 -42
View File
@@ -4,6 +4,8 @@ import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
import { setupHlsInstance } from '../utils/hlsSetup'
import { setupRtmpInstance } from '../utils/rtmpSetup'
import { detectVideoProtocol } from '../utils/videoProtocol'
import { createSubtitleBlobURL } from '../utils/subtitles'
import './VideoElement.css'
@@ -25,6 +27,7 @@ interface VideoElementProps {
onSeeked?: () => void
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
}
export const VideoElement: React.FC<VideoElementProps> = ({
@@ -45,11 +48,13 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onSeeked,
onAudioTracksLoaded,
onQualityLevelsLoaded,
onSubtitleTracksLoaded,
}) => {
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
const lastClickTimeRef = React.useRef<number>(0)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
const subtitleBlobUrlsRef = React.useRef<string[]>([])
@@ -210,7 +215,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
}, [setVideoState])
// Process subtitles - convert SRT to VTT blob URLs
// Process subtitles - convert SRT to VTT blob URLs and merge with HLS subtitles
useEffect(() => {
let cancelled = false
@@ -218,14 +223,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
subtitleBlobUrlsRef.current = []
if (subtitles.length === 0) {
// Merge manual subtitles and HLS subtitles
const allSubtitles = [...subtitles, ...hlsSubtitles]
if (allSubtitles.length === 0) {
setProcessedSubtitles([])
return
}
const processSubtitles = async () => {
const processed = await Promise.all(
subtitles.map(async (subtitle) => {
allSubtitles.map(async (subtitle) => {
try {
// Check if it's an SRT file
if (subtitle.src.endsWith('.srt')) {
@@ -273,7 +281,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
subtitleBlobUrlsRef.current = []
}
}, [subtitles])
}, [subtitles, hlsSubtitles])
useEffect(() => {
const video = videoRef.current
@@ -297,7 +305,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
}, [processedSubtitles, settings.subtitle, setSubtitle, videoRef])
// Detect HLS source and load hls.js if needed
// Detect video protocol and setup appropriate player
useEffect(() => {
const video = videoRef.current
if (!video) return
@@ -306,6 +314,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onAudioTracksLoaded?.([])
setAvailableQualities([])
onQualityLevelsLoaded?.([])
setHlsSubtitles([])
onSubtitleTracksLoaded?.([])
// Validate video URL first
const validation = validateVideoURL(src)
@@ -316,57 +326,117 @@ export const VideoElement: React.FC<VideoElementProps> = ({
return
}
const isHLS = src.includes('.m3u8')
// Detect video protocol
const detection = detectVideoProtocol(src)
let cleanupFn: (() => void) | null = null
const setupHls = async () => {
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
try {
cleanupFn = await setupHlsInstance({
video,
src,
autoplay,
onAudioTracksLoaded: (tracks) => {
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
},
onQualityLevelsLoaded: (qualities) => {
setAvailableQualities(qualities)
onQualityLevelsLoaded?.(qualities)
},
onError: handleError,
})
} catch (err) {
let error: Error
if (err instanceof Error && isCORSError(err)) {
const corsMessage = getCORSErrorMessage(src)
error = new Error(corsMessage)
} else {
error = err instanceof Error ? err : new Error('Failed to load HLS')
console.log('[VideoElement] Source:', src)
console.log('[VideoElement] Detected protocol:', detection.protocol)
console.log('[VideoElement] Is live stream?', detection.isLive)
console.log('[VideoElement] Needs special player?', detection.needsSpecialPlayer)
const setupPlayer = async () => {
try {
switch (detection.protocol) {
case 'hls': {
// HLS streaming setup
const canPlayHLS = video.canPlayType('application/vnd.apple.mpegurl')
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const shouldUseHlsJs = canPlayHLS === '' || !isSafari
console.log('[VideoElement] Native HLS support?', canPlayHLS)
console.log('[VideoElement] Is Safari?', isSafari)
console.log('[VideoElement] Will use HLS.js?', shouldUseHlsJs)
if (shouldUseHlsJs) {
console.log('[VideoElement] Setting up HLS.js...')
cleanupFn = await setupHlsInstance({
video,
src,
autoplay,
onAudioTracksLoaded: (tracks) => {
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
},
onQualityLevelsLoaded: (qualities) => {
setAvailableQualities(qualities)
onQualityLevelsLoaded?.(qualities)
},
onSubtitleTracksLoaded: (tracks) => {
setHlsSubtitles(tracks)
onSubtitleTracksLoaded?.(tracks)
},
onError: handleError,
})
} else {
console.log('[VideoElement] Using native HLS playback')
video.src = src
if (autoplay) {
void video.play().catch(() => undefined)
}
}
break
}
case 'rtmp': {
// RTMP/FLV streaming setup
console.log('[VideoElement] Setting up RTMP/FLV player...')
cleanupFn = await setupRtmpInstance({
video,
src,
autoplay,
onError: handleError,
onLoadedMetadata,
})
break
}
case 'dash': {
// DASH streaming - not yet implemented
const error = new Error('DASH streaming is not yet supported')
console.error('[VideoElement]', error.message)
setVideoState((prev) => ({ ...prev, error, loading: false }))
onError?.(error)
break
}
case 'native':
default: {
// Native HTML5 video (MP4, WebM, etc.)
console.log('[VideoElement] Using native video.src')
video.src = src
if (autoplay) {
void video.play().catch(() => undefined)
}
break
}
setVideoState((prev) => ({
...prev,
error,
loading: false,
}))
onError?.(error)
}
} else {
video.src = src
if (autoplay) {
void video.play().catch(() => undefined)
} catch (err) {
let error: Error
if (err instanceof Error && isCORSError(err)) {
const corsMessage = getCORSErrorMessage(src)
error = new Error(corsMessage)
} else {
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
}
console.error('[VideoElement] Setup error:', error)
setVideoState((prev) => ({
...prev,
error,
loading: false,
}))
onError?.(error)
}
}
setupHls()
setupPlayer()
// Cleanup function
return () => {
if (cleanupFn) {
cleanupFn()
}
// Also check for any lingering HLS instance
// Also check for any lingering player instances
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
@@ -374,6 +444,13 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
delete (video as any).__hlsInstance
}
if ((video as any).__rtmpInstance) {
const rtmp = (video as any).__rtmpInstance
if (rtmp && typeof rtmp.destroy === 'function') {
rtmp.destroy()
}
delete (video as any).__rtmpInstance
}
}
}, [
src,
@@ -384,6 +461,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError,
onAudioTracksLoaded,
onQualityLevelsLoaded,
onSubtitleTracksLoaded,
onLoadedMetadata,
])
// Handle audio track changes
+17 -2
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
import { VideoElement } from './VideoElement'
import { ControlsLayer } from './ControlsLayer'
import type { VideoPlayerProps, AudioTrack, VideoQuality } from '../types'
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
import '../styles/variables.css'
import './VideoPlayer.css'
@@ -31,6 +31,8 @@ const VideoPlayerContent: React.FC<
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[]
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
hlsSubtitles: SubtitleTrack[]
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
}
> = ({
src,
@@ -57,10 +59,15 @@ const VideoPlayerContent: React.FC<
onAudioTracksLoadedInternal,
qualities,
onQualityLevelsLoadedInternal,
hlsSubtitles,
onSubtitleTracksLoadedInternal,
}) => {
const { containerRef, uiState } = usePlayerContext()
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
// Merge manual subtitles and HLS-detected subtitles
const allSubtitles = [...subtitles, ...hlsSubtitles]
return (
<div ref={containerRef} className={`video-player ${controlsHiddenClass} ${className}`} style={style}>
<VideoElement
@@ -81,12 +88,13 @@ const VideoPlayerContent: React.FC<
onSeeked={onSeeked}
onAudioTracksLoaded={onAudioTracksLoadedInternal}
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
/>
{controls && (
<ControlsLayer
keyboardShortcuts={keyboardShortcuts}
pictureInPicture={pictureInPicture}
subtitles={subtitles}
subtitles={allSubtitles}
audioTracks={audioTracks}
qualities={qualities}
/>
@@ -121,6 +129,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
}) => {
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
const [qualities, setQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
// Apply theme CSS variables
useEffect(() => {
@@ -141,6 +150,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
setQualities(levels)
}, [])
const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => {
setHlsSubtitles(tracks)
}, [])
return (
<PlayerProvider initialMuted={muted} language={language}>
<VideoPlayerContent
@@ -168,6 +181,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
qualities={qualities}
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
hlsSubtitles={hlsSubtitles}
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
/>
</PlayerProvider>
)
+12 -12
View File
@@ -9,8 +9,8 @@
}
.center-play-button {
width: 96px;
height: 96px;
width: 72px;
height: 72px;
border-radius: var(--player-radius-full);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0)),
var(--player-primary);
@@ -21,7 +21,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45), 0 12px 28px rgba(239, 68, 68, 0.28);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(239, 68, 68, 0.2);
transition: background-color var(--player-transition-fast) ease,
color var(--player-transition-fast) ease, transform var(--player-transition-fast) ease,
box-shadow var(--player-transition-normal) ease;
@@ -30,8 +30,8 @@
.center-play-button:hover {
background-color: var(--player-primary-hover);
transform: scale(1.05);
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.5), 0 14px 32px rgba(239, 68, 68, 0.32);
transform: scale(1.08);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(239, 68, 68, 0.25);
}
.center-play-button:active {
@@ -45,19 +45,19 @@
}
.center-play-button svg {
width: 44px;
height: 44px;
margin-left: 4px;
width: 36px;
height: 36px;
margin-left: 3px;
}
@media (max-width: 640px) {
.center-play-button {
width: 74px;
height: 74px;
width: 64px;
height: 64px;
}
.center-play-button svg {
width: 34px;
height: 34px;
width: 30px;
height: 30px;
}
}
+8 -4
View File
@@ -13,7 +13,7 @@
height: 3px;
background-color: var(--player-progress-bg);
border-radius: var(--player-radius-full);
overflow: hidden;
overflow: visible;
transition: height var(--player-transition-fast) ease;
}
@@ -27,6 +27,7 @@
inset: 0;
width: 0;
background-color: var(--player-progress-buffered);
border-radius: var(--player-radius-full);
transition: width 0.12s ease;
}
@@ -35,6 +36,7 @@
inset: 0;
width: 0;
background-color: var(--player-primary);
border-radius: var(--player-radius-full);
display: flex;
align-items: center;
justify-content: flex-end;
@@ -46,14 +48,16 @@
height: 12px;
border-radius: 50%;
background-color: var(--player-primary);
transform: scale(0);
transition: transform var(--player-transition-fast) ease;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
transform: scale(1);
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
margin-right: -6px;
}
.progress-bar:hover .progress-handle,
.progress-bar.seeking .progress-handle {
transform: scale(1);
transform: scale(1.15);
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
}
.progress-tooltip {
+20 -2
View File
@@ -11,7 +11,7 @@
height: 4px;
background-color: var(--player-progress-bg);
border-radius: var(--player-radius-full);
overflow: hidden;
overflow: visible;
opacity: 0;
transition: width var(--player-transition-normal) ease,
opacity var(--player-transition-normal) ease;
@@ -51,6 +51,14 @@
background-color: var(--player-primary);
border: none;
margin-top: -4px;
cursor: pointer;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.volume-slider:hover::-webkit-slider-thumb {
transform: scale(1.15);
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
}
.volume-slider::-moz-range-track {
@@ -64,11 +72,21 @@
border-radius: 50%;
background-color: var(--player-primary);
border: none;
cursor: pointer;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.volume-slider:hover::-moz-range-thumb {
transform: scale(1.15);
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
}
.volume-slider-fill {
position: absolute;
inset: 0;
top: 0;
left: 0;
bottom: 0;
width: 0;
background: var(--player-primary);
border-radius: var(--player-radius-full);
+2 -1
View File
@@ -4,7 +4,8 @@
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.28);
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
z-index: var(--player-z-loading);
pointer-events: none;
}