feat: implement phase2 player isolation and media config
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user