Add live broadcast detection and UI indicator

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.
This commit is contained in:
hibna
2025-11-04 06:52:24 +03:00
parent 870a4d5a4e
commit 52ca1ef6c2
8 changed files with 109 additions and 11 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"Bash(npm publish)", "Bash(npm publish)",
"Bash(git push:*)", "Bash(git push:*)",
"Bash(npm test:*)", "Bash(npm test:*)",
"Bash(npm install:*)" "Bash(npm install:*)",
"Bash(pnpm lint:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+4
View File
@@ -35,6 +35,10 @@ export default [
...tsPlugin.configs.recommended.rules, ...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': 'warn' 'react-refresh/only-export-components': 'warn'
+50
View File
@@ -85,6 +85,45 @@
margin-left: auto; margin-left: auto;
} }
/* Live indicator */
.live-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(220, 38, 38, 0.2);
border: 1px solid rgb(220, 38, 38);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: var(--player-text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.live-dot {
width: 8px;
height: 8px;
background: rgb(220, 38, 38);
border-radius: 50%;
animation: live-pulse 2s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(0.9);
}
}
.live-text {
line-height: 1;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.controls-bar { .controls-bar {
padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm); padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm);
@@ -99,4 +138,15 @@
.controls-right { .controls-right {
gap: var(--player-spacing-xs); gap: var(--player-spacing-xs);
} }
.live-indicator {
padding: 3px 8px;
font-size: 11px;
gap: 5px;
}
.live-dot {
width: 6px;
height: 6px;
}
} }
+15 -5
View File
@@ -210,17 +210,27 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
{/* Bottom controls bar */} {/* Bottom controls bar */}
<div className="controls-bar"> <div className="controls-bar">
{/* Progress bar (full width on top) */} {/* Progress bar (full width on top) - hidden for live broadcasts */}
<div className="progress-container"> {!videoState.isLiveBroadcast && (
<ProgressBar /> <div className="progress-container">
</div> <ProgressBar />
</div>
)}
{/* Control buttons */} {/* Control buttons */}
<div className="controls-row"> <div className="controls-row">
<div className="controls-left"> <div className="controls-left">
<PlayPauseButton /> <PlayPauseButton />
<VolumeControl /> <VolumeControl />
<TimeDisplay /> {/* Time display - hidden for live broadcasts */}
{!videoState.isLiveBroadcast && <TimeDisplay />}
{/* Show "LIVE" badge for live broadcasts */}
{videoState.isLiveBroadcast && (
<div className="live-indicator">
<span className="live-dot"></span>
<span className="live-text">LIVE</span>
</div>
)}
</div> </div>
<div className="controls-right"> <div className="controls-right">
+21
View File
@@ -90,11 +90,16 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const video = videoRef.current const video = videoRef.current
if (!video) return if (!video) return
// Check if this is a live broadcast (duration is Infinity for live streams)
const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0
console.log('[VideoElement] Is live broadcast?', isLiveBroadcast, 'duration:', video.duration)
setVideoState((prev) => ({ setVideoState((prev) => ({
...prev, ...prev,
duration: video.duration, duration: video.duration,
volume: video.volume, volume: video.volume,
muted: video.muted, muted: video.muted,
isLiveBroadcast,
})) }))
// Enable default subtitle if specified // Enable default subtitle if specified
@@ -111,6 +116,21 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onLoadedMetadata?.() onLoadedMetadata?.()
}, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle]) }, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle])
const handleDurationChange = useCallback(() => {
const video = videoRef.current
if (!video) return
// Re-check if this is a live broadcast when duration changes
const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0
console.log('[VideoElement] Duration changed. Is live broadcast?', isLiveBroadcast, 'duration:', video.duration)
setVideoState((prev) => ({
...prev,
duration: video.duration,
isLiveBroadcast,
}))
}, [videoRef, setVideoState])
const handleVolumeChange = useCallback(() => { const handleVolumeChange = useCallback(() => {
const video = videoRef.current const video = videoRef.current
if (!video) return if (!video) return
@@ -721,6 +741,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onPause={handlePause} onPause={handlePause}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange} onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking} onSeeking={handleSeeking}
onSeeked={handleSeeked} onSeeked={handleSeeked}
+1
View File
@@ -57,6 +57,7 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
loading: false, loading: false,
error: null, error: null,
seeking: false, seeking: false,
isLiveBroadcast: false,
}) })
const [uiState, setUIState] = useState<UIState>({ const [uiState, setUIState] = useState<UIState>({
+15 -5
View File
@@ -28,9 +28,19 @@ export default class Hls {
} }
} }
loadSource(_src: string) {} loadSource(_src: string) {
attachMedia(_video: HTMLVideoElement) {} // Mock implementation
destroy() {} }
on(_event: string, _handler: Function) {} attachMedia(_video: HTMLVideoElement) {
off(_event: string, _handler: Function) {} // Mock implementation
}
destroy() {
// Mock implementation
}
on(_event: string, _handler: (...args: unknown[]) => void) {
// Mock implementation
}
off(_event: string, _handler: (...args: unknown[]) => void) {
// Mock implementation
}
} }
+1
View File
@@ -72,6 +72,7 @@ export interface VideoState {
loading: boolean loading: boolean
error: Error | null error: Error | null
seeking: boolean seeking: boolean
isLiveBroadcast: boolean // True for actual live broadcasts (not VOD)
} }
export interface UIState { export interface UIState {