From 73d5d65d2b729b85247167f31286cd691aca1f37 Mon Sep 17 00:00:00 2001 From: hibna Date: Thu, 12 Feb 2026 18:39:11 +0300 Subject: [PATCH] feat: implement phase2 player isolation and media config --- DOCUMENTATION.md | 19 ++++++ README.md | 15 +++++ src/components/VideoElement.tsx | 29 +++++++-- src/components/VideoPlayer.tsx | 66 ++++++++++++++++---- src/components/menus/SettingsMenu.test.tsx | 11 ++++ src/hooks/useKeyboardShortcuts.test.tsx | 31 +++++++++- src/hooks/useKeyboardShortcuts.ts | 70 +++++++++++++++++++++- src/types/index.ts | 5 ++ 8 files changed, 226 insertions(+), 20 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a965c9d..788b25d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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 diff --git a/README.md b/README.md index 7a6b41e..ff3db7e 100644 --- a/README.md +++ b/README.md @@ -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 + +``` + ### 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 | diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index e57fa47..09890e0 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -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 = ({ 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 = ({ } // 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 = ({ } }, [ src, + protocol, autoplay, videoRef, handleError, @@ -762,8 +781,10 @@ export const VideoElement: React.FC = ({ poster={poster} loop={loop} muted={muted} - playsInline - preload="metadata" + crossOrigin={crossOrigin} + playsInline={playsInline} + preload={preload} + controlsList={controlsList} onPlay={handlePlay} onPause={handlePause} onTimeUpdate={handleTimeUpdate} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 3b53166..03f97b3 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -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(() => { + if (!theme) { + return style || {} + } + + const cssVariables: Record = {} + 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 ( -
+
= ({ 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 = ({ const [qualities, setQualities] = useState([]) const [hlsSubtitles, setHlsSubtitles] = useState([]) - // 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 = ({ { 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', }, } }) diff --git a/src/hooks/useKeyboardShortcuts.test.tsx b/src/hooks/useKeyboardShortcuts.test.tsx index ab69342..8d1dcda 100644 --- a/src/hooks/useKeyboardShortcuts.test.tsx +++ b/src/hooks/useKeyboardShortcuts.test.tsx @@ -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
keyboard-shortcuts-test
} +const ActivePlayerTestComponent = ({ enabled = true }: { enabled?: boolean }) => { + useKeyboardShortcuts(enabled) + + return ( +
{ + if (contextState.value?.containerRef) { + contextState.value.containerRef.current = node + } + }} + > + keyboard-shortcuts-active-test +
+ ) +} + 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() + + 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) + }) }) diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index e8d03a6..1e6c2ad 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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, diff --git a/src/types/index.ts b/src/types/index.ts index 32506b9..63a2d0b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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