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
|
||||
|
||||
[](https://www.npmjs.com/package/@alper/video-player)
|
||||
[](https://bundlephobia.com/package/@alper/video-player)
|
||||
[](https://www.npmjs.com/package/@source/player)
|
||||
[](https://bundlephobia.com/package/@source/player)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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
@@ -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
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +405,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
let isCancelled = false
|
||||
|
||||
setAvailableAudioTracks([])
|
||||
onAudioTracksLoaded?.([])
|
||||
@@ -426,6 +427,38 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
const detection = detectVideoProtocol(src)
|
||||
let cleanupFn: (() => void) | null = null
|
||||
|
||||
const teardownPlayer = () => {
|
||||
if (cleanupFn) {
|
||||
cleanupFn()
|
||||
cleanupFn = null
|
||||
}
|
||||
|
||||
// Also check for any lingering player instances
|
||||
if ((video as any).__hlsInstance) {
|
||||
const hls = (video as any).__hlsInstance
|
||||
if (hls && typeof hls.destroy === 'function') {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
}
|
||||
|
||||
if ((video as any).__rtmpInstance) {
|
||||
const rtmp = (video as any).__rtmpInstance
|
||||
if (rtmp && typeof rtmp.destroy === 'function') {
|
||||
rtmp.destroy()
|
||||
}
|
||||
delete (video as any).__rtmpInstance
|
||||
}
|
||||
|
||||
if ((video as any).__mpegtsInstance) {
|
||||
const mpegts = (video as any).__mpegtsInstance
|
||||
if (mpegts && typeof mpegts.destroy === 'function') {
|
||||
mpegts.destroy()
|
||||
}
|
||||
delete (video as any).__mpegtsInstance
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[VideoElement] Source:', src)
|
||||
console.log('[VideoElement] Detected protocol:', detection.protocol)
|
||||
console.log('[VideoElement] Is live stream?', detection.isLive)
|
||||
@@ -451,20 +484,29 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
src,
|
||||
autoplay,
|
||||
onAudioTracksLoaded: (tracks) => {
|
||||
if (isCancelled) return
|
||||
setAvailableAudioTracks(tracks)
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
},
|
||||
onQualityLevelsLoaded: (qualities) => {
|
||||
if (isCancelled) return
|
||||
setAvailableQualities(qualities)
|
||||
onQualityLevelsLoaded?.(qualities)
|
||||
},
|
||||
onSubtitleTracksLoaded: (tracks) => {
|
||||
if (isCancelled) return
|
||||
setHlsSubtitles(tracks)
|
||||
onSubtitleTracksLoaded?.(tracks)
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
if (isCancelled) {
|
||||
teardownPlayer()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (isCancelled) return
|
||||
console.log('[VideoElement] Using native HLS playback')
|
||||
video.src = src
|
||||
if (autoplay) {
|
||||
@@ -484,6 +526,11 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
onError: handleError,
|
||||
onLoadedMetadata,
|
||||
})
|
||||
|
||||
if (isCancelled) {
|
||||
teardownPlayer()
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -497,11 +544,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
onError: handleError,
|
||||
onLoadedMetadata,
|
||||
})
|
||||
|
||||
if (isCancelled) {
|
||||
teardownPlayer()
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'dash': {
|
||||
// DASH streaming - not yet implemented
|
||||
if (isCancelled) return
|
||||
const error = new Error('DASH streaming is not yet supported')
|
||||
console.error('[VideoElement]', error.message)
|
||||
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
||||
@@ -512,6 +565,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
case 'native':
|
||||
default: {
|
||||
// Native HTML5 video (MP4, WebM, etc.)
|
||||
if (isCancelled) return
|
||||
console.log('[VideoElement] Using native video.src')
|
||||
video.src = src
|
||||
if (autoplay) {
|
||||
@@ -528,6 +582,9 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
} else {
|
||||
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
||||
}
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
console.error('[VideoElement] Setup error:', error)
|
||||
setVideoState((prev) => ({
|
||||
...prev,
|
||||
@@ -538,35 +595,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
setupPlayer()
|
||||
void setupPlayer()
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (cleanupFn) {
|
||||
cleanupFn()
|
||||
}
|
||||
// Also check for any lingering player instances
|
||||
if ((video as any).__hlsInstance) {
|
||||
const hls = (video as any).__hlsInstance
|
||||
if (hls && typeof hls.destroy === 'function') {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
}
|
||||
if ((video as any).__rtmpInstance) {
|
||||
const rtmp = (video as any).__rtmpInstance
|
||||
if (rtmp && typeof rtmp.destroy === 'function') {
|
||||
rtmp.destroy()
|
||||
}
|
||||
delete (video as any).__rtmpInstance
|
||||
}
|
||||
if ((video as any).__mpegtsInstance) {
|
||||
const mpegts = (video as any).__mpegtsInstance
|
||||
if (mpegts && typeof mpegts.destroy === 'function') {
|
||||
mpegts.destroy()
|
||||
}
|
||||
delete (video as any).__mpegtsInstance
|
||||
}
|
||||
isCancelled = true
|
||||
teardownPlayer()
|
||||
}
|
||||
}, [
|
||||
src,
|
||||
|
||||
@@ -1,130 +1,141 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, waitFor, fireEvent, act } from '@testing-library/react'
|
||||
import { VideoPlayer } from './VideoPlayer'
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
const defaultProps = {
|
||||
src: 'https://example.com/video.mp4',
|
||||
};
|
||||
}
|
||||
|
||||
it('renders video player container', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} />);
|
||||
expect(container.querySelector('.video-player')).toBeInTheDocument();
|
||||
});
|
||||
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||
expect(container.querySelector('.video-player')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders video element', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} />);
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||
const video = container.querySelector('video')
|
||||
expect(video).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with autoplay prop', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} autoplay />);
|
||||
const video = container.querySelector('video');
|
||||
const { container } = render(<VideoPlayer {...defaultProps} autoplay />)
|
||||
const video = container.querySelector('video')
|
||||
// VideoElement handles autoplay programmatically via play() method
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
expect(video).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with muted prop', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} muted />);
|
||||
const video = container.querySelector('video');
|
||||
const { container } = render(<VideoPlayer {...defaultProps} muted />)
|
||||
const video = container.querySelector('video')
|
||||
// Muted state is managed through VideoElement
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
expect(video).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies loop when enabled', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} loop />);
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toHaveAttribute('loop');
|
||||
});
|
||||
const { container } = render(<VideoPlayer {...defaultProps} loop />)
|
||||
const video = container.querySelector('video')
|
||||
expect(video).toHaveAttribute('loop')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const className = 'custom-player';
|
||||
const { container } = render(<VideoPlayer {...defaultProps} className={className} />);
|
||||
expect(container.querySelector('.video-player')).toHaveClass('video-player', className);
|
||||
});
|
||||
const className = 'custom-player'
|
||||
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
|
||||
expect(container.querySelector('.video-player')).toHaveClass('video-player', className)
|
||||
})
|
||||
|
||||
it('calls onPlay callback when play event fires', async () => {
|
||||
const onPlay = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />);
|
||||
const onPlay = vi.fn()
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPlay={onPlay} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('play'));
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
act(() => {
|
||||
fireEvent.play(video)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
expect(onPlay).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onPause callback when pause event fires', async () => {
|
||||
const onPause = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />);
|
||||
const onPause = vi.fn()
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onPause={onPause} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('pause'));
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
act(() => {
|
||||
fireEvent.pause(video)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPause).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
expect(onPause).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onEnded callback when ended event fires', async () => {
|
||||
const onEnded = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />);
|
||||
const onEnded = vi.fn()
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onEnded={onEnded} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
video.dispatchEvent(new Event('ended'));
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
act(() => {
|
||||
fireEvent.ended(video)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
expect(onEnded).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onTimeUpdate callback with current time', async () => {
|
||||
const onTimeUpdate = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />);
|
||||
const onTimeUpdate = vi.fn()
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onTimeUpdate={onTimeUpdate} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true });
|
||||
video.dispatchEvent(new Event('timeupdate'));
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true })
|
||||
act(() => {
|
||||
fireEvent.timeUpdate(video)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTimeUpdate).toHaveBeenCalledWith(10.5);
|
||||
});
|
||||
});
|
||||
expect(onTimeUpdate).toHaveBeenCalledWith(10.5)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with subtitles prop', () => {
|
||||
it('renders with subtitles prop', async () => {
|
||||
const subtitles = [
|
||||
{ src: 'subtitles-en.vtt', lang: 'en', label: 'English' },
|
||||
{ src: 'subtitles-tr.vtt', lang: 'tr', label: 'Türkçe' },
|
||||
];
|
||||
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />);
|
||||
]
|
||||
const { container } = render(<VideoPlayer {...defaultProps} subtitles={subtitles} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
// Subtitles are added dynamically by VideoElement
|
||||
expect(video).toBeInTheDocument();
|
||||
});
|
||||
expect(video).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('track')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders without errors', () => {
|
||||
const onError = vi.fn();
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />);
|
||||
const onError = vi.fn()
|
||||
const { container } = render(<VideoPlayer {...defaultProps} onError={onError} />)
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
expect(video).toBeInTheDocument();
|
||||
const video = container.querySelector('video') as HTMLVideoElement
|
||||
expect(video).toBeInTheDocument()
|
||||
// Error handling is tested separately in integration tests
|
||||
});
|
||||
})
|
||||
|
||||
it('hides controls when controls prop is false', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />);
|
||||
const controls = container.querySelector('.controls');
|
||||
expect(controls).not.toBeInTheDocument();
|
||||
});
|
||||
const { container } = render(<VideoPlayer {...defaultProps} controls={false} />)
|
||||
const controls = container.querySelector('.controls')
|
||||
expect(controls).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom style', () => {
|
||||
const style = { width: '800px', height: '450px' };
|
||||
const { container } = render(<VideoPlayer {...defaultProps} style={style} />);
|
||||
const playerElement = container.querySelector('.video-player') as HTMLElement;
|
||||
expect(playerElement.style.width).toBe('800px');
|
||||
expect(playerElement.style.height).toBe('450px');
|
||||
});
|
||||
});
|
||||
const style = { width: '800px', height: '450px' }
|
||||
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
|
||||
const playerElement = container.querySelector('.video-player') as HTMLElement
|
||||
expect(playerElement.style.width).toBe('800px')
|
||||
expect(playerElement.style.height).toBe('450px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,26 +3,27 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||
import { initializePolyfills } from '../utils/polyfills'
|
||||
import '../styles/variables.css'
|
||||
import './VideoPlayer.css'
|
||||
|
||||
// Lazy load polyfills only if needed
|
||||
// Initialize polyfills only when the current browser needs them
|
||||
let polyfillsInitialized = false
|
||||
const initializePolyfillsIfNeeded = async () => {
|
||||
const initializePolyfillsIfNeeded = () => {
|
||||
if (polyfillsInitialized) return
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
// Check if polyfills are needed
|
||||
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
|
||||
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
||||
|
||||
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
||||
const { initializePolyfills } = await import('../utils/polyfills')
|
||||
initializePolyfills()
|
||||
polyfillsInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize polyfills asynchronously
|
||||
// Initialize polyfills if needed
|
||||
initializePolyfillsIfNeeded()
|
||||
|
||||
const VideoPlayerContent: React.FC<
|
||||
|
||||
@@ -7,7 +7,10 @@ export const PIPButton: React.FC = () => {
|
||||
const { videoState, togglePictureInPicture } = usePlayerContext()
|
||||
|
||||
// Check if PIP is supported
|
||||
const isPIPSupported = 'pictureInPictureEnabled' in document
|
||||
const isPIPSupported =
|
||||
typeof (document as any).pictureInPictureEnabled === 'boolean' &&
|
||||
(document as any).pictureInPictureEnabled &&
|
||||
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
|
||||
|
||||
if (!isPIPSupported) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { AudioTrack, VideoQuality } from '../../types'
|
||||
import { SettingsMenu } from './SettingsMenu'
|
||||
|
||||
const { contextState } = vi.hoisted(() => ({
|
||||
contextState: {
|
||||
value: null as any,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../contexts/PlayerContext', () => ({
|
||||
usePlayerContext: () => contextState.value,
|
||||
}))
|
||||
|
||||
const audioTracks: AudioTrack[] = [
|
||||
{
|
||||
name: 'English',
|
||||
language: 'en',
|
||||
url: '',
|
||||
groupId: 'audio',
|
||||
},
|
||||
]
|
||||
|
||||
const qualities: VideoQuality[] = [
|
||||
{ label: '1080p', height: 1080, levelIndex: 0 },
|
||||
{ label: '720p', height: 720, levelIndex: 1 },
|
||||
]
|
||||
|
||||
const subtitles = [{ src: '/sub.vtt', lang: 'en', label: 'English' }]
|
||||
|
||||
describe('SettingsMenu', () => {
|
||||
beforeEach(() => {
|
||||
contextState.value = {
|
||||
uiState: {
|
||||
controlsVisible: true,
|
||||
settingsOpen: true,
|
||||
volumeControlOpen: false,
|
||||
qualityMenuOpen: false,
|
||||
subtitleMenuOpen: false,
|
||||
},
|
||||
videoState: {
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
buffered: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
playbackRate: 1,
|
||||
fullscreen: false,
|
||||
pictureInPicture: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
seeking: false,
|
||||
isLiveBroadcast: false,
|
||||
},
|
||||
settings: {
|
||||
quality: null,
|
||||
subtitle: null,
|
||||
audioTrack: null,
|
||||
playbackRate: 1,
|
||||
},
|
||||
setPlaybackRate: vi.fn(),
|
||||
setSubtitle: vi.fn(),
|
||||
setAudioTrack: vi.fn(),
|
||||
setQuality: vi.fn(),
|
||||
toggleSettings: vi.fn(),
|
||||
translations: {
|
||||
noSubtitlesAvailable: 'No subtitles available',
|
||||
subtitles: 'Subtitles',
|
||||
off: 'Off',
|
||||
auto: 'Auto',
|
||||
quality: 'Quality',
|
||||
speed: 'Speed',
|
||||
normal: 'Normal',
|
||||
default: 'Default',
|
||||
audioTrack: 'Audio Track',
|
||||
settings: 'Settings',
|
||||
level: 'Level',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('does not render when settings are closed', () => {
|
||||
contextState.value.uiState.settingsOpen = false
|
||||
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes playback speed from speed submenu', () => {
|
||||
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Speed'))
|
||||
fireEvent.click(screen.getByText('1.5x'))
|
||||
|
||||
expect(contextState.value.setPlaybackRate).toHaveBeenCalledWith(1.5)
|
||||
})
|
||||
|
||||
it('selects subtitle from submenu', () => {
|
||||
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Subtitles'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||
expect(contextState.value.setSubtitle).toHaveBeenCalledWith(subtitles[0])
|
||||
})
|
||||
|
||||
it('selects audio track from submenu', () => {
|
||||
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Audio Track'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||
expect(contextState.value.setAudioTrack).toHaveBeenCalledWith(audioTracks[0])
|
||||
})
|
||||
|
||||
it('selects quality from submenu', () => {
|
||||
render(<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Quality'))
|
||||
fireEvent.click(screen.getByText('720p'))
|
||||
expect(contextState.value.setQuality).toHaveBeenCalledWith(qualities[1])
|
||||
})
|
||||
})
|
||||
@@ -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 { 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
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
name: 'VideoPlayer',
|
||||
|
||||
Reference in New Issue
Block a user