diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 610b19d..c660b8b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(npm publish)", "Bash(git push:*)", "Bash(npm test:*)", - "Bash(npm install:*)" + "Bash(npm install:*)", + "Bash(pnpm lint:*)" ], "deny": [], "ask": [] diff --git a/eslint.config.js b/eslint.config.js index 13270fa..6087bb9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' diff --git a/src/components/ControlsLayer.css b/src/components/ControlsLayer.css index 6678a7f..3bae18b 100644 --- a/src/components/ControlsLayer.css +++ b/src/components/ControlsLayer.css @@ -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; + } } diff --git a/src/components/ControlsLayer.tsx b/src/components/ControlsLayer.tsx index df6b629..c1814df 100644 --- a/src/components/ControlsLayer.tsx +++ b/src/components/ControlsLayer.tsx @@ -210,17 +210,27 @@ export const ControlsLayer: React.FC = ({ {/* Bottom controls bar */}
- {/* Progress bar (full width on top) */} -
- -
+ {/* Progress bar (full width on top) - hidden for live broadcasts */} + {!videoState.isLiveBroadcast && ( +
+ +
+ )} {/* Control buttons */}
- + {/* Time display - hidden for live broadcasts */} + {!videoState.isLiveBroadcast && } + {/* Show "LIVE" badge for live broadcasts */} + {videoState.isLiveBroadcast && ( +
+ + LIVE +
+ )}
diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 78d9a6d..5f5e3fb 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -90,11 +90,16 @@ export const VideoElement: React.FC = ({ 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 = ({ 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 = ({ onPause={handlePause} onTimeUpdate={handleTimeUpdate} onLoadedMetadata={handleLoadedMetadata} + onDurationChange={handleDurationChange} onVolumeChange={handleVolumeChange} onSeeking={handleSeeking} onSeeked={handleSeeked} diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx index 62ec1f6..2779394 100644 --- a/src/contexts/PlayerContext.tsx +++ b/src/contexts/PlayerContext.tsx @@ -57,6 +57,7 @@ export const PlayerProvider: React.FC = ({ loading: false, error: null, seeking: false, + isLiveBroadcast: false, }) const [uiState, setUIState] = useState({ diff --git a/src/test/mocks/hls.mock.ts b/src/test/mocks/hls.mock.ts index 388b035..da3fe99 100644 --- a/src/test/mocks/hls.mock.ts +++ b/src/test/mocks/hls.mock.ts @@ -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 + } } diff --git a/src/types/index.ts b/src/types/index.ts index 3ea68cb..d960339 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 {