52ca1ef6c2
Introduces isLiveBroadcast to VideoState and PlayerContext to detect live streams (duration is Infinity or 0). Updates ControlsLayer to show a 'LIVE' badge and hide progress/time for live broadcasts. Adjusts VideoElement to set and update isLiveBroadcast on metadata and duration changes. Adds related CSS for the live indicator and improves ESLint config for unused vars.
201 lines
5.3 KiB
TypeScript
201 lines
5.3 KiB
TypeScript
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
|
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
|
import type { Translations } from '../i18n'
|
|
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
|
|
|
type SelectedQuality = PlayerSettings['quality']
|
|
type SelectedSubtitle = PlayerSettings['subtitle']
|
|
|
|
interface PlayerContextType extends PlayerContextValue {
|
|
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
|
setUIState: React.Dispatch<React.SetStateAction<UIState>>
|
|
translations: Translations
|
|
}
|
|
|
|
const PlayerContext = createContext<PlayerContextType | null>(null)
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export const usePlayerContext = () => {
|
|
const context = useContext(PlayerContext)
|
|
if (!context) {
|
|
throw new Error('usePlayerContext must be used within a PlayerProvider')
|
|
}
|
|
return context
|
|
}
|
|
|
|
interface PlayerProviderProps {
|
|
children: React.ReactNode
|
|
initialVolume?: number
|
|
initialMuted?: boolean
|
|
initialPlaybackRate?: number
|
|
language?: string
|
|
}
|
|
|
|
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|
children,
|
|
initialVolume = 1,
|
|
initialMuted = false,
|
|
initialPlaybackRate = 1,
|
|
language,
|
|
}) => {
|
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
// Get translations based on language prop or browser language
|
|
const translations = getTranslations(language || detectBrowserLanguage())
|
|
|
|
const [videoState, setVideoState] = useState<VideoState>({
|
|
playing: false,
|
|
currentTime: 0,
|
|
duration: 0,
|
|
buffered: 0,
|
|
volume: initialVolume,
|
|
muted: initialMuted,
|
|
playbackRate: initialPlaybackRate,
|
|
fullscreen: false,
|
|
pictureInPicture: false,
|
|
loading: false,
|
|
error: null,
|
|
seeking: false,
|
|
isLiveBroadcast: false,
|
|
})
|
|
|
|
const [uiState, setUIState] = useState<UIState>({
|
|
controlsVisible: true,
|
|
settingsOpen: false,
|
|
volumeControlOpen: false,
|
|
qualityMenuOpen: false,
|
|
subtitleMenuOpen: false,
|
|
})
|
|
|
|
const [settings, setSettings] = useState<PlayerSettings>({
|
|
quality: null,
|
|
subtitle: null,
|
|
audioTrack: null,
|
|
playbackRate: initialPlaybackRate,
|
|
})
|
|
|
|
// Video controls
|
|
const play = useCallback(() => {
|
|
videoRef.current?.play()
|
|
}, [])
|
|
|
|
const pause = useCallback(() => {
|
|
videoRef.current?.pause()
|
|
}, [])
|
|
|
|
const togglePlay = useCallback(() => {
|
|
if (videoState.playing) {
|
|
pause()
|
|
} else {
|
|
play()
|
|
}
|
|
}, [videoState.playing, play, pause])
|
|
|
|
const seek = useCallback((time: number) => {
|
|
if (videoRef.current) {
|
|
videoRef.current.currentTime = time
|
|
}
|
|
}, [])
|
|
|
|
const setVolume = useCallback((volume: number) => {
|
|
if (videoRef.current) {
|
|
const clampedVolume = Math.max(0, Math.min(1, volume))
|
|
videoRef.current.volume = clampedVolume
|
|
setVideoState((prev) => ({ ...prev, volume: clampedVolume }))
|
|
}
|
|
}, [])
|
|
|
|
const toggleMute = useCallback(() => {
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = !videoRef.current.muted
|
|
setVideoState((prev) => ({ ...prev, muted: !prev.muted }))
|
|
}
|
|
}, [])
|
|
|
|
const setPlaybackRate = useCallback((rate: number) => {
|
|
if (videoRef.current) {
|
|
videoRef.current.playbackRate = rate
|
|
setVideoState((prev) => ({ ...prev, playbackRate: rate }))
|
|
setSettings((prev) => ({ ...prev, playbackRate: rate }))
|
|
}
|
|
}, [])
|
|
|
|
// Fullscreen & PIP
|
|
const toggleFullscreen = useCallback(() => {
|
|
if (!document.fullscreenElement) {
|
|
containerRef.current?.requestFullscreen()
|
|
} else {
|
|
document.exitFullscreen()
|
|
}
|
|
}, [])
|
|
|
|
const togglePictureInPicture = useCallback(async () => {
|
|
if (!document.pictureInPictureElement) {
|
|
try {
|
|
await videoRef.current?.requestPictureInPicture()
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
setVideoState((prev) => ({ ...prev, error }))
|
|
}
|
|
}
|
|
} else {
|
|
await document.exitPictureInPicture()
|
|
}
|
|
}, [])
|
|
|
|
// UI controls
|
|
const showControls = useCallback(() => {
|
|
setUIState((prev) => ({ ...prev, controlsVisible: true }))
|
|
}, [])
|
|
|
|
const hideControls = useCallback(() => {
|
|
setUIState((prev) => ({ ...prev, controlsVisible: false }))
|
|
}, [])
|
|
|
|
const toggleSettings = useCallback(() => {
|
|
setUIState((prev) => ({ ...prev, settingsOpen: !prev.settingsOpen }))
|
|
}, [])
|
|
|
|
// Settings
|
|
const setQuality = useCallback((quality: SelectedQuality) => {
|
|
setSettings((prev) => ({ ...prev, quality }))
|
|
}, [])
|
|
|
|
const setSubtitle = useCallback((subtitle: SelectedSubtitle) => {
|
|
setSettings((prev) => ({ ...prev, subtitle }))
|
|
}, [])
|
|
|
|
const setAudioTrack = useCallback((audioTrack: AudioTrack | null) => {
|
|
setSettings((prev) => ({ ...prev, audioTrack }))
|
|
}, [])
|
|
|
|
const value: PlayerContextType = {
|
|
videoState,
|
|
uiState,
|
|
settings,
|
|
videoRef,
|
|
containerRef,
|
|
setVideoState,
|
|
setUIState,
|
|
translations,
|
|
play,
|
|
pause,
|
|
togglePlay,
|
|
seek,
|
|
setVolume,
|
|
toggleMute,
|
|
setPlaybackRate,
|
|
toggleFullscreen,
|
|
togglePictureInPicture,
|
|
showControls,
|
|
hideControls,
|
|
toggleSettings,
|
|
setQuality,
|
|
setSubtitle,
|
|
setAudioTrack,
|
|
}
|
|
|
|
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
|
}
|