Some fixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user