Some fixes

This commit is contained in:
hibna
2026-02-12 17:54:16 +03:00
parent f57ee77c56
commit 8a32c5c1b3
18 changed files with 997 additions and 135 deletions
+67
View File
@@ -0,0 +1,67 @@
# Video Player - Duzeltme Sonucu
Bu dosya, onceki "eksik/hata" listesindeki maddelerin duzeltildigini gostermek icin guncellendi.
Guncelleme tarihi: 2026-02-12
## 1) Duzeltilen Kod Sorunlari
- [x] README'de gecen `features` ve `initializePolyfills` export edilmiyordu
- Duzeltme: `src/index.ts` icine `initializePolyfills` ve `features` exportlari eklendi.
- [x] `VideoProtocol` tipi ile protokol algilama sonucu uyumsuzdu
- Duzeltme: `src/types/index.ts` icindeki `VideoProtocol` tipine `mpegts` eklendi.
- [x] PIP butonu desteksiz tarayicida gorunebiliyordu
- Duzeltme: `src/components/controls/PIPButton.tsx` icindeki destek kontrolu
`pictureInPictureEnabled === true` ve `requestPictureInPicture` fonksiyon kontrolu ile guclendirildi.
- [x] `VideoElement` async setup/cancel race riski vardi
- Duzeltme: `src/components/VideoElement.tsx` icine cancellation guard eklendi.
- Asenkron kurulumdan sonra unmount olmus senaryoda gec kalan instance'lar aninda temizleniyor.
- [x] Demo poster yolu hataliydi (`.srt` dosyasina isaret ediyordu)
- Duzeltme: `examples/App.tsx` poster yolu `'/player/poster.svg'` olarak guncellendi.
- Yeni poster dosyasi eklendi: `public/poster.svg`.
## 2) Duzeltilen Dokumantasyon Uyumsuzluklari
- [x] README TODO listesi implementasyonla celisiyordu
- Duzeltme: Tamamlanmis maddeler (`audio track UI`, `quality selector`, `speed menu`, `settings panel`) `[x]` olarak guncellendi.
- [x] README bundle boyutu iddialari guncel build ile uyumsuzdu
- Duzeltme: Bundle bolumu guncel ve daha gercekci degerlerle guncellendi.
- Ayrica ust bolumdeki "15KB" iddiasi revize edildi.
- [x] README'de "Media Session API" kullanim iddiasi vardi ama kodda yoktu
- Duzeltme: Teknik API listesi gercek kullanimla hizalandi.
## 3) Test Kalitesi Duzeltmeleri
- [x] `act(...)` uyarilari
- Duzeltme: `src/components/VideoPlayer.test.tsx` icinde olay tetiklemeleri `fireEvent`/`act` ile duzenlendi.
- Son test kosusunda `act(...)` uyarisi alinmadi.
- [x] Test kapsami sinirliydi
- Duzeltme: asagidaki yeni test dosyalari eklendi:
- `src/components/menus/SettingsMenu.test.tsx`
- `src/hooks/useKeyboardShortcuts.test.tsx`
- `src/hooks/useTouchGestures.test.tsx`
- `src/utils/hlsSetup.test.ts`
- `src/utils/rtmpSetup.test.ts`
- `src/utils/mpegtsSetup.test.ts`
## 4) Dogrulama Sonuclari
Asagidaki komutlar bu guncellemelerden sonra basariyla calisti:
- `npm run lint`
- `npm run test`
- `npm run build`
- `npm run build:lib`
Toplam test durumu:
- 9 test dosyasi
- 80 test
- tumu basarili
+146
View File
@@ -0,0 +1,146 @@
# Video Player - Mevcut Ozellik Envanteri
Bu dosya, depoda yer alan kod, testler ve calistirilabilir komutlar incelenerek olusturuldu.
Inceleme tarihi: 2026-02-12
## 1) Genel Mimari ve Paketleme
- React + TypeScript tabanli bir video oynatici kutuphanesi.
- Ana giris dosyasi: `src/index.ts`
- Ana bilesen: `src/components/VideoPlayer.tsx`
- Durum yonetimi: `src/contexts/PlayerContext.tsx`
- Vite ile hem demo app hem kutuphane derlemesi var:
- Uygulama derlemesi: `npm run build` (`vite.config.ts`)
- Kutuphane derlemesi: `npm run build:lib` (`vite.config.lib.ts`)
- Tip bildirimi uretiliyor (`vite-plugin-dts`).
## 2) Desteklenen Medya Turleri ve Protokoller
Kaynak: `src/utils/videoProtocol.ts`, `src/components/VideoElement.tsx`
- Native HTML5 video (mp4/webm vb.): dogrudan `<video src=...>`
- HLS (`.m3u8`)
- Safari'de native oynatim denemesi
- Diger tarayicilarda `hls.js` ile oynatim
- `hls.js` npm import + CDN fallback yapisi (`src/utils/hlsLoader.ts`)
- FLV / RTMP akislari:
- `flv.js` ile kurulum (`src/utils/rtmpSetup.ts`)
- npm import + CDN fallback (`src/utils/rtmpLoader.ts`)
- MPEG-TS / IPTV (`.ts`) akislari:
- `mpegts.js` ile kurulum (`src/utils/mpegtsSetup.ts`)
- DASH (`.mpd`) algilamasi var, fakat oynatim su an desteklenmiyor (hata donduruluyor).
## 3) Oynatici Kontrolleri ve UI Ozellikleri
Kaynak: `src/components/ControlsLayer.tsx`, `src/components/controls/*`
- Play/Pause butonu
- Ortada buyuk play overlay butonu (duraklatilmis durumda)
- Ilerleme cubugu:
- Tiklayarak seek
- Surukleyerek seek
- Buffered gostergesi
- Hover zamani tooltip'i
- Ses kontrolu:
- Mute/unmute
- Slider ile ses seviyesi
- Zaman gostergesi (current/duration)
- Fullscreen butonu
- Picture-in-Picture butonu (tarayici destegine bagli)
- Ayarlar menusu:
- Hiz secimi (0.25x-2x)
- Altyazi secimi
- Ses izi secimi (HLS audio tracks varsa)
- Kalite secimi (HLS levels varsa)
- Yuklenme sirasinda spinner
- Canli yayin algilanirsa:
- Progress/time gizleniyor
- "LIVE" rozeti gosteriliyor
- Kontroller fullscreen + playing durumunda otomatik gizlenebiliyor.
## 4) Giris Yontemleri (Keyboard ve Touch)
### Klavye kisayollari (`src/hooks/useKeyboardShortcuts.ts`)
- `Space` / `K`: play-pause
- `ArrowLeft` / `ArrowRight`: -/+5s
- `J` / `L`: -/+10s
- `ArrowUp` / `ArrowDown`: ses artir/azalt
- `M`: mute
- `F`: fullscreen
- `P`: PIP
- `0` veya `Home`: basa sar
- `End`: sona git
- `1-9`: yuzdeye git
### Dokunmatik jestler (`src/hooks/useTouchGestures.ts`)
- Tek dokunus: play/pause
- Cift dokunus sol: -10s
- Cift dokunus sag: +10s
- Yatay kaydirma: seek
- Dikey kaydirma: ses seviyesi
## 5) Altyazi ve Icerik Isleme
Kaynak: `src/utils/subtitles.ts`, `src/components/VideoElement.tsx`
- WebVTT altyazi dosyalari direkt kullaniliyor.
- SRT dosyalari parse edilip VTT blob URL'e cevriliyor.
- Elle verilen altyazilar + HLS'den gelen altyazilar birlestiriliyor.
- Varsayilan (`default`) altyazi secimi destekleniyor.
## 6) I18n ve Tema
Kaynak: `src/i18n/index.ts`, `src/styles/variables.css`, `src/components/VideoPlayer.tsx`
- Yerlesik diller: Ingilizce (`en`) ve Turkce (`tr`)
- Tarayici dilini algilama destegi
- `theme` prop'u ile CSS variable override:
- `primaryColor`
- `accentColor`
- `backgroundColor`
- `textColor`
## 7) API ve Callback Yuzeyi
Kaynak: `src/types/index.ts`
- Temel props:
- `src`, `poster`, `autoplay`, `loop`, `muted`, `volume`, `playbackRate`, `currentTime`
- `controls`, `subtitles`, `theme`, `language`, `keyboardShortcuts`, `pictureInPicture`
- `className`, `style`
- Olay callbackleri:
- `onPlay`, `onPause`, `onEnded`
- `onTimeUpdate`, `onVolumeChange`, `onProgress`
- `onLoadedMetadata`, `onDurationChange`, `onRateChange`
- `onSeeking`, `onSeeked`
- `onFullscreenChange`, `onPictureInPictureChange`
- `onWaiting`, `onCanPlay`, `onError`
## 8) Hata Yonetimi ve Dayaniklilik
Kaynak: `src/utils/corsHelper.ts`, `src/utils/hlsSetup.ts`, `src/utils/rtmpSetup.ts`, `src/utils/mpegtsSetup.ts`
- URL dogrulama ve CORS kaynakli hata mesajlari
- HLS/FLV/MPEG-TS icin hata callbackleri
- HLS'de fatal hata toparlama denemeleri (`startLoad`, `recoverMediaError`)
- FLV/MPEG-TS tarafinda network/media hata recovery denemeleri
- Player instance temizligi icin cleanup akisi
## 9) Test, Lint ve Derleme Durumu
Bu inceleme sirasinda calistirilan komutlar:
- `npm run lint` -> basarili
- `npm run test` -> basarili
- 3 test dosyasi, toplam 64 test geciyor
- `npm run build` -> basarili
- `npm run build:lib` -> basarili
Test dosyalari:
- `src/components/VideoPlayer.test.tsx`
- `src/utils/corsHelper.test.ts`
- `src/utils/videoProtocol.test.ts`
+20 -18
View File
@@ -1,19 +1,19 @@
# 🎬 Modern Video Player
[![npm version](https://img.shields.io/npm/v/@alper/video-player.svg?style=flat-square)](https://www.npmjs.com/package/@alper/video-player)
[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@alper/video-player?style=flat-square)](https://bundlephobia.com/package/@alper/video-player)
[![npm version](https://img.shields.io/npm/v/@source/player.svg?style=flat-square)](https://www.npmjs.com/package/@source/player)
[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@source/player?style=flat-square)](https://bundlephobia.com/package/@source/player)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
**The smallest React video player with the most features.** Only **~15KB gzipped** with zero runtime dependencies!
**A compact, feature-rich React video player** with zero runtime dependencies.
A feature-rich, modern video player library built with React, TypeScript, and Vite. Designed for reusability across multiple projects.
## 🏆 Why Choose This Player?
| Feature | @alper/video-player | video.js | react-player | plyr |
| Feature | @source/player | video.js | react-player | plyr |
|---------|---------------------|----------|--------------|------|
| **Bundle Size (gzipped)** | **15KB** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ |
| **Bundle Size (gzipped)** | **~18KB JS + ~3.5KB CSS** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ |
| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ |
| **React Native** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
| **TypeScript Native** | **Yes** ✅ | Types ⚠️ | Partial ⚠️ | Types ⚠️ |
@@ -25,7 +25,7 @@ A feature-rich, modern video player library built with React, TypeScript, and Vi
### Key Advantages
- 📦 **97% smaller than video.js** - Only 15KB vs 500KB
- 📦 **Compact bundle** - Core player ships around ~18KB gzipped JS (+ ~3.5KB CSS)
-**Blazing fast** - Zero runtime dependencies means faster load times
- 🎯 **React-first** - Built specifically for React, not a wrapper
- 🔧 **Full TypeScript** - Complete type safety out of the box
@@ -105,7 +105,7 @@ npm run build:lib
npm link
# In your other project
npm link @alper/video-player
npm link @source/player
```
## 🚀 Usage
@@ -113,8 +113,8 @@ npm link @alper/video-player
### Basic Example
```tsx
import { VideoPlayer } from '@alper/video-player'
import '@alper/video-player/styles.css'
import { VideoPlayer } from '@source/player'
import '@source/player/styles.css'
function App() {
return (
@@ -205,7 +205,7 @@ function App() {
### Feature Detection & Polyfills
```tsx
import { features, initializePolyfills } from '@alper/video-player'
import { features, initializePolyfills } from '@source/player'
// Initialize polyfills manually (optional - auto-initialized on VideoPlayer mount)
initializePolyfills()
@@ -227,7 +227,7 @@ console.log('Has volume control:', features.hasVolumeControl())
### CORS Error Handling
```tsx
import { validateVideoURL, checkVideoCORS } from '@alper/video-player'
import { validateVideoURL, checkVideoCORS } from '@source/player'
// Validate URL before loading
const validation = validateVideoURL(videoUrl)
@@ -360,8 +360,10 @@ interface PlayerTheme {
## 📊 Bundle Size
- Core library: **~8KB** (gzipped)
- Core player JS bundle: **~18KB** (gzipped)
- Core player CSS bundle: **~3.5KB** (gzipped)
- HLS.js (optional, lazy-loaded): **~200KB** (only when using HLS streams)
- MPEGTS.js (optional, lazy-loaded): **~72KB** (gzipped, only for `.ts` streams)
- Zero runtime dependencies (React is peer dependency)
## 🔧 Technical Details
@@ -370,8 +372,8 @@ interface PlayerTheme {
- HTML5 Video API
- Fullscreen API
- Picture-in-Picture API
- Media Session API
- Fetch API (Range Requests)
- TextTrack API (subtitles)
- Touch Events API
- Keyboard Events API
@@ -408,10 +410,10 @@ interface PlayerTheme {
## 🚧 TODO / Future Enhancements
- [ ] Multiple audio track UI and switching
- [ ] Quality selector for HLS streams
- [ ] Playback speed menu
- [ ] Settings panel
- [x] Multiple audio track UI and switching
- [x] Quality selector for HLS streams
- [x] Playback speed menu
- [x] Settings panel
- [ ] Chapters/markers support
- [ ] Thumbnail preview on hover
- [ ] Playlist support
@@ -419,7 +421,7 @@ interface PlayerTheme {
- [ ] AirPlay support
- [ ] DASH streaming support
- [ ] Accessibility improvements (ARIA labels)
- [ ] Unit tests
- [x] Unit tests
- [ ] E2E tests
- [ ] Storybook documentation
+2 -2
View File
@@ -9,7 +9,7 @@ function App() {
// Demo video URLs (you can replace with your own)
const demoVideoUrl = '/player/ses.mp4'
const demoPoster = '/player/ses_duzeltilmis.srt'
const demoPoster = '/player/poster.svg'
const demoSubtitles: SubtitleTrack[] = [
{
@@ -129,7 +129,7 @@ function App() {
<footer className="app-footer">
<p>Built with React, TypeScript, and Vite</p>
<p>Zero runtime dependencies ~8KB gzipped</p>
<p>Zero runtime dependencies ~18KB gzipped core JS</p>
</footer>
</div>
)
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@alper/video-player",
"version": "0.1.15",
"name": "@source/player",
"version": "1.0.0",
"description": "Modern, feature-rich video player library for React",
"type": "module",
"main": "./dist/video-player.umd.cjs",
@@ -12,7 +12,7 @@
"import": "./dist/video-player.js",
"require": "./dist/video-player.umd.cjs"
},
"./styles.css": "./dist/video-player.css"
"./styles.css": "./dist/player.css"
},
"files": [
"dist"
@@ -77,9 +77,9 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitea.hibna.com.tr/hibna/video-player.git"
"url": "https://gits.hibna.com.tr/hibna/player"
},
"publishConfig": {
"registry": "https://gitea.hibna.com.tr/api/packages/hibna/npm/"
"registry": "https://gits.hibna.com.tr/api/packages/hibna/npm/"
}
}
+60 -26
View File
@@ -405,6 +405,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
useEffect(() => {
const video = videoRef.current
if (!video) return
let isCancelled = false
setAvailableAudioTracks([])
onAudioTracksLoaded?.([])
@@ -426,6 +427,38 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const detection = detectVideoProtocol(src)
let cleanupFn: (() => void) | null = null
const teardownPlayer = () => {
if (cleanupFn) {
cleanupFn()
cleanupFn = null
}
// Also check for any lingering player instances
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
if ((video as any).__rtmpInstance) {
const rtmp = (video as any).__rtmpInstance
if (rtmp && typeof rtmp.destroy === 'function') {
rtmp.destroy()
}
delete (video as any).__rtmpInstance
}
if ((video as any).__mpegtsInstance) {
const mpegts = (video as any).__mpegtsInstance
if (mpegts && typeof mpegts.destroy === 'function') {
mpegts.destroy()
}
delete (video as any).__mpegtsInstance
}
}
console.log('[VideoElement] Source:', src)
console.log('[VideoElement] Detected protocol:', detection.protocol)
console.log('[VideoElement] Is live stream?', detection.isLive)
@@ -451,20 +484,29 @@ export const VideoElement: React.FC<VideoElementProps> = ({
src,
autoplay,
onAudioTracksLoaded: (tracks) => {
if (isCancelled) return
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
},
onQualityLevelsLoaded: (qualities) => {
if (isCancelled) return
setAvailableQualities(qualities)
onQualityLevelsLoaded?.(qualities)
},
onSubtitleTracksLoaded: (tracks) => {
if (isCancelled) return
setHlsSubtitles(tracks)
onSubtitleTracksLoaded?.(tracks)
},
onError: handleError,
})
if (isCancelled) {
teardownPlayer()
return
}
} else {
if (isCancelled) return
console.log('[VideoElement] Using native HLS playback')
video.src = src
if (autoplay) {
@@ -484,6 +526,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError: handleError,
onLoadedMetadata,
})
if (isCancelled) {
teardownPlayer()
return
}
break
}
@@ -497,11 +544,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onError: handleError,
onLoadedMetadata,
})
if (isCancelled) {
teardownPlayer()
return
}
break
}
case 'dash': {
// DASH streaming - not yet implemented
if (isCancelled) return
const error = new Error('DASH streaming is not yet supported')
console.error('[VideoElement]', error.message)
setVideoState((prev) => ({ ...prev, error, loading: false }))
@@ -512,6 +565,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
case 'native':
default: {
// Native HTML5 video (MP4, WebM, etc.)
if (isCancelled) return
console.log('[VideoElement] Using native video.src')
video.src = src
if (autoplay) {
@@ -528,6 +582,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
} else {
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
}
if (isCancelled) return
console.error('[VideoElement] Setup error:', error)
setVideoState((prev) => ({
...prev,
@@ -538,35 +595,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
}
}
setupPlayer()
void setupPlayer()
// Cleanup function
return () => {
if (cleanupFn) {
cleanupFn()
}
// Also check for any lingering player instances
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
if ((video as any).__rtmpInstance) {
const rtmp = (video as any).__rtmpInstance
if (rtmp && typeof rtmp.destroy === 'function') {
rtmp.destroy()
}
delete (video as any).__rtmpInstance
}
if ((video as any).__mpegtsInstance) {
const mpegts = (video as any).__mpegtsInstance
if (mpegts && typeof mpegts.destroy === 'function') {
mpegts.destroy()
}
delete (video as any).__mpegtsInstance
}
isCancelled = true
teardownPlayer()
}
}, [
src,
+89 -78
View File
@@ -1,130 +1,141 @@
import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { VideoPlayer } from './VideoPlayer';
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor, fireEvent, act } from '@testing-library/react'
import { VideoPlayer } from './VideoPlayer'
describe('VideoPlayer', () => {
const defaultProps = {
src: 'https://example.com/video.mp4',
};
}
it('renders video player container', () => {
const { container } = render(<VideoPlayer {...defaultProps} />);
expect(container.querySelector('.video-player')).toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} />)
expect(container.querySelector('.video-player')).toBeInTheDocument()
})
it('renders video element', () => {
const { container } = render(<VideoPlayer {...defaultProps} />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} />)
const video = container.querySelector('video')
expect(video).toBeInTheDocument()
})
it('renders with autoplay prop', () => {
const { container } = render(<VideoPlayer {...defaultProps} autoplay />);
const video = container.querySelector('video');
const { container } = render(<VideoPlayer {...defaultProps} autoplay />)
const video = container.querySelector('video')
// VideoElement handles autoplay programmatically via play() method
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
})
it('renders with muted prop', () => {
const { container } = render(<VideoPlayer {...defaultProps} muted />);
const video = container.querySelector('video');
const { container } = render(<VideoPlayer {...defaultProps} muted />)
const video = container.querySelector('video')
// Muted state is managed through VideoElement
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
})
it('applies loop when enabled', () => {
const { container } = render(<VideoPlayer {...defaultProps} loop />);
const video = container.querySelector('video');
expect(video).toHaveAttribute('loop');
});
const { container } = render(<VideoPlayer {...defaultProps} loop />)
const video = container.querySelector('video')
expect(video).toHaveAttribute('loop')
})
it('applies custom className', () => {
const className = 'custom-player';
const { container } = render(<VideoPlayer {...defaultProps} className={className} />);
expect(container.querySelector('.video-player')).toHaveClass('video-player', className);
});
const className = 'custom-player'
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
expect(container.querySelector('.video-player')).toHaveClass('video-player', className)
})
it('calls onPlay callback when play event fires', async () => {
const onPlay = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />);
const onPlay = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('play'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.play(video)
})
await waitFor(() => {
expect(onPlay).toHaveBeenCalled();
});
});
expect(onPlay).toHaveBeenCalled()
})
})
it('calls onPause callback when pause event fires', async () => {
const onPause = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />);
const onPause = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('pause'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.pause(video)
})
await waitFor(() => {
expect(onPause).toHaveBeenCalled();
});
});
expect(onPause).toHaveBeenCalled()
})
})
it('calls onEnded callback when ended event fires', async () => {
const onEnded = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />);
const onEnded = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />)
const video = container.querySelector('video') as HTMLVideoElement;
video.dispatchEvent(new Event('ended'));
const video = container.querySelector('video') as HTMLVideoElement
act(() => {
fireEvent.ended(video)
})
await waitFor(() => {
expect(onEnded).toHaveBeenCalled();
});
});
expect(onEnded).toHaveBeenCalled()
})
})
it('calls onTimeUpdate callback with current time', async () => {
const onTimeUpdate = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />);
const onTimeUpdate = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />)
const video = container.querySelector('video') as HTMLVideoElement;
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true });
video.dispatchEvent(new Event('timeupdate'));
const video = container.querySelector('video') as HTMLVideoElement
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true })
act(() => {
fireEvent.timeUpdate(video)
})
await waitFor(() => {
expect(onTimeUpdate).toHaveBeenCalledWith(10.5);
});
});
expect(onTimeUpdate).toHaveBeenCalledWith(10.5)
})
})
it('renders with subtitles prop', () => {
it('renders with subtitles prop', async () => {
const subtitles = [
{ src: 'subtitles-en.vtt', lang: 'en', label: 'English' },
{ src: 'subtitles-tr.vtt', lang: 'tr', label: 'Türkçe' },
];
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />);
]
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />)
const video = container.querySelector('video') as HTMLVideoElement;
const video = container.querySelector('video') as HTMLVideoElement
// Subtitles are added dynamically by VideoElement
expect(video).toBeInTheDocument();
});
expect(video).toBeInTheDocument()
await waitFor(() => {
expect(container.querySelectorAll('track')).toHaveLength(2)
})
})
it('renders without errors', () => {
const onError = vi.fn();
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />);
const onError = vi.fn()
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />)
const video = container.querySelector('video') as HTMLVideoElement;
expect(video).toBeInTheDocument();
const video = container.querySelector('video') as HTMLVideoElement
expect(video).toBeInTheDocument()
// Error handling is tested separately in integration tests
});
})
it('hides controls when controls prop is false', () => {
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />);
const controls = container.querySelector('.controls');
expect(controls).not.toBeInTheDocument();
});
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
const controls = container.querySelector('.controls')
expect(controls).not.toBeInTheDocument()
})
it('applies custom style', () => {
const style = { width: '800px', height: '450px' };
const { container } = render(<VideoPlayer {...defaultProps} style={style} />);
const playerElement = container.querySelector('.video-player') as HTMLElement;
expect(playerElement.style.width).toBe('800px');
expect(playerElement.style.height).toBe('450px');
});
});
const style = { width: '800px', height: '450px' }
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
const playerElement = container.querySelector('.video-player') as HTMLElement
expect(playerElement.style.width).toBe('800px')
expect(playerElement.style.height).toBe('450px')
})
})
+5 -4
View File
@@ -3,26 +3,27 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
import { VideoElement } from './VideoElement'
import { ControlsLayer } from './ControlsLayer'
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
import { initializePolyfills } from '../utils/polyfills'
import '../styles/variables.css'
import './VideoPlayer.css'
// Lazy load polyfills only if needed
// Initialize polyfills only when the current browser needs them
let polyfillsInitialized = false
const initializePolyfillsIfNeeded = async () => {
const initializePolyfillsIfNeeded = () => {
if (polyfillsInitialized) return
if (typeof document === 'undefined') return
// Check if polyfills are needed
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
if (needsFullscreenPolyfill || needsPIPPolyfill) {
const { initializePolyfills } = await import('../utils/polyfills')
initializePolyfills()
polyfillsInitialized = true
}
}
// Initialize polyfills asynchronously
// Initialize polyfills if needed
initializePolyfillsIfNeeded()
const VideoPlayerContent: React.FC<
+4 -1
View File
@@ -7,7 +7,10 @@ export const PIPButton: React.FC = () => {
const { videoState, togglePictureInPicture } = usePlayerContext()
// Check if PIP is supported
const isPIPSupported = 'pictureInPictureEnabled' in document
const isPIPSupported =
typeof (document as any).pictureInPictureEnabled === 'boolean' &&
(document as any).pictureInPictureEnabled &&
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
if (!isPIPSupported) {
return null
+122
View File
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import type { AudioTrack, VideoQuality } from '../../types'
import { SettingsMenu } from './SettingsMenu'
const { contextState } = vi.hoisted(() => ({
contextState: {
value: null as any,
},
}))
vi.mock('../../contexts/PlayerContext', () => ({
usePlayerContext: () => contextState.value,
}))
const audioTracks: AudioTrack[] = [
{
name: 'English',
language: 'en',
url: '',
groupId: 'audio',
},
]
const qualities: VideoQuality[] = [
{ label: '1080p', height: 1080, levelIndex: 0 },
{ label: '720p', height: 720, levelIndex: 1 },
]
const subtitles = [{ src: '/sub.vtt', lang: 'en', label: 'English' }]
describe('SettingsMenu', () => {
beforeEach(() => {
contextState.value = {
uiState: {
controlsVisible: true,
settingsOpen: true,
volumeControlOpen: false,
qualityMenuOpen: false,
subtitleMenuOpen: false,
},
videoState: {
playing: false,
currentTime: 0,
duration: 0,
buffered: 0,
volume: 1,
muted: false,
playbackRate: 1,
fullscreen: false,
pictureInPicture: false,
loading: false,
error: null,
seeking: false,
isLiveBroadcast: false,
},
settings: {
quality: null,
subtitle: null,
audioTrack: null,
playbackRate: 1,
},
setPlaybackRate: vi.fn(),
setSubtitle: vi.fn(),
setAudioTrack: vi.fn(),
setQuality: vi.fn(),
toggleSettings: vi.fn(),
translations: {
noSubtitlesAvailable: 'No subtitles available',
subtitles: 'Subtitles',
off: 'Off',
auto: 'Auto',
quality: 'Quality',
speed: 'Speed',
normal: 'Normal',
default: 'Default',
audioTrack: 'Audio Track',
settings: 'Settings',
level: 'Level',
},
}
})
it('does not render when settings are closed', () => {
contextState.value.uiState.settingsOpen = false
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
})
it('changes playback speed from speed submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Speed'))
fireEvent.click(screen.getByText('1.5x'))
expect(contextState.value.setPlaybackRate).toHaveBeenCalledWith(1.5)
})
it('selects subtitle from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Subtitles'))
fireEvent.click(screen.getByRole('button', { name: 'English' }))
expect(contextState.value.setSubtitle).toHaveBeenCalledWith(subtitles[0])
})
it('selects audio track from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Audio Track'))
fireEvent.click(screen.getByRole('button', { name: 'English' }))
expect(contextState.value.setAudioTrack).toHaveBeenCalledWith(audioTracks[0])
})
it('selects quality from submenu', () => {
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
fireEvent.click(screen.getByText('Quality'))
fireEvent.click(screen.getByText('720p'))
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
})
})
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/react'
import { useKeyboardShortcuts } from './useKeyboardShortcuts'
const { contextState } = vi.hoisted(() => ({
contextState: {
value: null as any,
},
}))
vi.mock('../contexts/PlayerContext', () => ({
usePlayerContext: () => contextState.value,
}))
const TestComponent = ({ enabled = true }: { enabled?: boolean }) => {
useKeyboardShortcuts(enabled)
return <div>keyboard-shortcuts-test</div>
}
describe('useKeyboardShortcuts', () => {
beforeEach(() => {
contextState.value = {
videoState: {
currentTime: 30,
duration: 120,
volume: 0.5,
},
togglePlay: vi.fn(),
seek: vi.fn(),
setVolume: vi.fn(),
toggleMute: vi.fn(),
toggleFullscreen: vi.fn(),
togglePictureInPicture: vi.fn(),
}
})
it('handles play/pause and seek shortcuts', () => {
render(<TestComponent />)
fireEvent.keyDown(window, { key: 'k' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'j' })
expect(contextState.value.togglePlay).toHaveBeenCalledTimes(1)
expect(contextState.value.seek).toHaveBeenNthCalledWith(1, 35)
expect(contextState.value.seek).toHaveBeenNthCalledWith(2, 20)
})
it('handles volume, fullscreen, pip and mute shortcuts', () => {
render(<TestComponent />)
fireEvent.keyDown(window, { key: 'ArrowUp' })
fireEvent.keyDown(window, { key: 'm' })
fireEvent.keyDown(window, { key: 'f' })
fireEvent.keyDown(window, { key: 'p' })
expect(contextState.value.setVolume).toHaveBeenCalledWith(0.6)
expect(contextState.value.toggleMute).toHaveBeenCalledTimes(1)
expect(contextState.value.toggleFullscreen).toHaveBeenCalledTimes(1)
expect(contextState.value.togglePictureInPicture).toHaveBeenCalledTimes(1)
})
it('does not trigger shortcuts while typing in inputs', () => {
render(
<div>
<input aria-label="search" />
<TestComponent />
</div>
)
fireEvent.keyDown(document.querySelector('input')!, { key: 'k' })
expect(contextState.value.togglePlay).not.toHaveBeenCalled()
})
})
+90
View File
@@ -0,0 +1,90 @@
import { useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import { useTouchGestures } from './useTouchGestures'
const { contextState } = vi.hoisted(() => ({
contextState: {
value: null as any,
},
}))
vi.mock('../contexts/PlayerContext', () => ({
usePlayerContext: () => contextState.value,
}))
const TestComponent = () => {
const ref = useRef<HTMLDivElement | null>(null)
useTouchGestures(ref)
return <div ref={ref} data-testid="touch-area" />
}
const dispatchTouch = (
element: HTMLElement,
type: 'touchstart' | 'touchend',
x: number,
y: number
) => {
const event = new Event(type, { bubbles: true, cancelable: true }) as any
const touchPoint = { clientX: x, clientY: y }
if (type === 'touchstart') {
event.touches = [touchPoint]
} else {
event.changedTouches = [touchPoint]
}
element.dispatchEvent(event)
}
describe('useTouchGestures', () => {
beforeEach(() => {
vi.useFakeTimers()
contextState.value = {
videoState: {
currentTime: 50,
duration: 120,
volume: 0.5,
},
togglePlay: vi.fn(),
seek: vi.fn(),
setVolume: vi.fn(),
}
})
afterEach(() => {
vi.useRealTimers()
})
it('toggles play on single tap', () => {
const { getByTestId } = render(<TestComponent />)
const area = getByTestId('touch-area')
Object.defineProperty(area, 'clientWidth', { configurable: true, value: 300 })
Object.defineProperty(area, 'clientHeight', { configurable: true, value: 200 })
area.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 300, height: 200 } as DOMRect)
dispatchTouch(area, 'touchstart', 150, 100)
dispatchTouch(area, 'touchend', 150, 100)
vi.advanceTimersByTime(450)
expect(contextState.value.togglePlay).toHaveBeenCalledTimes(1)
})
it('seeks on horizontal swipe', () => {
const { getByTestId } = render(<TestComponent />)
const area = getByTestId('touch-area')
Object.defineProperty(area, 'clientWidth', { configurable: true, value: 300 })
Object.defineProperty(area, 'clientHeight', { configurable: true, value: 200 })
area.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 300, height: 200 } as DOMRect)
dispatchTouch(area, 'touchstart', 50, 100)
dispatchTouch(area, 'touchend', 250, 100)
expect(contextState.value.seek).toHaveBeenCalled()
})
})
+1
View File
@@ -21,6 +21,7 @@ export type {
export { formatTime, parseTime } from './utils/time'
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
export { initializePolyfills, features } from './utils/polyfills'
// i18n
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CSSProperties, MutableRefObject } from 'react'
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash'
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
export interface SubtitleTrack {
src: string
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupHlsInstance } from './hlsSetup'
class MockHlsInstance {
public static Events = {
MANIFEST_PARSED: 'manifestParsed',
LEVEL_LOADED: 'levelLoaded',
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated',
ERROR: 'error',
}
public static ErrorTypes = {
NETWORK_ERROR: 'networkError',
MEDIA_ERROR: 'mediaError',
OTHER_ERROR: 'otherError',
}
public loadSource = vi.fn()
public attachMedia = vi.fn()
public destroy = vi.fn()
public startLoad = vi.fn()
public recoverMediaError = vi.fn()
private handlers = new Map<string, Array<(...args: any[]) => void>>()
on(event: string, handler: (...args: any[]) => void) {
const existing = this.handlers.get(event) || []
existing.push(handler)
this.handlers.set(event, existing)
}
emit(event: string, ...args: any[]) {
for (const handler of this.handlers.get(event) || []) {
handler(...args)
}
}
}
const loadHls = vi.fn(async () => MockHlsInstance)
const isHlsSupported = vi.fn(() => true)
const getHlsAudioTracks = vi.fn(() => [{ name: 'English', language: 'en', groupId: 'audio', url: '' }])
const getHlsQualities = vi.fn(() => [{ label: '720p', height: 720, levelIndex: 1 }])
const getHlsSubtitleTracks = vi.fn(() => [{ label: 'English', lang: 'en', src: '/sub.vtt' }])
vi.mock('./hlsLoader', () => ({
loadHls,
isHlsSupported,
getHlsAudioTracks,
getHlsQualities,
getHlsSubtitleTracks,
}))
describe('setupHlsInstance', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('sets up hls, emits levels/tracks and cleans up', async () => {
const video = document.createElement('video')
const onAudioTracksLoaded = vi.fn()
const onQualityLevelsLoaded = vi.fn()
const onSubtitleTracksLoaded = vi.fn()
const cleanup = await setupHlsInstance({
video,
src: 'https://example.com/stream.m3u8',
autoplay: false,
onAudioTracksLoaded,
onQualityLevelsLoaded,
onSubtitleTracksLoaded,
})
const hls = (video as any).__hlsInstance as MockHlsInstance
expect(hls).toBeDefined()
expect(hls.loadSource).toHaveBeenCalledWith('https://example.com/stream.m3u8')
expect(hls.attachMedia).toHaveBeenCalledWith(video)
hls.emit(MockHlsInstance.Events.MANIFEST_PARSED)
expect(onAudioTracksLoaded).toHaveBeenCalled()
expect(onQualityLevelsLoaded).toHaveBeenCalled()
expect(onSubtitleTracksLoaded).toHaveBeenCalled()
vi.advanceTimersByTime(250)
expect(onQualityLevelsLoaded).toHaveBeenCalledTimes(2)
cleanup()
expect(hls.destroy).toHaveBeenCalledTimes(1)
expect((video as any).__hlsInstance).toBeUndefined()
})
it('attempts recovery on fatal network/media errors', async () => {
const video = document.createElement('video')
const onError = vi.fn()
await setupHlsInstance({
video,
src: 'https://example.com/stream.m3u8',
autoplay: false,
onError,
})
const hls = (video as any).__hlsInstance as MockHlsInstance
hls.emit(MockHlsInstance.Events.ERROR, null, {
fatal: true,
type: MockHlsInstance.ErrorTypes.NETWORK_ERROR,
})
hls.emit(MockHlsInstance.Events.ERROR, null, {
fatal: true,
type: MockHlsInstance.ErrorTypes.MEDIA_ERROR,
})
hls.emit(MockHlsInstance.Events.ERROR, null, {
fatal: true,
type: MockHlsInstance.ErrorTypes.OTHER_ERROR,
})
expect(hls.startLoad).toHaveBeenCalledTimes(1)
expect(hls.recoverMediaError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
})
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { setupMpegtsInstance } from './mpegtsSetup'
const createMockPlayer = () => {
const handlers = new Map<string, (...args: any[]) => void>()
return {
attachMediaElement: vi.fn(),
load: vi.fn(),
unload: vi.fn(),
detachMediaElement: vi.fn(),
destroy: vi.fn(),
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
}),
off: vi.fn(),
emit: (event: string, ...args: any[]) => {
handlers.get(event)?.(...args)
},
}
}
const mockPlayer = createMockPlayer()
const mockMpegts = {
Events: {
ERROR: 'error',
LOADING_COMPLETE: 'loadingComplete',
RECOVERED_EARLY_EOF: 'recoveredEOF',
METADATA_ARRIVED: 'metadataArrived',
STATISTICS_INFO: 'stats',
},
ErrorTypes: {
NETWORK_ERROR: 'networkError',
MEDIA_ERROR: 'mediaError',
},
ErrorDetails: {
NETWORK_EXCEPTION: 'networkException',
NETWORK_STATUS_CODE_INVALID: 'networkStatusCodeInvalid',
MEDIA_MSE_ERROR: 'mediaMSEError',
},
createPlayer: vi.fn(() => mockPlayer),
}
vi.mock('./mpegtsLoader', () => ({
loadMpegts: vi.fn(async () => mockMpegts),
isMpegtsSupported: vi.fn(() => true),
createDefaultMpegtsConfig: vi.fn(() => ({ enableWorker: false })),
}))
describe('setupMpegtsInstance', () => {
it('sets up and cleans mpegts player instance', async () => {
const video = document.createElement('video')
const cleanup = await setupMpegtsInstance({
video,
src: 'http://example.com/live/stream.ts',
autoplay: false,
})
expect(mockMpegts.createPlayer).toHaveBeenCalled()
expect(mockPlayer.attachMediaElement).toHaveBeenCalledWith(video)
expect(mockPlayer.load).toHaveBeenCalled()
expect((video as any).__mpegtsInstance).toBeDefined()
cleanup()
expect(mockPlayer.unload).toHaveBeenCalled()
expect(mockPlayer.detachMediaElement).toHaveBeenCalled()
expect(mockPlayer.destroy).toHaveBeenCalled()
expect((video as any).__mpegtsInstance).toBeUndefined()
})
it('triggers metadata callback and non-recoverable error callback', async () => {
const video = document.createElement('video')
const onLoadedMetadata = vi.fn()
const onError = vi.fn()
await setupMpegtsInstance({
video,
src: 'http://example.com/live/stream.ts',
autoplay: false,
onLoadedMetadata,
onError,
})
mockPlayer.emit(mockMpegts.Events.METADATA_ARRIVED, { duration: 123 })
mockPlayer.emit(mockMpegts.Events.ERROR, 'networkError', 'fatalError', {})
expect(onLoadedMetadata).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
})
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { setupRtmpInstance } from './rtmpSetup'
const createMockPlayer = () => {
const handlers = new Map<string, (...args: any[]) => void>()
return {
attachMediaElement: vi.fn(),
load: vi.fn(),
unload: vi.fn(),
detachMediaElement: vi.fn(),
destroy: vi.fn(),
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
}),
off: vi.fn(),
emit: (event: string, ...args: any[]) => {
handlers.get(event)?.(...args)
},
}
}
const mockPlayer = createMockPlayer()
const mockFlvjs = {
Events: {
ERROR: 'error',
LOADING_COMPLETE: 'loadingComplete',
RECOVERED_EARLY_EOF: 'recoveredEOF',
METADATA_ARRIVED: 'metadataArrived',
STATISTICS_INFO: 'stats',
},
ErrorTypes: {
NETWORK_ERROR: 'networkError',
MEDIA_ERROR: 'mediaError',
},
ErrorDetails: {
NETWORK_EXCEPTION: 'networkException',
NETWORK_STATUS_CODE_INVALID: 'networkStatusCodeInvalid',
MEDIA_MSE_ERROR: 'mediaMSEError',
},
createPlayer: vi.fn(() => mockPlayer),
}
vi.mock('./rtmpLoader', () => ({
loadFlvjs: vi.fn(async () => mockFlvjs),
isFlvjsSupported: vi.fn(() => true),
createDefaultFlvConfig: vi.fn(() => ({ enableWorker: true })),
}))
describe('setupRtmpInstance', () => {
it('sets up and cleans flv player instance', async () => {
const video = document.createElement('video')
const cleanup = await setupRtmpInstance({
video,
src: 'http://example.com/live.flv',
autoplay: false,
})
expect(mockFlvjs.createPlayer).toHaveBeenCalled()
expect(mockPlayer.attachMediaElement).toHaveBeenCalledWith(video)
expect(mockPlayer.load).toHaveBeenCalled()
expect((video as any).__rtmpInstance).toBeDefined()
cleanup()
expect(mockPlayer.unload).toHaveBeenCalled()
expect(mockPlayer.detachMediaElement).toHaveBeenCalled()
expect(mockPlayer.destroy).toHaveBeenCalled()
expect((video as any).__rtmpInstance).toBeUndefined()
})
it('triggers metadata callback and non-recoverable error callback', async () => {
const video = document.createElement('video')
const onLoadedMetadata = vi.fn()
const onError = vi.fn()
await setupRtmpInstance({
video,
src: 'http://example.com/live.flv',
autoplay: false,
onLoadedMetadata,
onError,
})
mockPlayer.emit(mockFlvjs.Events.METADATA_ARRIVED, { duration: 123 })
mockPlayer.emit(mockFlvjs.Events.ERROR, 'networkError', 'fatalError', {})
expect(onLoadedMetadata).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
})
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
},
},
build: {
copyPublicDir: false,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'VideoPlayer',