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) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const STRINGS = {
|
||||
SETTINGS: 'Settings',
|
||||
QUALITY: 'Quality',
|
||||
SPEED: 'Speed',
|
||||
SUBTITLES: 'Subtitles',
|
||||
AUDIO: 'Audio',
|
||||
AUTO: 'Auto',
|
||||
OFF: 'Off',
|
||||
DEFAULT: 'Default',
|
||||
LEVEL: 'Level',
|
||||
} as const
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
||||
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
||||
import type { Translations } from '../i18n'
|
||||
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
||||
|
||||
type SelectedQuality = PlayerSettings['quality']
|
||||
type SelectedSubtitle = PlayerSettings['subtitle']
|
||||
@@ -7,6 +9,7 @@ type SelectedSubtitle = PlayerSettings['subtitle']
|
||||
interface PlayerContextType extends PlayerContextValue {
|
||||
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
||||
setUIState: React.Dispatch<React.SetStateAction<UIState>>
|
||||
translations: Translations
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | null>(null)
|
||||
@@ -25,6 +28,7 @@ interface PlayerProviderProps {
|
||||
initialVolume?: number
|
||||
initialMuted?: boolean
|
||||
initialPlaybackRate?: number
|
||||
language?: string
|
||||
}
|
||||
|
||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
@@ -32,10 +36,14 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
initialVolume = 1,
|
||||
initialMuted = false,
|
||||
initialPlaybackRate = 1,
|
||||
language,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Get translations based on language prop or browser language
|
||||
const translations = getTranslations(language || detectBrowserLanguage())
|
||||
|
||||
const [videoState, setVideoState] = useState<VideoState>({
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
@@ -169,6 +177,7 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
containerRef,
|
||||
setVideoState,
|
||||
setUIState,
|
||||
translations,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Simple i18n system for video player
|
||||
*/
|
||||
|
||||
export interface Translations {
|
||||
noSubtitlesAvailable: string;
|
||||
subtitles: string;
|
||||
off: string;
|
||||
auto: string;
|
||||
quality: string;
|
||||
speed: string;
|
||||
normal: string;
|
||||
default: string;
|
||||
audioTrack: string;
|
||||
settings: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export const translations: Record<string, Translations> = {
|
||||
en: {
|
||||
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",
|
||||
},
|
||||
tr: {
|
||||
noSubtitlesAvailable: 'Altyazı mevcut değil',
|
||||
subtitles: 'Altyazı',
|
||||
off: 'Kapalı',
|
||||
auto: 'Otomatik',
|
||||
quality: 'Kalite',
|
||||
speed: 'Hız',
|
||||
normal: 'Normal',
|
||||
default: 'Varsayılan',
|
||||
audioTrack: 'Ses',
|
||||
settings: 'Ayarlar',
|
||||
level: "Seviye",
|
||||
},
|
||||
};
|
||||
|
||||
export const getTranslations = (language: string = 'en'): Translations => {
|
||||
// Try exact match first
|
||||
if (translations[language]) {
|
||||
return translations[language];
|
||||
}
|
||||
|
||||
// Try language code without region (e.g., "en" from "en-US")
|
||||
const languageCode = language.split('-')[0];
|
||||
if (translations[languageCode]) {
|
||||
return translations[languageCode];
|
||||
}
|
||||
|
||||
// Default to English
|
||||
return translations.en;
|
||||
};
|
||||
|
||||
export const detectBrowserLanguage = (): string => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
return navigator.language || 'en';
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
+78
-211
@@ -6,251 +6,118 @@ export interface IconProps {
|
||||
color?: string
|
||||
}
|
||||
|
||||
export const PlayIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 5v14l11-7L8 5z" fill={color} />
|
||||
interface BaseIconProps extends IconProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Icon: React.FC<BaseIconProps> = ({ size = 24, className = '', color = 'currentColor', children }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className}>
|
||||
{React.Children.map(children, child =>
|
||||
React.isValidElement(child) ? React.cloneElement(child, { fill: color } as any) : child
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PauseIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" fill={color} />
|
||||
</svg>
|
||||
export const PlayIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const VolumeUpIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const PauseIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const VolumeDownIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" fill={color} />
|
||||
</svg>
|
||||
export const VolumeUpIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const VolumeMuteIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const VolumeDownIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FullscreenIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill={color} />
|
||||
</svg>
|
||||
export const VolumeMuteIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const FullscreenExitIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" fill={color} />
|
||||
</svg>
|
||||
export const FullscreenIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SettingsIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const FullscreenExitIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const PIPIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" fill={color} />
|
||||
</svg>
|
||||
export const SettingsIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SubtitlesIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const PIPIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const SpeedIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M20.38 8.57l-1.23 1.85a8 8 0 01-.22 7.58H5.07A8 8 0 0115.58 6.85l1.85-1.23A10 10 0 003.35 19a2 2 0 001.72 1h13.85a2 2 0 001.74-1 10 10 0 00-.27-10.44z"
|
||||
fill={color}
|
||||
/>
|
||||
<path d="M10.59 15.41a2 2 0 002.83 0l5.66-8.49-8.49 5.66a2 2 0 000 2.83z" fill={color} />
|
||||
</svg>
|
||||
export const SubtitlesIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const QualityIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 12H8v-2H6v2H4.5V9H6v2h2V9h1.5v6zm9 0h-1.5v-2.25H15V15h-1.5V9H15v2.25h3.5V9H20v6z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const SpeedIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M20.38 8.57l-1.23 1.85a8 8 0 01-.22 7.58H5.07A8 8 0 0115.58 6.85l1.85-1.23A10 10 0 003.35 19a2 2 0 001.72 1h13.85a2 2 0 001.74-1 10 10 0 00-.27-10.44z" />
|
||||
<path d="M10.59 15.41a2 2 0 002.83 0l5.66-8.49-8.49 5.66a2 2 0 000 2.83z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ForwardIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" fill={color} />
|
||||
</svg>
|
||||
export const QualityIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 12H8v-2H6v2H4.5V9H6v2h2V9h1.5v6zm9 0h-1.5v-2.25H15V15h-1.5V9H15v2.25h3.5V9H20v6z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const RewindIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" fill={color} />
|
||||
</svg>
|
||||
export const ForwardIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const RewindIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const LoadingIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
opacity="0.3"
|
||||
fill={color}
|
||||
/>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className} style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3" fill={color} />
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CheckIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill={color} />
|
||||
</svg>
|
||||
export const CheckIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const AudioIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
export const AudioIcon: React.FC<IconProps> = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
+4
-2
@@ -19,9 +19,11 @@ export type {
|
||||
// Utils
|
||||
export { formatTime, parseTime } from './utils/time'
|
||||
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
||||
export { initializePolyfills, features } from './utils/polyfills'
|
||||
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
||||
export { loadHls, isHlsSupported, hasNativeHlsSupport } from './utils/hlsLoader'
|
||||
|
||||
// i18n
|
||||
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
|
||||
export type { Translations } from './i18n'
|
||||
|
||||
// Hooks (for advanced users)
|
||||
export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock HTMLMediaElement methods that are not implemented in jsdom
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'load', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock requestFullscreen and exitFullscreen
|
||||
Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
Object.defineProperty(Document.prototype, 'exitFullscreen', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
// Mock Picture-in-Picture API
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
|
||||
Object.defineProperty(Document.prototype, 'exitPictureInPicture', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
(globalThis as any).IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
};
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export interface VideoPlayerProps {
|
||||
controls?: boolean
|
||||
subtitles?: SubtitleTrack[]
|
||||
theme?: PlayerTheme
|
||||
language?: string
|
||||
keyboardShortcuts?: boolean
|
||||
pictureInPicture?: boolean
|
||||
className?: string
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
isSameOrigin,
|
||||
isBlobOrDataURL,
|
||||
validateVideoURL,
|
||||
getCORSErrorMessage,
|
||||
isCORSError,
|
||||
checkVideoCORS,
|
||||
} from './corsHelper';
|
||||
|
||||
describe('corsHelper', () => {
|
||||
describe('isSameOrigin', () => {
|
||||
it('returns true for same origin URLs', () => {
|
||||
const sameOriginUrl = `${window.location.origin}/video.mp4`;
|
||||
expect(isSameOrigin(sameOriginUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different origin URLs', () => {
|
||||
expect(isSameOrigin('https://example.com/video.mp4')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for relative URLs', () => {
|
||||
expect(isSameOrigin('/videos/test.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for relative path-like strings', () => {
|
||||
// In browsers, "not-a-url" is treated as a relative URL
|
||||
expect(isSameOrigin('not-a-url')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlobOrDataURL', () => {
|
||||
it('returns true for blob URLs', () => {
|
||||
expect(isBlobOrDataURL('blob:http://example.com/123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for data URLs', () => {
|
||||
expect(isBlobOrDataURL('data:video/mp4;base64,AAAA')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for regular URLs', () => {
|
||||
expect(isBlobOrDataURL('https://example.com/video.mp4')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isBlobOrDataURL('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateVideoURL', () => {
|
||||
it('returns invalid for empty URL', () => {
|
||||
const result = validateVideoURL('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Video URL is empty');
|
||||
});
|
||||
|
||||
it('returns invalid for whitespace-only URL', () => {
|
||||
const result = validateVideoURL(' ');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Video URL is empty');
|
||||
});
|
||||
|
||||
it('returns valid for relative path strings', () => {
|
||||
// Browser treats this as a relative URL
|
||||
const result = validateVideoURL('not a valid url');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid for same origin URL without warning', () => {
|
||||
const result = validateVideoURL(`${window.location.origin}/video.mp4`);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns valid for blob URL without warning', () => {
|
||||
const result = validateVideoURL('blob:http://example.com/123456');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns valid with warning for external URL', () => {
|
||||
const result = validateVideoURL('https://example.com/video.mp4');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toContain('CORS');
|
||||
});
|
||||
|
||||
it('returns valid for relative URLs', () => {
|
||||
const result = validateVideoURL('/videos/test.mp4');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCORSErrorMessage', () => {
|
||||
it('returns generic message for same origin', () => {
|
||||
const message = getCORSErrorMessage(`${window.location.origin}/video.mp4`);
|
||||
expect(message).toBe('Failed to load video. Please check the URL.');
|
||||
});
|
||||
|
||||
it('returns generic message for blob URLs', () => {
|
||||
const message = getCORSErrorMessage('blob:http://example.com/123456');
|
||||
expect(message).toBe('Failed to load video. Please check the URL.');
|
||||
});
|
||||
|
||||
it('returns CORS-specific message for external URLs', () => {
|
||||
const message = getCORSErrorMessage('https://example.com/video.mp4');
|
||||
expect(message).toContain('CORS Error');
|
||||
expect(message).toContain('example.com');
|
||||
expect(message).toContain('Access-Control-Allow-Origin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCORSError', () => {
|
||||
it('returns true for errors containing "cors"', () => {
|
||||
const error = new Error('CORS policy blocked this request');
|
||||
expect(isCORSError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for errors containing "cross-origin"', () => {
|
||||
const error = new Error('Cross-origin request blocked');
|
||||
expect(isCORSError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for errors containing "blocked by cors policy"', () => {
|
||||
const error = new Error('Request blocked by CORS policy');
|
||||
expect(isCORSError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for errors containing "access-control-allow-origin"', () => {
|
||||
const error = new Error('No \'access-control-allow-origin\' header present');
|
||||
expect(isCORSError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-CORS errors', () => {
|
||||
const error = new Error('Network timeout');
|
||||
expect(isCORSError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
const error = new Error('BLOCKED BY CORS POLICY');
|
||||
expect(isCORSError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVideoCORS', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns supported when CORS headers are present', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Map([
|
||||
['Access-Control-Allow-Origin', '*'],
|
||||
['Accept-Ranges', 'bytes'],
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.needsProxy).toBe(false);
|
||||
expect(result.supportsRange).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not supported when CORS headers are missing', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
headers: new Map(),
|
||||
});
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supported).toBe(false);
|
||||
expect(result.needsProxy).toBe(true);
|
||||
expect(result.error).toContain('CORS not enabled');
|
||||
});
|
||||
|
||||
it('detects range support', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Map([
|
||||
['Access-Control-Allow-Origin', '*'],
|
||||
['Accept-Ranges', 'bytes'],
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supportsRange).toBe(true);
|
||||
});
|
||||
|
||||
it('detects no range support when header is "none"', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Map([
|
||||
['Access-Control-Allow-Origin', '*'],
|
||||
['Accept-Ranges', 'none'],
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supportsRange).toBe(false);
|
||||
});
|
||||
|
||||
it('handles CORS fetch errors', async () => {
|
||||
(global.fetch as any).mockRejectedValue(new TypeError('Failed to fetch (CORS)'));
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supported).toBe(false);
|
||||
expect(result.needsProxy).toBe(true);
|
||||
expect(result.error).toContain('CORS blocked');
|
||||
});
|
||||
|
||||
it('handles general fetch errors', async () => {
|
||||
(global.fetch as any).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await checkVideoCORS('https://example.com/video.mp4');
|
||||
expect(result.supported).toBe(false);
|
||||
expect(result.needsProxy).toBe(true);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-17
@@ -122,23 +122,7 @@ export const getCORSErrorMessage = (url: string): string => {
|
||||
return 'Failed to load video. Please check the URL.'
|
||||
}
|
||||
|
||||
return `
|
||||
❌ CORS Error: Unable to load video from external source.
|
||||
|
||||
The video server at "${new URL(url).origin}" does not allow cross-origin requests.
|
||||
|
||||
To fix this issue:
|
||||
1. Add CORS headers to your video server:
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, HEAD
|
||||
Access-Control-Allow-Headers: Range
|
||||
|
||||
2. Use a proxy server to bypass CORS restrictions
|
||||
|
||||
3. Host the video on the same domain as your application
|
||||
|
||||
4. Use a CDN that supports CORS (e.g., Cloudflare, AWS CloudFront)
|
||||
`.trim()
|
||||
return `CORS Error: Unable to load video from ${new URL(url).origin}. The server must allow cross-origin requests with proper Access-Control-Allow-Origin headers.`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* HLS control utilities for audio tracks and quality levels
|
||||
* Separated to avoid circular dependencies and enable better tree-shaking
|
||||
*/
|
||||
|
||||
/**
|
||||
* Update active quality level in HLS instance. Passing null re-enables auto.
|
||||
*/
|
||||
export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => {
|
||||
if (!hls || !Array.isArray(hls.levels)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (levelIndex === null || typeof levelIndex === 'undefined' || levelIndex < 0) {
|
||||
if (hls.currentLevel !== -1) {
|
||||
hls.currentLevel = -1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (levelIndex >= hls.levels.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hls.currentLevel !== levelIndex) {
|
||||
hls.currentLevel = levelIndex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active audio track in HLS instance
|
||||
*/
|
||||
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
|
||||
if (!hls || !hls.audioTracks) {
|
||||
return
|
||||
}
|
||||
|
||||
if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) {
|
||||
return
|
||||
}
|
||||
|
||||
hls.audioTrack = audioTrackIndex
|
||||
}
|
||||
+6
-39
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
import type { AudioTrack, VideoQuality } from '../types'
|
||||
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
||||
|
||||
// Re-export control functions for backward compatibility
|
||||
export { setHlsQualityLevel, setHlsAudioTrack } from './hlsControl'
|
||||
|
||||
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
|
||||
|
||||
@@ -119,6 +123,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
? resolution.split('x').map((value: string) => parseInt(value, 10))
|
||||
: [undefined, undefined]
|
||||
|
||||
const translations = getTranslations(detectBrowserLanguage());
|
||||
const width = level.width || widthFromResolution
|
||||
const height = level.height || heightFromResolution
|
||||
const bitrate = typeof level.bitrate === 'number' ? level.bitrate : level.attrs?.BANDWIDTH
|
||||
@@ -131,7 +136,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
} else if (typeof bitrate === 'number' && bitrate > 0) {
|
||||
label = `${Math.round(bitrate / 1000)} kbps`
|
||||
} else {
|
||||
label = `Seviye ${index + 1}`
|
||||
label = `${translations.level} ${index + 1}`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -156,41 +161,3 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active quality level in HLS instance. Passing null re-enables auto.
|
||||
*/
|
||||
export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => {
|
||||
if (!hls || !Array.isArray(hls.levels)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (levelIndex === null || typeof levelIndex === 'undefined' || levelIndex < 0) {
|
||||
if (hls.currentLevel !== -1) {
|
||||
hls.currentLevel = -1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (levelIndex >= hls.levels.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hls.currentLevel !== levelIndex) {
|
||||
hls.currentLevel = levelIndex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active audio track in HLS instance
|
||||
*/
|
||||
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
|
||||
if (!hls || !hls.audioTracks) {
|
||||
return
|
||||
}
|
||||
|
||||
if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) {
|
||||
return
|
||||
}
|
||||
|
||||
hls.audioTrack = audioTrackIndex
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* HLS setup and configuration utilities
|
||||
*/
|
||||
|
||||
import type { AudioTrack, VideoQuality } from '../types'
|
||||
|
||||
interface HlsSetupOptions {
|
||||
video: HTMLVideoElement
|
||||
src: string
|
||||
autoplay: boolean
|
||||
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
||||
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export const setupHlsInstance = async ({
|
||||
video,
|
||||
src,
|
||||
autoplay,
|
||||
onAudioTracksLoaded,
|
||||
onQualityLevelsLoaded,
|
||||
onError,
|
||||
}: HlsSetupOptions): Promise<() => void> => {
|
||||
const { loadHls, isHlsSupported, getHlsAudioTracks, getHlsQualities } = await import('./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, () => {
|
||||
setTimeout(() => {
|
||||
const tracks = getHlsAudioTracks(hls)
|
||||
const qualities = getHlsQualities(hls)
|
||||
|
||||
if (tracks.length > 0) {
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
}
|
||||
|
||||
onQualityLevelsLoaded?.(qualities)
|
||||
}, 100)
|
||||
|
||||
if (autoplay) {
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||
const tracks = getHlsAudioTracks(hls)
|
||||
if (tracks.length > 0) {
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
}
|
||||
})
|
||||
|
||||
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:
|
||||
onError?.(new Error('Fatal HLS error'))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
;(video as any).__hlsInstance = hls
|
||||
|
||||
return () => {
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user