Some fixes

This commit is contained in:
hibna
2026-02-12 17:54:16 +03:00
parent f57ee77c56
commit 8a32c5c1b3
18 changed files with 997 additions and 135 deletions
+60 -26
View File
@@ -405,6 +405,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
useEffect(() => {
const video = videoRef.current
if (!video) return
let isCancelled = false
setAvailableAudioTracks([])
onAudioTracksLoaded?.([])
@@ -426,6 +427,38 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const detection = detectVideoProtocol(src)
let cleanupFn: (() => void) | null = null
const teardownPlayer = () => {
if (cleanupFn) {
cleanupFn()
cleanupFn = null
}
// Also check for any lingering player instances
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
if ((video as any).__rtmpInstance) {
const rtmp = (video as any).__rtmpInstance
if (rtmp && typeof rtmp.destroy === 'function') {
rtmp.destroy()
}
delete (video as any).__rtmpInstance
}
if ((video as any).__mpegtsInstance) {
const mpegts = (video as any).__mpegtsInstance
if (mpegts && typeof mpegts.destroy === 'function') {
mpegts.destroy()
}
delete (video as any).__mpegtsInstance
}
}
console.log('[VideoElement] Source:', src)
console.log('[VideoElement] Detected protocol:', detection.protocol)
console.log('[VideoElement] Is live stream?', detection.isLive)
@@ -451,20 +484,29 @@ export const VideoElement: React.FC<VideoElementProps> = ({
src,
autoplay,
onAudioTracksLoaded: (tracks) => {
if (isCancelled) return
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
},
onQualityLevelsLoaded: (qualities) => {
if (isCancelled) return
setAvailableQualities(qualities)
onQualityLevelsLoaded?.(qualities)
},
onSubtitleTracksLoaded: (tracks) => {
if (isCancelled) return
setHlsSubtitles(tracks)
onSubtitleTracksLoaded?.(tracks)
},
onError: handleError,
})
if (isCancelled) {
teardownPlayer()
return
}
} else {
if (isCancelled) return
console.log('[VideoElement] Using native HLS playback')
video.src = src
if (autoplay) {
@@ -484,6 +526,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError: handleError,
onLoadedMetadata,
})
if (isCancelled) {
teardownPlayer()
return
}
break
}
@@ -497,11 +544,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError: handleError,
onLoadedMetadata,
})
if (isCancelled) {
teardownPlayer()
return
}
break
}
case 'dash': {
// DASH streaming - not yet implemented
if (isCancelled) return
const error = new Error('DASH streaming is not yet supported')
console.error('[VideoElement]', error.message)
setVideoState((prev) => ({ ...prev, error, loading: false }))
@@ -512,6 +565,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
case 'native':
default: {
// Native HTML5 video (MP4, WebM, etc.)
if (isCancelled) return
console.log('[VideoElement] Using native video.src')
video.src = src
if (autoplay) {
@@ -528,6 +582,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
} else {
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
}
if (isCancelled) return
console.error('[VideoElement] Setup error:', error)
setVideoState((prev) => ({
...prev,
@@ -538,35 +595,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
}
setupPlayer()
void setupPlayer()
// Cleanup function
return () => {
if (cleanupFn) {
cleanupFn()
}
// Also check for any lingering player instances
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
if ((video as any).__rtmpInstance) {
const rtmp = (video as any).__rtmpInstance
if (rtmp && typeof rtmp.destroy === 'function') {
rtmp.destroy()
}
delete (video as any).__rtmpInstance
}
if ((video as any).__mpegtsInstance) {
const mpegts = (video as any).__mpegtsInstance
if (mpegts && typeof mpegts.destroy === 'function') {
mpegts.destroy()
}
delete (video as any).__mpegtsInstance
}
isCancelled = true
teardownPlayer()
}
}, [
src,
+89 -78
View File
@@ -1,130 +1,141 @@
import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { VideoPlayer } from './VideoPlayer';
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor, fireEvent, act } from '@testing-library/react'
import { VideoPlayer } from './VideoPlayer'
describe('VideoPlayer', () => {
const defaultProps = {
src: 'https://example.com/video.mp4',
};
}
it('renders video player container', () => {
const { container } = render(<VideoPlayer {...defaultProps} />);
expect(container.querySelector('.video-player')).toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} />)
expect(container.querySelector('.video-player')).toBeInTheDocument()
})
it('renders video element', () => {
const { container } = render(<VideoPlayer {...defaultProps} />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} />)
const video = container.querySelector('video')
expect(video).toBeInTheDocument()
})
it('renders with autoplay prop', () => {
const { container } = render(<VideoPlayer {...defaultProps} autoplay />);
const video = container.querySelector('video');
const { container } = render(<VideoPlayer {...defaultProps} autoplay />)
const video = container.querySelector('video')
// VideoElement handles autoplay programmatically via play() method
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
})
it('renders with muted prop', () => {
const { container } = render(<VideoPlayer {...defaultProps} muted />);
const video = container.querySelector('video');
const { container } = render(<VideoPlayer {...defaultProps} muted />)
const video = container.querySelector('video')
// Muted state is managed through VideoElement
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
})
it('applies loop when enabled', () => {
const { container } = render(<VideoPlayer {...defaultProps} loop />);
const video = container.querySelector('video');
expect(video).toHaveAttribute('loop');
});
const { container } = render(<VideoPlayer {...defaultProps} loop />)
const video = container.querySelector('video')
expect(video).toHaveAttribute('loop')
})
it('applies custom className', () => {
const className = 'custom-player';
const { container } = render(<VideoPlayer {...defaultProps} className={className} />);
expect(container.querySelector('.video-player')).toHaveClass('video-player', className);
});
const className = 'custom-player'
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
expect(container.querySelector('.video-player')).toHaveClass('video-player', className)
})
it('calls onPlay callback when play event fires', async () => {
const onPlay = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />);
const onPlay = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('play'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.play(video)
})
await waitFor(() => {
expect(onPlay).toHaveBeenCalled();
});
});
expect(onPlay).toHaveBeenCalled()
})
})
it('calls onPause callback when pause event fires', async () => {
const onPause = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />);
const onPause = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('pause'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.pause(video)
})
await waitFor(() => {
expect(onPause).toHaveBeenCalled();
});
});
expect(onPause).toHaveBeenCalled()
})
})
it('calls onEnded callback when ended event fires', async () => {
const onEnded = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />);
const onEnded = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('ended'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.ended(video)
})
await waitFor(() => {
expect(onEnded).toHaveBeenCalled();
});
});
expect(onEnded).toHaveBeenCalled()
})
})
it('calls onTimeUpdate callback with current time', async () => {
const onTimeUpdate = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />);
const onTimeUpdate = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />)
const video = container.querySelector('video') as HTMLVideoElement;
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true });
video.dispatchEvent(new Event('timeupdate'));
const video = container.querySelector('video') as HTMLVideoElement
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true })
act(() => {
fireEvent.timeUpdate(video)
})
await waitFor(() => {
expect(onTimeUpdate).toHaveBeenCalledWith(10.5);
});
});
expect(onTimeUpdate).toHaveBeenCalledWith(10.5)
})
})
it('renders with subtitles prop', () => {
it('renders with subtitles prop', async () => {
const subtitles = [
{ src: 'subtitles-en.vtt', lang: 'en', label: 'English' },
{ src: 'subtitles-tr.vtt', lang: 'tr', label: 'Türkçe' },
];
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />);
]
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />)
const video = container.querySelector('video') as HTMLVideoElement;
const video = container.querySelector('video') as HTMLVideoElement
// Subtitles are added dynamically by VideoElement
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
await waitFor(() => {
expect(container.querySelectorAll('track')).toHaveLength(2)
})
})
it('renders without errors', () => {
const onError = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />);
const onError = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />)
const video = container.querySelector('video') as HTMLVideoElement;
expect(video).toBeInTheDocument();
const video = container.querySelector('video') as HTMLVideoElement
expect(video).toBeInTheDocument()
// Error handling is tested separately in integration tests
});
})
it('hides controls when controls prop is false', () => {
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />);
const controls = container.querySelector('.controls');
expect(controls).not.toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
const controls = container.querySelector('.controls')
expect(controls).not.toBeInTheDocument()
})
it('applies custom style', () => {
const style = { width: '800px', height: '450px' };
const { container } = render(<VideoPlayer {...defaultProps} style={style} />);
const playerElement = container.querySelector('.video-player') as HTMLElement;
expect(playerElement.style.width).toBe('800px');
expect(playerElement.style.height).toBe('450px');
});
});
const style = { width: '800px', height: '450px' }
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
const playerElement = container.querySelector('.video-player') as HTMLElement
expect(playerElement.style.width).toBe('800px')
expect(playerElement.style.height).toBe('450px')
})
})
+5 -4
View File
@@ -3,26 +3,27 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
import { VideoElement } from './VideoElement'
import { ControlsLayer } from './ControlsLayer'
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
import { initializePolyfills } from '../utils/polyfills'
import '../styles/variables.css'
import './VideoPlayer.css'
// Lazy load polyfills only if needed
// Initialize polyfills only when the current browser needs them
let polyfillsInitialized = false
const initializePolyfillsIfNeeded = async () => {
const initializePolyfillsIfNeeded = () => {
if (polyfillsInitialized) return
if (typeof document === 'undefined') return
// Check if polyfills are needed
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
if (needsFullscreenPolyfill || needsPIPPolyfill) {
const { initializePolyfills } = await import('../utils/polyfills')
initializePolyfills()
polyfillsInitialized = true
}
}
// Initialize polyfills asynchronously
// Initialize polyfills if needed
initializePolyfillsIfNeeded()
const VideoPlayerContent: React.FC<
+4 -1
View File
@@ -7,7 +7,10 @@ export const PIPButton: React.FC = () => {
const { videoState, togglePictureInPicture } = usePlayerContext()
// Check if PIP is supported
const isPIPSupported = 'pictureInPictureEnabled' in document
const isPIPSupported =
typeof (document as any).pictureInPictureEnabled === 'boolean' &&
(document as any).pictureInPictureEnabled &&
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
if (!isPIPSupported) {
return null
+122
View File
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import type { AudioTrack, VideoQuality } from '../../types'
import { SettingsMenu } from './SettingsMenu'
const { contextState } = vi.hoisted(() => ({
contextState: {
value: null as any,
},
}))
vi.mock('../../contexts/PlayerContext', () => ({
usePlayerContext: () => contextState.value,
}))
const audioTracks: AudioTrack[] = [
{
name: 'English',
language: 'en',
url: '',
groupId: 'audio',
},
]
const qualities: VideoQuality[] = [
{ label: '1080p', height: 1080, levelIndex: 0 },
{ label: '720p', height: 720, levelIndex: 1 },
]
const subtitles = [{ src: '/sub.vtt', lang: 'en', label: 'English' }]
describe('SettingsMenu', () => {
beforeEach(() => {
contextState.value = {
uiState: {
controlsVisible: true,
settingsOpen: true,
volumeControlOpen: false,
qualityMenuOpen: false,
subtitleMenuOpen: false,
},
videoState: {
playing: false,
currentTime: 0,
duration: 0,
buffered: 0,
volume: 1,
muted: false,
playbackRate: 1,
fullscreen: false,
pictureInPicture: false,
loading: false,
error: null,
seeking: false,
isLiveBroadcast: false,
},
settings: {
quality: null,
subtitle: null,
audioTrack: null,
playbackRate: 1,
},
setPlaybackRate: vi.fn(),
setSubtitle: vi.fn(),
setAudioTrack: vi.fn(),
setQuality: vi.fn(),
toggleSettings: vi.fn(),
translations: {
noSubtitlesAvailable: 'No subtitles available',
subtitles: 'Subtitles',
off: 'Off',
auto: 'Auto',
quality: 'Quality',
speed: 'Speed',
normal: 'Normal',
default: 'Default',
audioTrack: 'Audio Track',
settings: 'Settings',
level: 'Level',
},
}
})
it('does not render when settings are closed', () => {
contextState.value.uiState.settingsOpen = false
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
})
it('changes playback speed from speed submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Speed'))
fireEvent.click(screen.getByText('1.5x'))
expect(contextState.value.setPlaybackRate).toHaveBeenCalledWith(1.5)
})
it('selects subtitle from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Subtitles'))
fireEvent.click(screen.getByRole('button', { name: 'English' }))
expect(contextState.value.setSubtitle).toHaveBeenCalledWith(subtitles[0])
})
it('selects audio track from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Audio Track'))
fireEvent.click(screen.getByRole('button', { name: 'English' }))
expect(contextState.value.setAudioTrack).toHaveBeenCalledWith(audioTracks[0])
})
it('selects quality from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Quality'))
fireEvent.click(screen.getByText('720p'))
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
})
})