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(git push:*)",
"Bash(npm test:*)",
"Bash(npm install:*)"
"Bash(npm install:*)",
"Bash(pnpm lint:*)"
],
"deny": [],
"ask": []
+4
View File
@@ -35,6 +35,10 @@ export default [
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': 'warn'
+50
View File
@@ -85,6 +85,45 @@
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) {
.controls-bar {
padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm);
@@ -99,4 +138,15 @@
.controls-right {
gap: var(--player-spacing-xs);
}
.live-indicator {
padding: 3px 8px;
font-size: 11px;
gap: 5px;
}
.live-dot {
width: 6px;
height: 6px;
}
}
+12 -2
View File
@@ -210,17 +210,27 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
{/* Bottom controls bar */}
<div className="controls-bar">
{/* Progress bar (full width on top) */}
{/* Progress bar (full width on top) - hidden for live broadcasts */}
{!videoState.isLiveBroadcast && (
<div className="progress-container">
<ProgressBar />
</div>
)}
{/* Control buttons */}
<div className="controls-row">
<div className="controls-left">
<PlayPauseButton />
<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 className="controls-right">
+21
View File
@@ -90,11 +90,16 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const video = videoRef.current
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) => ({
...prev,
duration: video.duration,
volume: video.volume,
muted: video.muted,
isLiveBroadcast,
}))
// Enable default subtitle if specified
@@ -111,6 +116,21 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onLoadedMetadata?.()
}, [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 video = videoRef.current
if (!video) return
@@ -721,6 +741,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
+1
View File
@@ -57,6 +57,7 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
loading: false,
error: null,
seeking: false,
isLiveBroadcast: false,
})
const [uiState, setUIState] = useState<UIState>({
+15 -5
View File
@@ -28,9 +28,19 @@ export default class Hls {
}
}
loadSource(_src: string) {}
attachMedia(_video: HTMLVideoElement) {}
destroy() {}
on(_event: string, _handler: Function) {}
off(_event: string, _handler: Function) {}
loadSource(_src: string) {
// Mock implementation
}
attachMedia(_video: HTMLVideoElement) {
// 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
error: Error | null
seeking: boolean
isLiveBroadcast: boolean // True for actual live broadcasts (not VOD)
}
export interface UIState {