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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user