feat: implement phase2 player isolation and media config

This commit is contained in:
hibna
2026-02-12 18:39:11 +03:00
parent fcd2a14a05
commit 73d5d65d2b
8 changed files with 226 additions and 20 deletions
+19
View File
@@ -952,6 +952,7 @@ function QualitySelector() {
#### Özellikler
- ✅ Input/textarea alanlarında devre dışı
- ✅ Sadece aktif/focus olan player için çalışır (çoklu player uyumu)
- ✅ Default tarayıcı davranışını önler
- ✅ Enable/disable ile açılıp kapatılabilir
- ✅ Fullscreen'de de çalışır
@@ -1070,6 +1071,9 @@ interface VideoPlayerProps {
// Poster/thumbnail resmi
poster?: string
// Protokol override (default: auto)
protocol?: 'auto' | 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
// Otomatik oynat
autoplay?: boolean
@@ -1079,6 +1083,21 @@ interface VideoPlayerProps {
// Başlangıçta sessiz
muted?: boolean
// Başlangıç ses seviyesi (0-1)
volume?: number
// Oynatma hızı
playbackRate?: number
// Başlangıç zamanı (saniye)
currentTime?: number
// Video element attribute'ları
crossOrigin?: '' | 'anonymous' | 'use-credentials'
preload?: 'none' | 'metadata' | 'auto'
playsInline?: boolean
controlsList?: string
// Kontrolleri göster (default: true)
controls?: boolean
+15
View File
@@ -63,6 +63,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
- `P` - Picture-in-Picture
- `0-9` - Jump to percentage (10%-90%)
- `Home` / `End` - Jump to start/end
- Shortcuts only work for the currently active/focused player instance
### 📱 Touch Gestures
- **Tap** - Play/Pause
@@ -165,6 +166,15 @@ function App() {
/>
```
### Force Protocol (Override Auto Detection)
```tsx
<VideoPlayer
src="https://cdn.example.com/video"
protocol="hls"
/>
```
### IPTV Streaming
```tsx
@@ -310,12 +320,17 @@ video-player/
|------|------|---------|-------------|
| `src` | `string` | **required** | Video source URL (MP4, WebM, HLS, IPTV .ts) |
| `poster` | `string` | - | Poster image URL |
| `protocol` | `'auto' \| 'native' \| 'hls' \| 'rtmp' \| 'dash' \| 'mpegts'` | `'auto'` | Force playback engine instead of URL auto-detection |
| `autoplay` | `boolean` | `false` | Auto-play video on load |
| `loop` | `boolean` | `false` | Loop video playback |
| `muted` | `boolean` | `false` | Start muted |
| `volume` | `number` | - | Initial volume (0-1) |
| `playbackRate` | `number` | - | Playback speed (0.25, 0.5, 1, 1.5, 2, etc.) |
| `currentTime` | `number` | - | Initial playback position in seconds |
| `crossOrigin` | `'' \| 'anonymous' \| 'use-credentials'` | - | Sets the video `crossOrigin` attribute |
| `preload` | `'none' \| 'metadata' \| 'auto'` | `'metadata'` | Sets the video preload strategy |
| `playsInline` | `boolean` | `true` | Enables inline playback on mobile browsers |
| `controlsList` | `string` | - | Passes `controlsList` attribute to the video element |
| `controls` | `boolean` | `true` | Show player controls |
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
| `theme` | `PlayerTheme` | - | Custom theme colors |
+25 -4
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useCallback, useState } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
import type { SubtitleTrack, AudioTrack, VideoQuality, VideoProtocol } from '../types'
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
import { setupHlsInstance } from '../utils/hlsSetup'
@@ -14,12 +14,17 @@ import './VideoElement.css'
interface VideoElementProps {
src: string
poster?: string
protocol?: 'auto' | VideoProtocol
autoplay?: boolean
loop?: boolean
muted?: boolean
volume?: number
playbackRate?: number
currentTime?: number
crossOrigin?: '' | 'anonymous' | 'use-credentials'
preload?: 'none' | 'metadata' | 'auto'
playsInline?: boolean
controlsList?: string
subtitles?: SubtitleTrack[]
onPlay?: () => void
onPause?: () => void
@@ -45,12 +50,17 @@ interface VideoElementProps {
export const VideoElement: React.FC<VideoElementProps> = ({
src,
poster,
protocol = 'auto',
autoplay = false,
loop = false,
muted = false,
volume,
playbackRate,
currentTime: initialCurrentTime,
crossOrigin,
preload = 'metadata',
playsInline = true,
controlsList,
subtitles = [],
onPlay,
onPause,
@@ -416,7 +426,15 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
// Detect video protocol
const detection = detectVideoProtocol(src)
const detectedProtocol = detectVideoProtocol(src)
const detection =
protocol === 'auto'
? detectedProtocol
: {
...detectedProtocol,
protocol,
needsSpecialPlayer: protocol !== 'native',
}
let cleanupFn: (() => void) | null = null
const teardownPlayer = () => {
@@ -596,6 +614,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
}, [
src,
protocol,
autoplay,
videoRef,
handleError,
@@ -762,8 +781,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
poster={poster}
loop={loop}
muted={muted}
playsInline
preload="metadata"
crossOrigin={crossOrigin}
playsInline={playsInline}
preload={preload}
controlsList={controlsList}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
+53 -13
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'
import React, { useMemo, useState, useCallback } from 'react'
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
import { VideoElement } from './VideoElement'
import { ControlsLayer } from './ControlsLayer'
@@ -38,14 +38,20 @@ const VideoPlayerContent: React.FC<
> = ({
src,
poster,
protocol = 'auto',
autoplay = false,
loop = false,
muted = false,
volume,
playbackRate,
currentTime,
crossOrigin,
preload = 'metadata',
playsInline = true,
controlsList,
controls = true,
subtitles = [],
theme,
keyboardShortcuts = true,
pictureInPicture = true,
className = '',
@@ -75,21 +81,55 @@ const VideoPlayerContent: React.FC<
}) => {
const { containerRef, uiState } = usePlayerContext()
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
const themedStyle = useMemo<React.CSSProperties>(() => {
if (!theme) {
return style || {}
}
const cssVariables: Record<string, string> = {}
if (theme.primaryColor) {
cssVariables['--player-primary'] = theme.primaryColor
}
if (theme.accentColor) {
cssVariables['--player-primary-hover'] = theme.accentColor
}
if (theme.backgroundColor) {
cssVariables['--player-bg'] = theme.backgroundColor
}
if (theme.textColor) {
cssVariables['--player-text'] = theme.textColor
}
return {
...cssVariables,
...(style || {}),
} as React.CSSProperties
}, [theme, style])
// Merge manual subtitles and HLS-detected subtitles
const allSubtitles = [...subtitles, ...hlsSubtitles]
return (
<div ref={containerRef} className={`video-player ${controlsHiddenClass} ${className}`} style={style}>
<div
ref={containerRef}
className={`video-player ${controlsHiddenClass} ${className}`}
style={themedStyle}
tabIndex={0}
>
<VideoElement
src={src}
poster={poster}
protocol={protocol}
autoplay={autoplay}
loop={loop}
muted={muted}
volume={volume}
playbackRate={playbackRate}
currentTime={currentTime}
crossOrigin={crossOrigin}
preload={preload}
playsInline={playsInline}
controlsList={controlsList}
subtitles={subtitles}
onPlay={onPlay}
onPause={onPause}
@@ -127,12 +167,17 @@ const VideoPlayerContent: React.FC<
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
src,
poster,
protocol = 'auto',
autoplay = false,
loop = false,
muted = false,
volume,
playbackRate,
currentTime,
crossOrigin,
preload = 'metadata',
playsInline = true,
controlsList,
controls = true,
subtitles = [],
theme,
@@ -162,17 +207,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [qualities, setQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
// Apply theme CSS variables
useEffect(() => {
if (theme) {
const root = document.documentElement
if (theme.primaryColor) root.style.setProperty('--player-primary', theme.primaryColor)
if (theme.accentColor) root.style.setProperty('--player-primary-hover', theme.accentColor)
if (theme.backgroundColor) root.style.setProperty('--player-bg', theme.backgroundColor)
if (theme.textColor) root.style.setProperty('--player-text', theme.textColor)
}
}, [theme])
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
setAudioTracks(tracks)
}, [])
@@ -190,14 +224,20 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<VideoPlayerContent
src={src}
poster={poster}
protocol={protocol}
autoplay={autoplay}
loop={loop}
muted={muted}
volume={volume}
playbackRate={playbackRate}
currentTime={currentTime}
crossOrigin={crossOrigin}
preload={preload}
playsInline={playsInline}
controlsList={controlsList}
controls={controls}
subtitles={subtitles}
theme={theme}
keyboardShortcuts={keyboardShortcuts}
pictureInPicture={pictureInPicture}
className={className}
@@ -77,6 +77,17 @@ describe('SettingsMenu', () => {
audioTrack: 'Audio Track',
settings: 'Settings',
level: 'Level',
play: 'Play',
pause: 'Pause',
mute: 'Mute',
unmute: 'Unmute',
enterFullscreen: 'Enter fullscreen',
exitFullscreen: 'Exit fullscreen',
enterPictureInPicture: 'Enter picture-in-picture',
exitPictureInPicture: 'Exit picture-in-picture',
videoProgress: 'Video progress',
volume: 'Volume',
live: 'LIVE',
},
}
})
+30 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useKeyboardShortcuts } from './useKeyboardShortcuts'
const { contextState } = vi.hoisted(() => ({
@@ -17,6 +17,23 @@ const TestComponent = ({ enabled = true }: { enabled?: boolean }) => {
return <div>keyboard-shortcuts-test</div>
}
const ActivePlayerTestComponent = ({ enabled = true }: { enabled?: boolean }) => {
useKeyboardShortcuts(enabled)
return (
<div
data-testid="player"
ref={(node) => {
if (contextState.value?.containerRef) {
contextState.value.containerRef.current = node
}
}}
>
keyboard-shortcuts-active-test
</div>
)
}
describe('useKeyboardShortcuts', () => {
beforeEach(() => {
contextState.value = {
@@ -71,4 +88,16 @@ describe('useKeyboardShortcuts', () => {
fireEvent.keyDown(document.querySelector('input')!, { key: 'k' })
expect(contextState.value.togglePlay).not.toHaveBeenCalled()
})
it('triggers shortcuts only for active/focused player', () => {
contextState.value.containerRef = { current: null }
render(<ActivePlayerTestComponent />)
fireEvent.keyDown(window, { key: 'k' })
expect(contextState.value.togglePlay).not.toHaveBeenCalled()
fireEvent.mouseDown(screen.getByTestId('player'))
fireEvent.keyDown(window, { key: 'k' })
expect(contextState.value.togglePlay).toHaveBeenCalledTimes(1)
})
})
+68 -2
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
export const useKeyboardShortcuts = (enabled: boolean = true) => {
const {
videoState,
containerRef,
togglePlay,
seek,
setVolume,
@@ -11,13 +12,76 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
toggleFullscreen,
togglePictureInPicture,
} = usePlayerContext()
const [isActivePlayer, setIsActivePlayer] = useState(false)
useEffect(() => {
if (!enabled) {
setIsActivePlayer(false)
return
}
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
const container = containerRef?.current
if (!container) {
return
}
const target = e.target as Node | null
const isInsidePlayer = !!target && container.contains(target)
setIsActivePlayer(isInsidePlayer)
if (isInsidePlayer && document.activeElement !== container) {
container.focus({ preventScroll: true })
}
}
const handleFocusIn = (e: FocusEvent) => {
const container = containerRef?.current
if (!container) {
return
}
const target = e.target as Node | null
setIsActivePlayer(!!target && container.contains(target))
}
const handleWindowBlur = () => {
setIsActivePlayer(false)
}
const container = containerRef?.current
if (container && container.contains(document.activeElement)) {
setIsActivePlayer(true)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown, { passive: true })
document.addEventListener('focusin', handleFocusIn)
window.addEventListener('blur', handleWindowBlur)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
document.removeEventListener('focusin', handleFocusIn)
window.removeEventListener('blur', handleWindowBlur)
}
}, [enabled, containerRef])
useEffect(() => {
if (!enabled) return
const handleKeyDown = (e: KeyboardEvent) => {
const container = containerRef?.current
if (container && !isActivePlayer) {
return
}
// Don't trigger if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target instanceof HTMLElement && e.target.isContentEditable)
) {
return
}
@@ -108,6 +172,8 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
enabled,
isActivePlayer,
containerRef,
videoState.currentTime,
videoState.duration,
videoState.volume,
+5
View File
@@ -37,12 +37,17 @@ export interface PlayerTheme {
export interface VideoPlayerProps {
src: string
poster?: string
protocol?: 'auto' | VideoProtocol
autoplay?: boolean
loop?: boolean
muted?: boolean
volume?: number // 0-1 arası ses seviyesi
playbackRate?: number // Oynatma hızı (0.25, 0.5, 1, 1.5, 2, vb.)
currentTime?: number // Başlangıç zamanı (saniye)
crossOrigin?: '' | 'anonymous' | 'use-credentials'
preload?: 'none' | 'metadata' | 'auto'
playsInline?: boolean
controlsList?: string
controls?: boolean
subtitles?: SubtitleTrack[]
theme?: PlayerTheme