Some fixes
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -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`
|
||||||
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
# 🎬 Modern Video Player
|
# 🎬 Modern Video Player
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@alper/video-player)
|
[](https://www.npmjs.com/package/@source/player)
|
||||||
[](https://bundlephobia.com/package/@alper/video-player)
|
[](https://bundlephobia.com/package/@source/player)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](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.
|
A feature-rich, modern video player library built with React, TypeScript, and Vite. Designed for reusability across multiple projects.
|
||||||
|
|
||||||
## 🏆 Why Choose This Player?
|
## 🏆 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 ⚠️ |
|
| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ |
|
||||||
| **React Native** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
|
| **React Native** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ |
|
||||||
| **TypeScript Native** | **Yes** ✅ | Types ⚠️ | Partial ⚠️ | Types ⚠️ |
|
| **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
|
### 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
|
- ⚡ **Blazing fast** - Zero runtime dependencies means faster load times
|
||||||
- 🎯 **React-first** - Built specifically for React, not a wrapper
|
- 🎯 **React-first** - Built specifically for React, not a wrapper
|
||||||
- 🔧 **Full TypeScript** - Complete type safety out of the box
|
- 🔧 **Full TypeScript** - Complete type safety out of the box
|
||||||
@@ -105,7 +105,7 @@ npm run build:lib
|
|||||||
npm link
|
npm link
|
||||||
|
|
||||||
# In your other project
|
# In your other project
|
||||||
npm link @alper/video-player
|
npm link @source/player
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Usage
|
## 🚀 Usage
|
||||||
@@ -113,8 +113,8 @@ npm link @alper/video-player
|
|||||||
### Basic Example
|
### Basic Example
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { VideoPlayer } from '@alper/video-player'
|
import { VideoPlayer } from '@source/player'
|
||||||
import '@alper/video-player/styles.css'
|
import '@source/player/styles.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +205,7 @@ function App() {
|
|||||||
### Feature Detection & Polyfills
|
### Feature Detection & Polyfills
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { features, initializePolyfills } from '@alper/video-player'
|
import { features, initializePolyfills } from '@source/player'
|
||||||
|
|
||||||
// Initialize polyfills manually (optional - auto-initialized on VideoPlayer mount)
|
// Initialize polyfills manually (optional - auto-initialized on VideoPlayer mount)
|
||||||
initializePolyfills()
|
initializePolyfills()
|
||||||
@@ -227,7 +227,7 @@ console.log('Has volume control:', features.hasVolumeControl())
|
|||||||
### CORS Error Handling
|
### CORS Error Handling
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { validateVideoURL, checkVideoCORS } from '@alper/video-player'
|
import { validateVideoURL, checkVideoCORS } from '@source/player'
|
||||||
|
|
||||||
// Validate URL before loading
|
// Validate URL before loading
|
||||||
const validation = validateVideoURL(videoUrl)
|
const validation = validateVideoURL(videoUrl)
|
||||||
@@ -360,8 +360,10 @@ interface PlayerTheme {
|
|||||||
|
|
||||||
## 📊 Bundle Size
|
## 📊 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)
|
- 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)
|
- Zero runtime dependencies (React is peer dependency)
|
||||||
|
|
||||||
## 🔧 Technical Details
|
## 🔧 Technical Details
|
||||||
@@ -370,8 +372,8 @@ interface PlayerTheme {
|
|||||||
- HTML5 Video API
|
- HTML5 Video API
|
||||||
- Fullscreen API
|
- Fullscreen API
|
||||||
- Picture-in-Picture API
|
- Picture-in-Picture API
|
||||||
- Media Session API
|
|
||||||
- Fetch API (Range Requests)
|
- Fetch API (Range Requests)
|
||||||
|
- TextTrack API (subtitles)
|
||||||
- Touch Events API
|
- Touch Events API
|
||||||
- Keyboard Events API
|
- Keyboard Events API
|
||||||
|
|
||||||
@@ -408,10 +410,10 @@ interface PlayerTheme {
|
|||||||
|
|
||||||
## 🚧 TODO / Future Enhancements
|
## 🚧 TODO / Future Enhancements
|
||||||
|
|
||||||
- [ ] Multiple audio track UI and switching
|
- [x] Multiple audio track UI and switching
|
||||||
- [ ] Quality selector for HLS streams
|
- [x] Quality selector for HLS streams
|
||||||
- [ ] Playback speed menu
|
- [x] Playback speed menu
|
||||||
- [ ] Settings panel
|
- [x] Settings panel
|
||||||
- [ ] Chapters/markers support
|
- [ ] Chapters/markers support
|
||||||
- [ ] Thumbnail preview on hover
|
- [ ] Thumbnail preview on hover
|
||||||
- [ ] Playlist support
|
- [ ] Playlist support
|
||||||
@@ -419,7 +421,7 @@ interface PlayerTheme {
|
|||||||
- [ ] AirPlay support
|
- [ ] AirPlay support
|
||||||
- [ ] DASH streaming support
|
- [ ] DASH streaming support
|
||||||
- [ ] Accessibility improvements (ARIA labels)
|
- [ ] Accessibility improvements (ARIA labels)
|
||||||
- [ ] Unit tests
|
- [x] Unit tests
|
||||||
- [ ] E2E tests
|
- [ ] E2E tests
|
||||||
- [ ] Storybook documentation
|
- [ ] Storybook documentation
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ function App() {
|
|||||||
|
|
||||||
// Demo video URLs (you can replace with your own)
|
// Demo video URLs (you can replace with your own)
|
||||||
const demoVideoUrl = '/player/ses.mp4'
|
const demoVideoUrl = '/player/ses.mp4'
|
||||||
const demoPoster = '/player/ses_duzeltilmis.srt'
|
const demoPoster = '/player/poster.svg'
|
||||||
|
|
||||||
const demoSubtitles: SubtitleTrack[] = [
|
const demoSubtitles: SubtitleTrack[] = [
|
||||||
{
|
{
|
||||||
@@ -129,7 +129,7 @@ function App() {
|
|||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>Built with React, TypeScript, and Vite</p>
|
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@alper/video-player",
|
"name": "@source/player",
|
||||||
"version": "0.1.15",
|
"version": "1.0.0",
|
||||||
"description": "Modern, feature-rich video player library for React",
|
"description": "Modern, feature-rich video player library for React",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/video-player.umd.cjs",
|
"main": "./dist/video-player.umd.cjs",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"import": "./dist/video-player.js",
|
"import": "./dist/video-player.js",
|
||||||
"require": "./dist/video-player.umd.cjs"
|
"require": "./dist/video-player.umd.cjs"
|
||||||
},
|
},
|
||||||
"./styles.css": "./dist/video-player.css"
|
"./styles.css": "./dist/player.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -77,9 +77,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.hibna.com.tr/hibna/video-player.git"
|
"url": "https://gits.hibna.com.tr/hibna/player"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.hibna.com.tr/api/packages/hibna/npm/"
|
"registry": "https://gits.hibna.com.tr/api/packages/hibna/npm/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
setAvailableAudioTracks([])
|
setAvailableAudioTracks([])
|
||||||
onAudioTracksLoaded?.([])
|
onAudioTracksLoaded?.([])
|
||||||
@@ -426,6 +427,38 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const detection = detectVideoProtocol(src)
|
const detection = detectVideoProtocol(src)
|
||||||
let cleanupFn: (() => void) | null = null
|
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] Source:', src)
|
||||||
console.log('[VideoElement] Detected protocol:', detection.protocol)
|
console.log('[VideoElement] Detected protocol:', detection.protocol)
|
||||||
console.log('[VideoElement] Is live stream?', detection.isLive)
|
console.log('[VideoElement] Is live stream?', detection.isLive)
|
||||||
@@ -451,20 +484,29 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
src,
|
src,
|
||||||
autoplay,
|
autoplay,
|
||||||
onAudioTracksLoaded: (tracks) => {
|
onAudioTracksLoaded: (tracks) => {
|
||||||
|
if (isCancelled) return
|
||||||
setAvailableAudioTracks(tracks)
|
setAvailableAudioTracks(tracks)
|
||||||
onAudioTracksLoaded?.(tracks)
|
onAudioTracksLoaded?.(tracks)
|
||||||
},
|
},
|
||||||
onQualityLevelsLoaded: (qualities) => {
|
onQualityLevelsLoaded: (qualities) => {
|
||||||
|
if (isCancelled) return
|
||||||
setAvailableQualities(qualities)
|
setAvailableQualities(qualities)
|
||||||
onQualityLevelsLoaded?.(qualities)
|
onQualityLevelsLoaded?.(qualities)
|
||||||
},
|
},
|
||||||
onSubtitleTracksLoaded: (tracks) => {
|
onSubtitleTracksLoaded: (tracks) => {
|
||||||
|
if (isCancelled) return
|
||||||
setHlsSubtitles(tracks)
|
setHlsSubtitles(tracks)
|
||||||
onSubtitleTracksLoaded?.(tracks)
|
onSubtitleTracksLoaded?.(tracks)
|
||||||
},
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
teardownPlayer()
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (isCancelled) return
|
||||||
console.log('[VideoElement] Using native HLS playback')
|
console.log('[VideoElement] Using native HLS playback')
|
||||||
video.src = src
|
video.src = src
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
@@ -484,6 +526,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
onLoadedMetadata,
|
onLoadedMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
teardownPlayer()
|
||||||
|
return
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,11 +544,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
onLoadedMetadata,
|
onLoadedMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
teardownPlayer()
|
||||||
|
return
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dash': {
|
case 'dash': {
|
||||||
// DASH streaming - not yet implemented
|
// DASH streaming - not yet implemented
|
||||||
|
if (isCancelled) return
|
||||||
const error = new Error('DASH streaming is not yet supported')
|
const error = new Error('DASH streaming is not yet supported')
|
||||||
console.error('[VideoElement]', error.message)
|
console.error('[VideoElement]', error.message)
|
||||||
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
||||||
@@ -512,6 +565,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
case 'native':
|
case 'native':
|
||||||
default: {
|
default: {
|
||||||
// Native HTML5 video (MP4, WebM, etc.)
|
// Native HTML5 video (MP4, WebM, etc.)
|
||||||
|
if (isCancelled) return
|
||||||
console.log('[VideoElement] Using native video.src')
|
console.log('[VideoElement] Using native video.src')
|
||||||
video.src = src
|
video.src = src
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
@@ -528,6 +582,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
console.error('[VideoElement] Setup error:', error)
|
console.error('[VideoElement] Setup error:', error)
|
||||||
setVideoState((prev) => ({
|
setVideoState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -538,35 +595,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPlayer()
|
void setupPlayer()
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (cleanupFn) {
|
isCancelled = true
|
||||||
cleanupFn()
|
teardownPlayer()
|
||||||
}
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
src,
|
src,
|
||||||
|
|||||||
@@ -1,130 +1,141 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor, fireEvent, act } from '@testing-library/react'
|
||||||
import { VideoPlayer } from './VideoPlayer';
|
import { VideoPlayer } from './VideoPlayer'
|
||||||
|
|
||||||
describe('VideoPlayer', () => {
|
describe('VideoPlayer', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
src: 'https://example.com/video.mp4',
|
src: 'https://example.com/video.mp4',
|
||||||
};
|
}
|
||||||
|
|
||||||
it('renders video player container', () => {
|
it('renders video player container', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} />);
|
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||||
expect(container.querySelector('.video-player')).toBeInTheDocument();
|
expect(container.querySelector('.video-player')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders video element', () => {
|
it('renders video element', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} />);
|
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector('video')
|
||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders with autoplay prop', () => {
|
it('renders with autoplay prop', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} autoplay />);
|
const { container } = render(<VideoPlayer {...defaultProps} autoplay />)
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector('video')
|
||||||
// VideoElement handles autoplay programmatically via play() method
|
// VideoElement handles autoplay programmatically via play() method
|
||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders with muted prop', () => {
|
it('renders with muted prop', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} muted />);
|
const { container } = render(<VideoPlayer {...defaultProps} muted />)
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector('video')
|
||||||
// Muted state is managed through VideoElement
|
// Muted state is managed through VideoElement
|
||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('applies loop when enabled', () => {
|
it('applies loop when enabled', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} loop />);
|
const { container } = render(<VideoPlayer {...defaultProps} loop />)
|
||||||
const video = container.querySelector('video');
|
const video = container.querySelector('video')
|
||||||
expect(video).toHaveAttribute('loop');
|
expect(video).toHaveAttribute('loop')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const className = 'custom-player';
|
const className = 'custom-player'
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} className={className} />);
|
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
|
||||||
expect(container.querySelector('.video-player')).toHaveClass('video-player', className);
|
expect(container.querySelector('.video-player')).toHaveClass('video-player', className)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onPlay callback when play event fires', async () => {
|
it('calls onPlay callback when play event fires', async () => {
|
||||||
const onPlay = vi.fn();
|
const onPlay = vi.fn()
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />);
|
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />)
|
||||||
|
|
||||||
const video = container.querySelector('video') as HTMLVideoElement;
|
const video = container.querySelector('video') as HTMLVideoElement
|
||||||
video.dispatchEvent(new Event('play'));
|
act(() => {
|
||||||
|
fireEvent.play(video)
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onPlay).toHaveBeenCalled();
|
expect(onPlay).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onPause callback when pause event fires', async () => {
|
it('calls onPause callback when pause event fires', async () => {
|
||||||
const onPause = vi.fn();
|
const onPause = vi.fn()
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />);
|
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />)
|
||||||
|
|
||||||
const video = container.querySelector('video') as HTMLVideoElement;
|
const video = container.querySelector('video') as HTMLVideoElement
|
||||||
video.dispatchEvent(new Event('pause'));
|
act(() => {
|
||||||
|
fireEvent.pause(video)
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onPause).toHaveBeenCalled();
|
expect(onPause).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onEnded callback when ended event fires', async () => {
|
it('calls onEnded callback when ended event fires', async () => {
|
||||||
const onEnded = vi.fn();
|
const onEnded = vi.fn()
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />);
|
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />)
|
||||||
|
|
||||||
const video = container.querySelector('video') as HTMLVideoElement;
|
const video = container.querySelector('video') as HTMLVideoElement
|
||||||
video.dispatchEvent(new Event('ended'));
|
act(() => {
|
||||||
|
fireEvent.ended(video)
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onEnded).toHaveBeenCalled();
|
expect(onEnded).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onTimeUpdate callback with current time', async () => {
|
it('calls onTimeUpdate callback with current time', async () => {
|
||||||
const onTimeUpdate = vi.fn();
|
const onTimeUpdate = vi.fn()
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />);
|
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />)
|
||||||
|
|
||||||
const video = container.querySelector('video') as HTMLVideoElement;
|
const video = container.querySelector('video') as HTMLVideoElement
|
||||||
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true });
|
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true })
|
||||||
video.dispatchEvent(new Event('timeupdate'));
|
act(() => {
|
||||||
|
fireEvent.timeUpdate(video)
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
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 = [
|
const subtitles = [
|
||||||
{ src: 'subtitles-en.vtt', lang: 'en', label: 'English' },
|
{ src: 'subtitles-en.vtt', lang: 'en', label: 'English' },
|
||||||
{ src: 'subtitles-tr.vtt', lang: 'tr', label: 'Türkçe' },
|
{ 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
|
// Subtitles are added dynamically by VideoElement
|
||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument()
|
||||||
});
|
await waitFor(() => {
|
||||||
|
expect(container.querySelectorAll('track')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('renders without errors', () => {
|
it('renders without errors', () => {
|
||||||
const onError = vi.fn();
|
const onError = vi.fn()
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />);
|
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />)
|
||||||
|
|
||||||
const video = container.querySelector('video') as HTMLVideoElement;
|
const video = container.querySelector('video') as HTMLVideoElement
|
||||||
expect(video).toBeInTheDocument();
|
expect(video).toBeInTheDocument()
|
||||||
// Error handling is tested separately in integration tests
|
// Error handling is tested separately in integration tests
|
||||||
});
|
})
|
||||||
|
|
||||||
it('hides controls when controls prop is false', () => {
|
it('hides controls when controls prop is false', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />);
|
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
|
||||||
const controls = container.querySelector('.controls');
|
const controls = container.querySelector('.controls')
|
||||||
expect(controls).not.toBeInTheDocument();
|
expect(controls).not.toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('applies custom style', () => {
|
it('applies custom style', () => {
|
||||||
const style = { width: '800px', height: '450px' };
|
const style = { width: '800px', height: '450px' }
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} style={style} />);
|
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
|
||||||
const playerElement = container.querySelector('.video-player') as HTMLElement;
|
const playerElement = container.querySelector('.video-player') as HTMLElement
|
||||||
expect(playerElement.style.width).toBe('800px');
|
expect(playerElement.style.width).toBe('800px')
|
||||||
expect(playerElement.style.height).toBe('450px');
|
expect(playerElement.style.height).toBe('450px')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -3,26 +3,27 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
|||||||
import { VideoElement } from './VideoElement'
|
import { VideoElement } from './VideoElement'
|
||||||
import { ControlsLayer } from './ControlsLayer'
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
import './VideoPlayer.css'
|
import './VideoPlayer.css'
|
||||||
|
|
||||||
// Lazy load polyfills only if needed
|
// Initialize polyfills only when the current browser needs them
|
||||||
let polyfillsInitialized = false
|
let polyfillsInitialized = false
|
||||||
const initializePolyfillsIfNeeded = async () => {
|
const initializePolyfillsIfNeeded = () => {
|
||||||
if (polyfillsInitialized) return
|
if (polyfillsInitialized) return
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
// Check if polyfills are needed
|
// Check if polyfills are needed
|
||||||
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
|
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
|
||||||
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
||||||
|
|
||||||
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
||||||
const { initializePolyfills } = await import('../utils/polyfills')
|
|
||||||
initializePolyfills()
|
initializePolyfills()
|
||||||
polyfillsInitialized = true
|
polyfillsInitialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize polyfills asynchronously
|
// Initialize polyfills if needed
|
||||||
initializePolyfillsIfNeeded()
|
initializePolyfillsIfNeeded()
|
||||||
|
|
||||||
const VideoPlayerContent: React.FC<
|
const VideoPlayerContent: React.FC<
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export const PIPButton: React.FC = () => {
|
|||||||
const { videoState, togglePictureInPicture } = usePlayerContext()
|
const { videoState, togglePictureInPicture } = usePlayerContext()
|
||||||
|
|
||||||
// Check if PIP is supported
|
// 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) {
|
if (!isPIPSupported) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import type { AudioTrack, VideoQuality } from '../../types'
|
||||||
|
import { SettingsMenu } from './SettingsMenu'
|
||||||
|
|
||||||
|
const { contextState } = vi.hoisted(() => ({
|
||||||
|
contextState: {
|
||||||
|
value: null as any,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../contexts/PlayerContext', () => ({
|
||||||
|
usePlayerContext: () => contextState.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const audioTracks: AudioTrack[] = [
|
||||||
|
{
|
||||||
|
name: 'English',
|
||||||
|
language: 'en',
|
||||||
|
url: '',
|
||||||
|
groupId: 'audio',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const qualities: VideoQuality[] = [
|
||||||
|
{ label: '1080p', height: 1080, levelIndex: 0 },
|
||||||
|
{ label: '720p', height: 720, levelIndex: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const subtitles = [{ src: '/sub.vtt', lang: 'en', label: 'English' }]
|
||||||
|
|
||||||
|
describe('SettingsMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
contextState.value = {
|
||||||
|
uiState: {
|
||||||
|
controlsVisible: true,
|
||||||
|
settingsOpen: true,
|
||||||
|
volumeControlOpen: false,
|
||||||
|
qualityMenuOpen: false,
|
||||||
|
subtitleMenuOpen: false,
|
||||||
|
},
|
||||||
|
videoState: {
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
buffered: 0,
|
||||||
|
volume: 1,
|
||||||
|
muted: false,
|
||||||
|
playbackRate: 1,
|
||||||
|
fullscreen: false,
|
||||||
|
pictureInPicture: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
seeking: false,
|
||||||
|
isLiveBroadcast: false,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
quality: null,
|
||||||
|
subtitle: null,
|
||||||
|
audioTrack: null,
|
||||||
|
playbackRate: 1,
|
||||||
|
},
|
||||||
|
setPlaybackRate: vi.fn(),
|
||||||
|
setSubtitle: vi.fn(),
|
||||||
|
setAudioTrack: vi.fn(),
|
||||||
|
setQuality: vi.fn(),
|
||||||
|
toggleSettings: vi.fn(),
|
||||||
|
translations: {
|
||||||
|
noSubtitlesAvailable: 'No subtitles available',
|
||||||
|
subtitles: 'Subtitles',
|
||||||
|
off: 'Off',
|
||||||
|
auto: 'Auto',
|
||||||
|
quality: 'Quality',
|
||||||
|
speed: 'Speed',
|
||||||
|
normal: 'Normal',
|
||||||
|
default: 'Default',
|
||||||
|
audioTrack: 'Audio Track',
|
||||||
|
settings: 'Settings',
|
||||||
|
level: 'Level',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when settings are closed', () => {
|
||||||
|
contextState.value.uiState.settingsOpen = false
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes playback speed from speed submenu', () => {
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Speed'))
|
||||||
|
fireEvent.click(screen.getByText('1.5x'))
|
||||||
|
|
||||||
|
expect(contextState.value.setPlaybackRate).toHaveBeenCalledWith(1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects subtitle from submenu', () => {
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Subtitles'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||||
|
expect(contextState.value.setSubtitle).toHaveBeenCalledWith(subtitles[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects audio track from submenu', () => {
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Audio Track'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||||
|
expect(contextState.value.setAudioTrack).toHaveBeenCalledWith(audioTracks[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects quality from submenu', () => {
|
||||||
|
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Quality'))
|
||||||
|
fireEvent.click(screen.getByText('720p'))
|
||||||
|
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,6 +21,7 @@ export type {
|
|||||||
export { formatTime, parseTime } from './utils/time'
|
export { formatTime, parseTime } from './utils/time'
|
||||||
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
||||||
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
||||||
|
export { initializePolyfills, features } from './utils/polyfills'
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
|
export { getTranslations, detectBrowserLanguage, translations } from './i18n'
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties, MutableRefObject } from 'react'
|
import type { CSSProperties, MutableRefObject } from 'react'
|
||||||
|
|
||||||
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash'
|
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
export interface SubtitleTrack {
|
||||||
src: string
|
src: string
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
copyPublicDir: false,
|
||||||
lib: {
|
lib: {
|
||||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||||
name: 'VideoPlayer',
|
name: 'VideoPlayer',
|
||||||
|
|||||||
Reference in New Issue
Block a user