Add i18n, tests, and update documentation
Introduces internationalization (i18n) support with English and Turkish, adds unit tests and test setup with Vitest and React Testing Library, and updates documentation including README and changelog. Removes legacy publishing and usage guides, refactors components to use translation system, and updates build and test scripts in package.json. Also adds new utility modules for HLS and CORS, and improves PlayerContext and SettingsMenu for language support.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useRef, useState, useCallback, lazy, Suspense } from 'react'
|
||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { PlayPauseButton } from './controls/PlayPauseButton'
|
||||
import { ProgressBar } from './controls/ProgressBar'
|
||||
@@ -9,12 +9,13 @@ import { PIPButton } from './controls/PIPButton'
|
||||
import { SettingsButton } from './controls/SettingsButton'
|
||||
import { LoadingSpinner } from './overlays/LoadingSpinner'
|
||||
import { CenterPlayButton } from './controls/CenterPlayButton'
|
||||
import { SettingsMenu } from './menus/SettingsMenu'
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
|
||||
import { useTouchGestures } from '../hooks/useTouchGestures'
|
||||
import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
|
||||
import './ControlsLayer.css'
|
||||
|
||||
const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu })))
|
||||
|
||||
interface ControlsLayerProps {
|
||||
keyboardShortcuts?: boolean
|
||||
pictureInPicture?: boolean
|
||||
@@ -225,7 +226,9 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
<div className="controls-right">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SettingsButton />
|
||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
|
||||
<Suspense fallback={null}>
|
||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
|
||||
</Suspense>
|
||||
</div>
|
||||
{pictureInPicture && <PIPButton />}
|
||||
<FullscreenButton />
|
||||
|
||||
@@ -2,7 +2,8 @@ import React, { useEffect, useCallback, useState } from 'react'
|
||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||
import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
|
||||
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
||||
import { getHlsAudioTracks, getHlsQualities, setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsLoader'
|
||||
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
||||
import { setupHlsInstance } from '../utils/hlsSetup'
|
||||
import './VideoElement.css'
|
||||
|
||||
interface VideoElementProps {
|
||||
@@ -219,80 +220,21 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
|
||||
const setupHls = async () => {
|
||||
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
|
||||
// Browser doesn't support HLS natively, load hls.js
|
||||
try {
|
||||
// Dynamic import with CDN fallback
|
||||
const { loadHls, isHlsSupported } = await import('../utils/hlsLoader')
|
||||
const Hls = await loadHls()
|
||||
|
||||
if (!isHlsSupported(Hls)) {
|
||||
throw new Error('HLS.js is not supported in this browser')
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
})
|
||||
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// Extract audio tracks after manifest is parsed
|
||||
// Sometimes audio tracks are not immediately available, so we try with a small delay
|
||||
setTimeout(() => {
|
||||
const tracks = getHlsAudioTracks(hls)
|
||||
const qualities = getHlsQualities(hls)
|
||||
|
||||
if (tracks.length > 0) {
|
||||
setAvailableAudioTracks(tracks)
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
}
|
||||
|
||||
setAvailableQualities(qualities)
|
||||
onQualityLevelsLoaded?.(qualities)
|
||||
}, 100)
|
||||
|
||||
if (autoplay) {
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
// Also listen to audio track updates
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||
const tracks = getHlsAudioTracks(hls)
|
||||
if (tracks.length > 0) {
|
||||
cleanupFn = await setupHlsInstance({
|
||||
video,
|
||||
src,
|
||||
autoplay,
|
||||
onAudioTracksLoaded: (tracks) => {
|
||||
setAvailableAudioTracks(tracks)
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
}
|
||||
},
|
||||
onQualityLevelsLoaded: (qualities) => {
|
||||
setAvailableQualities(qualities)
|
||||
onQualityLevelsLoaded?.(qualities)
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad()
|
||||
break
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError()
|
||||
break
|
||||
default:
|
||||
handleError()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Store hls instance for cleanup
|
||||
;(video as any).__hlsInstance = hls
|
||||
|
||||
// Setup cleanup function
|
||||
cleanupFn = () => {
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
}
|
||||
} catch (err) {
|
||||
let error: Error
|
||||
if (err instanceof Error && isCORSError(err)) {
|
||||
@@ -309,7 +251,6 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
onError?.(error)
|
||||
}
|
||||
} else {
|
||||
// Native support or regular video
|
||||
video.src = src
|
||||
if (autoplay) {
|
||||
void video.play().catch(() => undefined)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders video element', () => {
|
||||
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');
|
||||
// VideoElement handles autoplay programmatically via play() method
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with muted prop', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} muted />);
|
||||
const video = container.querySelector('video');
|
||||
// Muted state is managed through VideoElement
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies loop when enabled', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('calls onPlay callback when play event fires', async () => {
|
||||
const onPlay = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('play'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onPause callback when pause event fires', async () => {
|
||||
const onPause = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('pause'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPause).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onEnded callback when ended event fires', async () => {
|
||||
const onEnded = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('ended'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onTimeUpdate callback with current time', async () => {
|
||||
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'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTimeUpdate).toHaveBeenCalledWith(10.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with subtitles prop', () => {
|
||||
const subtitles = [
|
||||
{ src: 'subtitles-en.vtt', srcLang: 'en', label: 'English' },
|
||||
{ src: 'subtitles-tr.vtt', srcLang: 'tr', label: 'Türkçe' },
|
||||
];
|
||||
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
// Subtitles are added dynamically by VideoElement
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without errors', () => {
|
||||
const onError = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,28 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
import type { VideoPlayerProps, AudioTrack, VideoQuality } from '../types'
|
||||
import { initializePolyfills } from '../utils/polyfills'
|
||||
import '../styles/variables.css'
|
||||
import './VideoPlayer.css'
|
||||
|
||||
// Initialize polyfills once
|
||||
// Lazy load polyfills only if needed
|
||||
let polyfillsInitialized = false
|
||||
if (!polyfillsInitialized) {
|
||||
initializePolyfills()
|
||||
polyfillsInitialized = true
|
||||
const initializePolyfillsIfNeeded = async () => {
|
||||
if (polyfillsInitialized) 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
|
||||
initializePolyfillsIfNeeded()
|
||||
|
||||
const VideoPlayerContent: React.FC<
|
||||
VideoPlayerProps & {
|
||||
audioTracks: AudioTrack[]
|
||||
@@ -92,6 +103,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
controls = true,
|
||||
subtitles = [],
|
||||
theme,
|
||||
language,
|
||||
keyboardShortcuts = true,
|
||||
pictureInPicture = true,
|
||||
className = '',
|
||||
@@ -129,7 +141,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlayerProvider initialMuted={muted}>
|
||||
<PlayerProvider initialMuted={muted} language={language}>
|
||||
<VideoPlayerContent
|
||||
src={src}
|
||||
poster={poster}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
setAudioTrack,
|
||||
setQuality,
|
||||
toggleSettings,
|
||||
translations,
|
||||
} = usePlayerContext()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||
@@ -66,7 +67,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
{currentView === 'main' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<h3>Ayarlar</h3>
|
||||
<h3>{translations.settings}</h3>
|
||||
</div>
|
||||
<div className="settings-main-options">
|
||||
{qualities.length > 0 && (
|
||||
@@ -75,9 +76,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<QualityIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Çözünürlük</span>
|
||||
<span className="settings-main-option-label">{translations.quality}</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.quality ? settings.quality.label : 'Otomatik'}
|
||||
{settings.quality ? settings.quality.label : translations.auto}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
@@ -89,7 +90,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<SpeedIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Hız</span>
|
||||
<span className="settings-main-option-label">{translations.speed}</span>
|
||||
<span className="settings-main-option-value">
|
||||
{videoState.playbackRate === 1 ? 'Normal' : `${videoState.playbackRate}x`}
|
||||
</span>
|
||||
@@ -102,9 +103,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<SubtitlesIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Altyazı</span>
|
||||
<span className="settings-main-option-label">{translations.subtitles}</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.subtitle ? settings.subtitle.label : 'Kapalı'}
|
||||
{settings.subtitle ? settings.subtitle.label : translations.off}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
@@ -116,9 +117,9 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<AudioIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Ses</span>
|
||||
<span className="settings-main-option-label">{translations.audioTrack}</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.audioTrack ? settings.audioTrack.name : 'Varsayılan'}
|
||||
{settings.audioTrack ? settings.audioTrack.name : translations.default}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
@@ -135,7 +136,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Oynatma Hızı</h3>
|
||||
<h3>{translations.speed}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
{playbackRates.map((rate) => (
|
||||
@@ -162,7 +163,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Altyazı</h3>
|
||||
<h3>{translations.subtitles}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<button
|
||||
@@ -172,7 +173,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>Kapalı</span>
|
||||
<span>{translations.off}</span>
|
||||
{!settings.subtitle && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
{subtitles.length > 0 ? (
|
||||
@@ -191,7 +192,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
))
|
||||
) : (
|
||||
<div className="settings-empty-state">
|
||||
<span>Altyazı mevcut değil</span>
|
||||
<span>{translations.noSubtitlesAvailable}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -205,7 +206,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Ses</h3>
|
||||
<h3>{translations.audioTrack}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
{audioTracks.map((track) => (
|
||||
@@ -234,7 +235,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Çözünürlük</h3>
|
||||
<h3>{translations.quality}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<button
|
||||
@@ -244,7 +245,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>Otomatik</span>
|
||||
<span>{translations.auto}</span>
|
||||
{!settings.quality && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
{qualities.map((quality) => {
|
||||
|
||||
Reference in New Issue
Block a user