From bad1cc6ca01d609ddaf6336ba134f669979ee277 Mon Sep 17 00:00:00 2001 From: hibna Date: Wed, 29 Oct 2025 13:10:07 +0300 Subject: [PATCH] Add i18n, tests, and update documentation Introduces internationalization (i18n) support with English and Turkish, adds unit tests and test setup with Vitest and React Testing Library, and updates documentation including README and changelog. Removes legacy publishing and usage guides, refactors components to use translation system, and updates build and test scripts in package.json. Also adds new utility modules for HLS and CORS, and improves PlayerContext and SettingsMenu for language support. --- CHANGELOG.md | 70 ++ LICENSE | 21 + PUBLISHING.md | 397 ------------ README-USAGE.md | 567 ----------------- README.md | 34 +- package.json | 14 +- pnpm-lock.yaml | 880 +++++++++++++++++++++++++- src/components/ControlsLayer.tsx | 9 +- src/components/VideoElement.tsx | 85 +-- src/components/VideoPlayer.test.tsx | 131 ++++ src/components/VideoPlayer.tsx | 24 +- src/components/menus/SettingsMenu.tsx | 31 +- src/constants/strings.ts | 11 + src/contexts/PlayerContext.tsx | 9 + src/i18n/index.ts | 69 ++ src/icons/index.tsx | 289 +++------ src/index.ts | 6 +- src/test/setup.ts | 72 +++ src/types/index.ts | 1 + src/utils/corsHelper.test.ts | 225 +++++++ src/utils/corsHelper.ts | 18 +- src/utils/hlsControl.ts | 43 ++ src/utils/hlsLoader.ts | 45 +- src/utils/hlsSetup.ts | 87 +++ vite.config.lib.ts | 23 +- vitest.config.ts | 23 + 26 files changed, 1843 insertions(+), 1341 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE delete mode 100644 PUBLISHING.md delete mode 100644 README-USAGE.md create mode 100644 src/components/VideoPlayer.test.tsx create mode 100644 src/constants/strings.ts create mode 100644 src/i18n/index.ts create mode 100644 src/test/setup.ts create mode 100644 src/utils/corsHelper.test.ts create mode 100644 src/utils/hlsControl.ts create mode 100644 src/utils/hlsSetup.ts create mode 100644 vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e63a38e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- ✅ Test infrastructure with Vitest and React Testing Library +- 🌍 Internationalization (i18n) system with English and Turkish support +- 📊 Comprehensive README with comparison table +- 📝 44 unit tests covering core functionality +- 🔄 Language prop for VideoPlayer component + +### Changed +- 🌐 Replaced hardcoded strings with translation system +- 📦 Updated build configuration for better tree-shaking + +### Fixed +- 🐛 TypeScript errors in test setup file + +## [0.1.3] - 2025-01-29 + +### Changed +- 📦 Fixed CSS export path in package.json +- 🔧 Build configuration improvements + +## [0.1.2] - 2025-01-29 + +### Added +- 📘 TypeScript declaration files support with vite-plugin-dts +- 🎯 Full type safety for all exports + +## [0.1.1] - 2025-01-29 + +### Added +- 🎬 Initial release of Modern Video Player +- ▶️ Core playback controls (play, pause, seek, volume) +- 🎨 Modern, responsive UI with auto-hiding controls +- ⌨️ Comprehensive keyboard shortcuts (15+ shortcuts) +- 📱 Touch gesture support for mobile devices +- 🎞️ HLS streaming support with automatic quality switching +- 📝 Subtitle support (WebVTT and SRT) +- 🎵 Multiple audio track support +- 🖼️ Picture-in-Picture and Fullscreen support +- 🎚️ Playback speed control (0.25x to 2x) +- 🎨 Theme customization with CSS variables +- 🔧 Zero runtime dependencies +- 📦 Tiny bundle size (~15KB gzipped) +- 🔄 HTTP Range Request support for large files +- 🛡️ CORS error handling and helpful error messages +- ⚡ Lazy loading for HLS.js and settings menu +- 🎯 React 18+ support +- 📘 Full TypeScript support + +### Technical Features +- 🏗️ Built with React 18, TypeScript 5, and Vite 7 +- 🎨 CSS modules and CSS variables for styling +- 🧩 Component-based architecture with React Context +- 🪝 Custom hooks for keyboard shortcuts and touch gestures +- 📦 ESM and UMD build outputs +- 🔧 Aggressive bundle optimization with Terser +- 🌲 Tree-shaking support + +[Unreleased]: https://gitea.hibna.com.tr/hibna/video-player/compare/v0.1.3...HEAD +[0.1.3]: https://gitea.hibna.com.tr/hibna/video-player/compare/v0.1.2...v0.1.3 +[0.1.2]: https://gitea.hibna.com.tr/hibna/video-player/compare/v0.1.1...v0.1.2 +[0.1.1]: https://gitea.hibna.com.tr/hibna/video-player/releases/tag/v0.1.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ca1726 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Alper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index 18272c9..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,397 +0,0 @@ -# Gitea Self-Hosted npm Registry'ye Yayınlama Kılavuzu - -Bu kılavuz, `@alper/video-player` paketinin Gitea self-hosted npm registry'ye nasıl yayınlanacağını adım adım açıklar. - ---- - -## 📋 Ön Gereksinimler - -- ✅ Çalışan bir Gitea sunucusu -- ✅ Gitea hesabınıza erişim -- ✅ Node.js ve pnpm/npm kurulu -- ✅ Bu proje build edilmiş durumda - ---- - -## 🔑 Adım 1: Gitea Access Token Oluşturma - -1. **Gitea web arayüzüne giriş yapın** - - Tarayıcınızda `https://gitea.yourdomain.com` adresine gidin - - Kullanıcı adı ve şifrenizle giriş yapın - -2. **Settings sayfasına gidin** - - Sağ üst köşedeki profil resmine tıklayın - - "Settings" seçeneğini seçin - -3. **Applications bölümüne gidin** - - Sol menüden "Applications" seçeneğini tıklayın - -4. **Yeni token oluşturun** - - "Generate New Token" butonuna tıklayın - - **Token Name:** `npm-publish-video-player` (veya istediğiniz bir isim) - - **Permissions (Yetkiler):** - - ✅ `write:package` - Paket yükleme yetkisi - - ✅ `read:package` - Paket okuma yetkisi (diğer projelerde kullanmak için) - - "Generate Token" butonuna tıklayın - -5. **Token'ı kopyalayın** - - ⚠️ **ÖNEMLİ:** Token sadece bir kez gösterilir! - - Token'ı güvenli bir yere kaydedin (örn: password manager) - - Örnek token: `1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t` - ---- - -## 📝 Adım 2: package.json Dosyasını Düzenleme - -`package.json` dosyasını açın ve aşağıdaki bilgileri **kendi Gitea sunucunuza göre** düzenleyin: - -```json -{ - "repository": { - "type": "git", - "url": "https://gitea.yourdomain.com/your-username/video-player.git" - }, - "publishConfig": { - "registry": "https://gitea.yourdomain.com/api/packages/your-username/npm/" - } -} -``` - -**Değiştirilmesi gerekenler:** -- `gitea.yourdomain.com` → Gitea sunucunuzun domain'i -- `your-username` → Gitea kullanıcı adınız - -**Örnek:** -```json -{ - "repository": { - "type": "git", - "url": "https://git.example.com/alper/video-player.git" - }, - "publishConfig": { - "registry": "https://git.example.com/api/packages/alper/npm/" - } -} -``` - ---- - -## 🔐 Adım 3: npm Login ile Kimlik Doğrulama - -Terminal'de aşağıdaki komutu çalıştırın: - -```bash -npm config set @alper:registry https://gitea.yourdomain.com/api/packages/your-username/npm/ - -npm login --scope=@alper --registry=https://gitea.yourdomain.com/api/packages/your-username/npm/ -``` - -**Değiştirin:** -- `gitea.yourdomain.com` → Gitea domain'iniz -- `your-username` → Gitea kullanıcı adınız - -**Giriş bilgileri istenecek:** - -``` -Username: your-gitea-username -Password: 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t (Adım 1'de oluşturduğunuz token) -Email: your-email@example.com -``` - -**Not:** Password kısmına Gitea şifrenizi değil, **token**'ı girin! - -Başarılı olursa şu mesajı görmelisiniz: -``` -Logged in as your-username on https://gitea.yourdomain.com/api/packages/your-username/npm/. -``` - ---- - -## 🏗️ Adım 4: Library Build - -Paketi yayınlamadan önce production build alın: - -```bash -# TypeScript compile ve Vite build -pnpm run build:lib -``` - -**Build başarılı olduysa:** -- `dist/` klasöründe şu dosyalar oluşacak: - - ✅ `video-player.js` (ESM format) - - ✅ `video-player.umd.cjs` (UMD format) - - ✅ `index.d.ts` (TypeScript type definitions) - - ✅ `style.css` (CSS dosyası) - -**Kontrol edin:** -```bash -ls dist/ -# veya Windows'ta: -dir dist\ -``` - ---- - -## 📤 Adım 5: npm Publish - -Artık paketi Gitea registry'ye yayınlayabilirsiniz: - -```bash -npm publish -``` - -**Başarılı olursa şu çıktıyı görmelisiniz:** -``` -npm notice -npm notice package: @alper/video-player@0.1.0 -npm notice === Tarball Contents === -npm notice 1.2kB package.json -npm notice 8.5kB dist/video-player.js -npm notice 7.8kB dist/video-player.umd.cjs -npm notice 2.1kB dist/index.d.ts -npm notice 3.4kB dist/style.css -npm notice === Tarball Details === -npm notice name: @alper/video-player -npm notice version: 0.1.0 -npm notice package size: 23.0 kB -npm notice unpacked size: 45.2 kB -npm notice total files: 5 -+ @alper/video-player@0.1.0 -``` - ---- - -## ✅ Adım 6: Yayını Doğrulama - -### Gitea Web Arayüzünde Kontrol - -1. Gitea'ya giriş yapın -2. Üst menüden "Packages" seçeneğine tıklayın -3. `@alper/video-player` paketini görmelisiniz -4. Pakete tıklayarak detayları görüntüleyin - -### Komut Satırından Kontrol - -```bash -npm view @alper/video-player --registry=https://gitea.yourdomain.com/api/packages/your-username/npm/ -``` - ---- - -## 🔄 Adım 7: Güncelleme Yayınlama - -Paket üzerinde değişiklik yaptığınızda yeni versiyon yayınlamak için: - -### 1. Versiyonu Güncelle - -```bash -# Patch version (0.1.0 → 0.1.1) - Bug fix -npm version patch - -# Minor version (0.1.0 → 0.2.0) - Yeni özellik -npm version minor - -# Major version (0.1.0 → 1.0.0) - Breaking change -npm version major - -# Veya manuel olarak package.json'da version'ı değiştirin -``` - -### 2. Build Al - -```bash -pnpm run build:lib -``` - -### 3. Yayınla - -```bash -npm publish -``` - -### 4. Git'e Push Et (Opsiyonel) - -```bash -git push && git push --tags -``` - ---- - -## 🚀 Diğer Projelerde Kullanma - -### Adım 1: Diğer Projenizde .npmrc Oluşturun - -Projenizin root dizininde `.npmrc` dosyası oluşturun: - -```ini -@alper:registry=https://gitea.yourdomain.com/api/packages/your-username/npm/ -//gitea.yourdomain.com/api/packages/your-username/npm/:_authToken=${GITEA_TOKEN} -``` - -### Adım 2: Environment Variable Ayarlayın - -```bash -# Linux/Mac -export GITEA_TOKEN=1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t - -# Windows (PowerShell) -$env:GITEA_TOKEN="1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t" - -# Veya .env dosyasına ekleyin: -echo "GITEA_TOKEN=1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t" >> .env -``` - -### Adım 3: Paketi Yükleyin - -```bash -pnpm add @alper/video-player -``` - -### Adım 4: Kullanın - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function App() { - return ; -} -``` - -Detaylı kullanım örnekleri için [README-USAGE.md](./README-USAGE.md) dosyasına bakın. - ---- - -## 🐛 Sorun Giderme - -### 1. "401 Unauthorized" Hatası - -**Sebep:** Token yanlış veya süresi dolmuş - -**Çözüm:** -```bash -# Tekrar login olun -npm login --scope=@alper --registry=https://gitea.yourdomain.com/api/packages/your-username/npm/ -``` - -### 2. "403 Forbidden" Hatası - -**Sebep:** Token'da `write:package` yetkisi yok - -**Çözüm:** -- Gitea'da yeni token oluşturun (Adım 1) -- `write:package` yetkisini seçin -- Yeni token ile login olun - -### 3. "Package already exists" Hatası - -**Sebep:** Aynı versiyonu tekrar yayınlamaya çalışıyorsunuz - -**Çözüm:** -```bash -# Versiyonu artırın -npm version patch -npm publish -``` - -### 4. "dist/ folder not found" Hatası - -**Sebep:** Build alınmamış - -**Çözüm:** -```bash -pnpm run build:lib -npm publish -``` - -### 5. ".npmrc not working" Sorunu - -**Sebep:** Environment variable ayarlanmamış - -**Çözüm:** -```bash -# Token'ı kontrol edin -echo $GITEA_TOKEN # Linux/Mac -echo $env:GITEA_TOKEN # Windows PowerShell - -# Ayarlanmamışsa ayarlayın -export GITEA_TOKEN=your-token # Linux/Mac -``` - -### 6. "Cannot find module" Hatası (Diğer Projelerde) - -**Sebep:** .npmrc dosyası yok veya yanlış yapılandırılmış - -**Çözüm:** -- `.npmrc` dosyasının proje root'unda olduğundan emin olun -- Registry URL'inin doğru olduğunu kontrol edin -- `pnpm install` veya `npm install` komutunu tekrar çalıştırın - ---- - -## 📚 Faydalı Komutlar - -```bash -# Hangi registry kullanılıyor? -npm config get @alper:registry - -# Login durumunu kontrol et -npm whoami --registry=https://gitea.yourdomain.com/api/packages/your-username/npm/ - -# Paket bilgilerini görüntüle -npm view @alper/video-player - -# Yüklü versiyonu kontrol et (diğer projelerde) -pnpm list @alper/video-player - -# Tüm npm ayarlarını temizle (sorun yaşıyorsanız) -npm config delete @alper:registry - -# Package-lock veya pnpm-lock dosyasını temizle -rm package-lock.json # veya pnpm-lock.yaml -pnpm install -``` - ---- - -## 🔒 Güvenlik Notları - -1. **Token'ları asla git'e commit etmeyin** - - `.npmrc` dosyasını `.gitignore`'a ekleyin - - Token'ları environment variable olarak kullanın - -2. **Token yetkilerini minimumda tutun** - - Sadece gerekli yetkileri verin (`read:package`, `write:package`) - -3. **Token'ları düzenli olarak yenileyin** - - Eski token'ları silin - - Yeni token oluşturun ve güncelleyin - -4. **Production ortamda environment variables kullanın** - - `.env` dosyasını git'e eklemeyin - - CI/CD sistemlerinde secret manager kullanın - ---- - -## ✅ Checklist - -Video Player Yayınlama: -- [ ] Gitea access token oluşturdum -- [ ] `package.json`'da `repository` ve `publishConfig` güncelledim -- [ ] `npm login` ile kimlik doğrulaması yaptım -- [ ] `pnpm run build:lib` ile build aldım -- [ ] `npm publish` ile yayınladım -- [ ] Gitea web arayüzünde paketi gördüm - -Diğer Projelerde Kullanma: -- [ ] Projede `.npmrc` dosyası oluşturdum -- [ ] `GITEA_TOKEN` environment variable'ını ayarladım -- [ ] `pnpm add @alper/video-player` ile yükledim -- [ ] CSS dosyasını import ettim -- [ ] VideoPlayer component'ini kullandım - ---- - -**Başarılar! 🎉** - -Artık video player kütüphanenizi tüm projelerinizde kullanabilirsiniz. diff --git a/README-USAGE.md b/README-USAGE.md deleted file mode 100644 index d935bea..0000000 --- a/README-USAGE.md +++ /dev/null @@ -1,567 +0,0 @@ -# @alper/video-player - Kullanım Kılavuzu - -Modern, özellik zengin React video player kütüphanesi. HLS streaming, altyazılar, klavye kısayolları, dokunmatik hareketler ve daha fazlasını destekler. - ---- - -## 🚀 Temel Kullanım - -### Minimum Örnek - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function App() { - return ( - - ); -} -``` - -### Özelliklerle Birlikte - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function App() { - return ( - console.log('Video başladı!')} - onPause={() => console.log('Video durdu!')} - onTimeUpdate={(time) => console.log('Zaman:', time)} - /> - ); -} -``` - ---- - -## 🎨 Tema Özelleştirme - -```tsx -import { VideoPlayer, PlayerTheme } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -const myTheme: PlayerTheme = { - primaryColor: '#3b82f6', // Ana buton rengi - accentColor: '#2563eb', // Hover rengi - backgroundColor: '#1f2937', // Arka plan - textColor: '#f9fafb', // Metin rengi -}; - -function App() { - return ( - - ); -} -``` - ---- - -## 📺 HLS Streaming Desteği - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function StreamingVideo() { - return ( - - ); -} -``` - -**Not:** HLS desteği için `hls.js` otomatik olarak yüklenir (optional dependency). - ---- - -## 📝 Altyazı Desteği - -### VTT veya SRT Formatında - -```tsx -import { VideoPlayer, SubtitleTrack } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -const subtitles: SubtitleTrack[] = [ - { - label: 'Türkçe', - src: '/subtitles/turkish.vtt', - srcLang: 'tr', - }, - { - label: 'English', - src: '/subtitles/english.vtt', - srcLang: 'en', - }, - { - label: 'Español', - src: '/subtitles/spanish.srt', - srcLang: 'es', - }, -]; - -function VideoWithSubtitles() { - return ( - - ); -} -``` - -**Desteklenen formatlar:** VTT, SRT - ---- - -## ⌨️ Klavye Kısayolları - -Varsayılan olarak aktif. Devre dışı bırakmak için `keyboardShortcuts={false}` kullanın. - -| Tuş | Eylem | -|-----|-------| -| `Space` | Oynat/Duraklat | -| `←` | 5 saniye geri | -| `→` | 5 saniye ileri | -| `↑` | Sesi artır (%5) | -| `↓` | Sesi azalt (%5) | -| `M` | Sesi kapat/aç | -| `F` | Tam ekran | -| `P` | Picture-in-Picture | -| `0-9` | Videonun %0-%90'ına git | - ---- - -## 👆 Dokunmatik Hareketler - -Mobil cihazlarda otomatik olarak aktiftir: - -- **Tek dokunma:** Kontrolleri göster/gizle -- **Çift dokunma:** Oynat/Duraklat -- **Sağa kaydır:** 10 saniye ileri -- **Sola kaydır:** 10 saniye geri - ---- - -## 🎛️ İleri Seviye: Context API Kullanımı - -Kendi özel kontrollerinizi oluşturabilirsiniz: - -```tsx -import { - PlayerProvider, - usePlayerContext, - VideoPlayer -} from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function CustomControls() { - const { - videoState, - play, - pause, - seek, - setVolume, - toggleFullscreen, - setPlaybackRate - } = usePlayerContext(); - - return ( -
- {/* Oynat/Duraklat */} - - - {/* İleri/Geri */} - - - - {/* Ses Kontrolü */} - setVolume(parseFloat(e.target.value))} - /> - {Math.round(videoState.volume * 100)}% - - {/* Zaman Göstergesi */} -
- {formatTime(videoState.currentTime)} / {formatTime(videoState.duration)} -
- - {/* Oynatma Hızı */} - - - {/* Tam Ekran */} - -
- ); -} - -function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; -} - -function App() { - return ( - - - - - ); -} -``` - ---- - -## 🔌 Next.js Entegrasyonu - -Next.js'te SSR (Server-Side Rendering) sorunlarını önlemek için: - -```tsx -'use client'; // App Router için - -import dynamic from 'next/dynamic'; -import '@alper/video-player/styles.css'; - -// VideoPlayer'ı client-side only olarak yükle -const VideoPlayer = dynamic( - () => import('@alper/video-player').then(mod => mod.VideoPlayer), - { ssr: false } -); - -export default function VideoPage() { - return ( -
-

Video Player

- -
- ); -} -``` - ---- - -## 📊 Props API Referansı - -### VideoPlayerProps - -| Prop | Tip | Varsayılan | Açıklama | -|------|-----|-----------|----------| -| `src` | `string` | **zorunlu** | Video URL'i (MP4, HLS, vb.) | -| `poster` | `string` | - | Poster resim URL'i | -| `autoplay` | `boolean` | `false` | Otomatik oynat | -| `loop` | `boolean` | `false` | Döngü modu | -| `muted` | `boolean` | `false` | Sessiz başlat | -| `controls` | `boolean` | `true` | Kontrolleri göster | -| `subtitles` | `SubtitleTrack[]` | - | Altyazı listesi | -| `theme` | `PlayerTheme` | - | Renk teması | -| `keyboardShortcuts` | `boolean` | `true` | Klavye desteği | -| `pictureInPicture` | `boolean` | `true` | PiP desteği | -| `className` | `string` | - | CSS sınıfı | -| `style` | `CSSProperties` | - | Inline stil | - -### Event Callbacks - -| Callback | Parametre | Açıklama | -|----------|-----------|----------| -| `onPlay` | `() => void` | Video başladığında | -| `onPause` | `() => void` | Video durduğunda | -| `onEnded` | `() => void` | Video bittiğinde | -| `onTimeUpdate` | `(time: number) => void` | Zaman güncellendiğinde | -| `onVolumeChange` | `(volume: number) => void` | Ses değiştiğinde | -| `onError` | `(error: Error) => void` | Hata oluştuğunda | -| `onLoadedMetadata` | `() => void` | Metadata yüklendiğinde | -| `onSeeking` | `() => void` | Arama başladığında | -| `onSeeked` | `() => void` | Arama bittiğinde | - -### SubtitleTrack - -```typescript -interface SubtitleTrack { - label: string; // Görünen isim (ör: "Türkçe") - src: string; // Altyazı dosya URL'i - srcLang: string; // Dil kodu (ör: "tr", "en") -} -``` - -### PlayerTheme - -```typescript -interface PlayerTheme { - primaryColor?: string; // Ana renk (varsayılan: #ef4444) - accentColor?: string; // Vurgu rengi (varsayılan: #dc2626) - backgroundColor?: string; // Arka plan rengi - textColor?: string; // Metin rengi -} -``` - ---- - -## 🎯 usePlayerContext Hook API - -```typescript -const { - // Video State - videoState: { - playing: boolean; - currentTime: number; - duration: number; - buffered: TimeRanges; - volume: number; - muted: boolean; - playbackRate: number; - fullscreen: boolean; - pictureInPicture: boolean; - loading: boolean; - error: string | null; - seeking: boolean; - }, - - // UI State - uiState: { - controlsVisible: boolean; - settingsOpen: boolean; - volumeControlOpen: boolean; - }, - - // Settings - settings: { - quality: string | null; - subtitle: string | null; - playbackRate: number; - }, - - // Actions - play: () => void; - pause: () => void; - seek: (time: number) => void; - setVolume: (volume: number) => void; - toggleMute: () => void; - setPlaybackRate: (rate: number) => void; - toggleFullscreen: () => void; - togglePictureInPicture: () => void; - -} = usePlayerContext(); -``` - ---- - -## 💡 Kullanım Örnekleri - -### 1. Analytics Entegrasyonu - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function AnalyticsVideo() { - const trackEvent = (event: string, data?: any) => { - // Analytics servisinize gönderin - console.log('Analytics:', event, data); - }; - - return ( - trackEvent('video_play')} - onPause={() => trackEvent('video_pause')} - onEnded={() => trackEvent('video_complete')} - onTimeUpdate={(time) => { - // Her %25'te bir event gönder - if (time % 25 === 0) { - trackEvent('video_progress', { percentage: time }); - } - }} - onError={(error) => trackEvent('video_error', { message: error.message })} - /> - ); -} -``` - -### 2. Playlist Sistemi - -```tsx -import { useState } from 'react'; -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -const playlist = [ - { id: 1, title: 'Video 1', src: '/videos/1.mp4' }, - { id: 2, title: 'Video 2', src: '/videos/2.mp4' }, - { id: 3, title: 'Video 3', src: '/videos/3.mp4' }, -]; - -function PlaylistPlayer() { - const [currentIndex, setCurrentIndex] = useState(0); - const currentVideo = playlist[currentIndex]; - - const handleEnded = () => { - // Sonraki videoya geç - if (currentIndex < playlist.length - 1) { - setCurrentIndex(currentIndex + 1); - } - }; - - return ( -
- - -
- {playlist.map((video, index) => ( - - ))} -
-
- ); -} -``` - -### 3. Çoklu Kalite Desteği - -```tsx -import { VideoPlayer } from '@alper/video-player'; -import '@alper/video-player/styles.css'; - -function QualityVideo() { - // HLS kullanıyorsanız otomatik kalite seçimi vardır - // Manuel kalite için farklı kaynak URL'leri kullanabilirsiniz - - return ( - - ); -} -``` - ---- - -## 🐛 Sorun Giderme - -### CSS Stilleri Yüklenmiyor - -Emin olun ki CSS dosyasını import ettiniz: - -```tsx -import '@alper/video-player/styles.css'; -``` - -### HLS Videoları Çalışmıyor - -1. `hls.js` yüklü mü kontrol edin: -```bash -pnpm add hls.js -``` - -2. Video URL'inin `.m3u8` uzantılı olduğundan emin olun - -### TypeScript Hataları - -`tsconfig.json`'da `skipLibCheck: true` ayarlayın veya paketi güncelleyin: - -```bash -pnpm update @alper/video-player -``` - -### Next.js'te Hydration Hatası - -VideoPlayer'ı `dynamic` import ile yükleyin (`ssr: false`): - -```tsx -const VideoPlayer = dynamic( - () => import('@alper/video-player').then(mod => mod.VideoPlayer), - { ssr: false } -); -``` - ---- - -## 📦 Bundle Boyutu - -- **Gzipped:** ~8KB (hls.js hariç) -- **hls.js ile:** ~100KB (optional, sadece HLS kullanıyorsanız yüklenir) - ---- - -## 🔄 Güncelleme - -```bash -# En son versiyonu yükle -pnpm update @alper/video-player - -# Belirli bir versiyonu yükle -pnpm add @alper/video-player@0.2.0 - -# Hangi versiyon yüklü? -pnpm list @alper/video-player -``` - ---- - -## 📄 Lisans - -MIT - ---- - -## 💬 Destek - -Sorularınız için projeyi geliştiren kişiyle iletişime geçin. diff --git a/README.md b/README.md index fc5b612..b43beac 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,38 @@ # 🎬 Modern Video Player -A feature-rich, modern video player library built with React, TypeScript, and Vite. Designed for reusability across multiple projects with zero runtime dependencies. +[![npm version](https://img.shields.io/npm/v/@alper/video-player.svg?style=flat-square)](https://www.npmjs.com/package/@alper/video-player) +[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@alper/video-player?style=flat-square)](https://bundlephobia.com/package/@alper/video-player) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +**The smallest React video player with the most features.** Only **~15KB gzipped** with zero runtime dependencies! + +A 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 | +|---------|---------------------|----------|--------------|------| +| **Bundle Size (gzipped)** | **15KB** ✅ | ~500KB ❌ | ~50KB ⚠️ | ~30KB ⚠️ | +| **Runtime Dependencies** | **0** ✅ | Many ❌ | Few ⚠️ | Few ⚠️ | +| **React Native** | **Yes** ✅ | Wrapper ⚠️ | **Yes** ✅ | Wrapper ⚠️ | +| **TypeScript Native** | **Yes** ✅ | Types ⚠️ | Partial ⚠️ | Types ⚠️ | +| **HLS Support** | **Yes** ✅ | Yes ✅ | Yes ✅ | No ❌ | +| **Quality Switching** | **Yes** ✅ | Yes ✅ | Limited ⚠️ | No ❌ | +| **Touch Gestures** | **15+** ✅ | Limited ⚠️ | No ❌ | Limited ⚠️ | +| **Keyboard Shortcuts** | **15+** ✅ | ~8 ⚠️ | Basic ⚠️ | ~10 ⚠️ | +| **i18n Support** | **Yes** ✅ | Yes ✅ | No ❌ | Yes ✅ | + +### Key Advantages + +- 📦 **97% smaller than video.js** - Only 15KB vs 500KB +- ⚡ **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 +- 🎨 **Easy customization** - CSS variables for theming +- 📱 **Mobile-ready** - Comprehensive touch gesture support +- 🌍 **Internationalized** - Built-in i18n with English and Turkish +- ♿ **Accessible** - ARIA labels and keyboard navigation ## ✨ Features diff --git a/package.json b/package.json index c0d00f9..beb7b09 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "build": "tsc && vite build", "build:lib": "vite build --config vite.config.lib.ts", "preview": "vite preview", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" }, "peerDependencies": { "react": "^18.0.0", @@ -30,20 +33,27 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@vitejs/plugin-react": "^5.1.0", + "@vitest/ui": "^4.0.4", "eslint": "^9.38.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.4.0", + "jsdom": "^27.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "terser": "^5.44.0", "typescript": "^5.9.3", "vite": "^7.1.12", - "vite-plugin-dts": "^4.5.4" + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.4" }, "optionalDependencies": { "hls.js": "^1.6.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a704d40..dcdeb2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@eslint/js': specifier: ^9.38.0 version: 9.38.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.2.2 version: 19.2.2 @@ -25,7 +34,10 @@ importers: version: 8.46.2(eslint@9.38.0)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.0(vite@7.1.12) + version: 5.1.0(vite@7.1.12(terser@5.44.0)) + '@vitest/ui': + specifier: ^4.0.4 + version: 4.0.4(vitest@4.0.4) eslint: specifier: ^9.38.0 version: 9.38.0 @@ -38,21 +50,30 @@ importers: globals: specifier: ^16.4.0 version: 16.4.0 + jsdom: + specifier: ^27.0.1 + version: 27.0.1(postcss@8.5.6) react: specifier: ^19.2.0 version: 19.2.0 react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + terser: + specifier: ^5.44.0 + version: 5.44.0 typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^7.1.12 - version: 7.1.12 + version: 7.1.12(terser@5.44.0) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12) + version: 4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(terser@5.44.0)) + vitest: + specifier: ^4.0.4 + version: 4.0.4(@vitest/ui@4.0.4)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0) optionalDependencies: hls.js: specifier: ^1.6.13 @@ -60,6 +81,18 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@4.0.5': + resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} + + '@asamuzakjp/dom-selector@6.7.3': + resolution: {integrity: sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -131,6 +164,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -143,6 +180,40 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.14': + resolution: {integrity: sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -371,6 +442,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -402,6 +476,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/pluginutils@1.0.0-beta.43': resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} @@ -554,9 +631,44 @@ packages: '@rushstack/ts-command-line@5.1.3': resolution: {integrity: sha512-Kdv0k/BnnxIYFlMVC1IxrIS0oGQd4T4b7vKfx52Y2+wk2WZSDFIvedr7JrhenzSlm3ou5KwtoTGTGd5nbODRug==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -569,6 +681,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -648,6 +766,40 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.0.4': + resolution: {integrity: sha512-0ioMscWJtfpyH7+P82sGpAi3Si30OVV73jD+tEqXm5+rIx9LgnfdaOn45uaFkKOncABi/PHL00Yn0oW/wK4cXw==} + + '@vitest/mocker@4.0.4': + resolution: {integrity: sha512-UTtKgpjWj+pvn3lUM55nSg34098obGhSHH+KlJcXesky8b5wCUgg7s60epxrS6yAG8slZ9W8T9jGWg4PisMf5Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.4': + resolution: {integrity: sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==} + + '@vitest/runner@4.0.4': + resolution: {integrity: sha512-99EDqiCkncCmvIZj3qJXBZbyoQ35ghOwVWNnQ5nj0Hnsv4Qm40HmrMJrceewjLVvsxV/JSU4qyx2CGcfMBmXJw==} + + '@vitest/snapshot@4.0.4': + resolution: {integrity: sha512-XICqf5Gi4648FGoBIeRgnHWSNDp+7R5tpclGosFaUUFzY6SfcpsfHNMnC7oDu/iOLBxYfxVzaQpylEvpgii3zw==} + + '@vitest/spy@4.0.4': + resolution: {integrity: sha512-G9L13AFyYECo40QG7E07EdYnZZYCKMTSp83p9W8Vwed0IyCG1GnpDLxObkx8uOGPXfDpdeVf24P1Yka8/q1s9g==} + + '@vitest/ui@4.0.4': + resolution: {integrity: sha512-CmuFQLKw5SaLU/Flo8dLiQw2P2ONguhjfhBL9AYkTeDZPToE8laGvObXqRzS5G+4RD4SgWcI1USAmGxMVIqT0g==} + peerDependencies: + vitest: 4.0.4 + + '@vitest/utils@4.0.4': + resolution: {integrity: sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==} + '@volar/language-core@2.4.23': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} @@ -687,6 +839,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -715,16 +871,35 @@ packages: alien-signals@0.4.14: resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -732,6 +907,9 @@ packages: resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -747,6 +925,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -754,6 +935,10 @@ packages: caniuse-lite@1.0.30001751: resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -765,6 +950,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -784,9 +972,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@5.3.1: + resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -799,9 +1002,22 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.242: resolution: {integrity: sha512-msZ7SYGFpXkm/iUizlMrm/FPNeYo8uSltQccLVFO3fV4RN2JWGdG7Aatztxtw3uDWp3DkupfkrosLjUnhY+iOw==} @@ -809,6 +1025,13 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -874,10 +1097,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -906,6 +1136,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -984,6 +1217,22 @@ packages: hls.js@1.6.13: resolution: {integrity: sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1004,6 +1253,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1020,6 +1273,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1033,6 +1289,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@27.0.1: + resolution: {integrity: sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==} + engines: {node: '>=20'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1082,6 +1347,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1089,9 +1358,16 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1100,6 +1376,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.0.3: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -1114,6 +1394,10 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1147,6 +1431,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1189,6 +1476,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1204,6 +1495,9 @@ packages: peerDependencies: react: ^19.2.0 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -1212,6 +1506,10 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1234,9 +1532,19 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1262,10 +1570,20 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -1273,10 +1591,20 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1293,14 +1621,51 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} + + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1386,18 +1751,96 @@ packages: yaml: optional: true + vitest@4.0.4: + resolution: {integrity: sha512-hV31h0/bGbtmDQc0KqaxsTO1v4ZQeF8ojDFuy4sZhFadwAqqvJA0LDw68QUocctI5EDpFMql/jVWKuPYHIf2Ew==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.4 + '@vitest/browser-preview': 4.0.4 + '@vitest/browser-webdriverio': 4.0.4 + '@vitest/ui': 4.0.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1419,6 +1862,26 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@4.0.5': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.2 + + '@asamuzakjp/dom-selector@6.7.3': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.2 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1508,6 +1971,8 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1531,6 +1996,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.14(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -1684,6 +2173,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -1738,6 +2232,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@polka/url@1.0.0-next.29': {} + '@rolldown/pluginutils@1.0.0-beta.43': {} '@rollup/pluginutils@5.3.0(rollup@4.52.5)': @@ -1847,8 +2343,46 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@standard-schema/spec@1.0.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@types/argparse@1.0.38': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -1870,6 +2404,13 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1975,7 +2516,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.0(vite@7.1.12)': + '@vitejs/plugin-react@5.1.0(vite@7.1.12(terser@5.44.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -1983,10 +2524,60 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.43 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.1.12 + vite: 7.1.12(terser@5.44.0) transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.4': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.4 + '@vitest/utils': 4.0.4 + chai: 6.2.0 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.4(vite@7.1.12(terser@5.44.0))': + dependencies: + '@vitest/spy': 4.0.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(terser@5.44.0) + + '@vitest/pretty-format@4.0.4': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.4': + dependencies: + '@vitest/utils': 4.0.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.4': + dependencies: + '@vitest/pretty-format': 4.0.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.4': {} + + '@vitest/ui@4.0.4(vitest@4.0.4)': + dependencies: + '@vitest/utils': 4.0.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.4(@vitest/ui@4.0.4)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0) + + '@vitest/utils@4.0.4': + dependencies: + '@vitest/pretty-format': 4.0.4 + tinyrainbow: 3.0.3 + '@volar/language-core@2.4.23': dependencies: '@volar/source-map': 2.4.23 @@ -2038,6 +2629,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -2069,20 +2662,36 @@ snapshots: alien-signals@0.4.14: {} + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} baseline-browser-mapping@2.8.20: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2104,10 +2713,14 @@ snapshots: node-releases: 2.0.26 update-browserslist-db: 1.1.4(browserslist@4.27.0) + buffer-from@1.1.2: {} + callsites@3.1.0: {} caniuse-lite@1.0.30001751: {} + chai@6.2.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2119,6 +2732,8 @@ snapshots: color-name@1.1.4: {} + commander@2.20.3: {} + compare-versions@6.1.1: {} concat-map@0.0.1: {} @@ -2135,20 +2750,52 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@5.3.1(postcss@8.5.6): + dependencies: + '@asamuzakjp/css-color': 4.0.5 + '@csstools/css-syntax-patches-for-csstree': 1.0.14(postcss@8.5.6) + css-tree: 3.1.0 + transitivePeerDependencies: + - postcss + csstype@3.1.3: {} + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + de-indent@1.0.2: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.242: {} entities@4.5.0: {} + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -2263,8 +2910,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.2.2: {} + exsolve@1.0.7: {} fast-deep-equal@3.1.3: {} @@ -2289,6 +2942,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2355,6 +3010,28 @@ snapshots: hls.js@1.6.13: optional: true + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2368,6 +3045,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -2380,6 +3059,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jju@1.4.0: {} @@ -2390,6 +3071,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@27.0.1(postcss@8.5.6): + dependencies: + '@asamuzakjp/dom-selector': 6.7.3 + cssstyle: 5.3.1(postcss@8.5.6) + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - postcss + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2433,6 +3142,8 @@ snapshots: lodash@4.17.21: {} + lru-cache@11.2.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2441,10 +3152,14 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.12.2: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2452,6 +3167,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -2471,6 +3188,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + mrmime@2.0.1: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -2502,6 +3221,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -2538,6 +3261,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} quansync@0.2.11: {} @@ -2549,10 +3278,17 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.18.0: {} react@19.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -2593,10 +3329,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2613,14 +3357,35 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.6.1: {} sprintf-js@1.0.3: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-argv@0.3.2: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -2633,15 +3398,46 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.17: {} + + tldts@7.0.17: + dependencies: + tldts-core: 7.0.17 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.17 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2668,7 +3464,7 @@ snapshots: dependencies: punycode: 2.3.1 - vite-plugin-dts@4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12): + vite-plugin-dts@4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(terser@5.44.0)): dependencies: '@microsoft/api-extractor': 7.53.3 '@rollup/pluginutils': 5.3.0(rollup@4.52.5) @@ -2681,13 +3477,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 7.1.12 + vite: 7.1.12(terser@5.44.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@7.1.12: + vite@7.1.12(terser@5.44.0): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -2697,15 +3493,83 @@ snapshots: tinyglobby: 0.2.15 optionalDependencies: fsevents: 2.3.3 + terser: 5.44.0 + + vitest@4.0.4(@vitest/ui@4.0.4)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0): + dependencies: + '@vitest/expect': 4.0.4 + '@vitest/mocker': 4.0.4(vite@7.1.12(terser@5.44.0)) + '@vitest/pretty-format': 4.0.4 + '@vitest/runner': 4.0.4 + '@vitest/snapshot': 4.0.4 + '@vitest/spy': 4.0.4 + '@vitest/utils': 4.0.4 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.1.12(terser@5.44.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/ui': 4.0.4(vitest@4.0.4) + jsdom: 27.0.1(postcss@8.5.6) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@4.0.0: {} diff --git a/src/components/ControlsLayer.tsx b/src/components/ControlsLayer.tsx index 4743bb5..df6b629 100644 --- a/src/components/ControlsLayer.tsx +++ b/src/components/ControlsLayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react' +import React, { useEffect, useRef, useState, useCallback, lazy, Suspense } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' import { PlayPauseButton } from './controls/PlayPauseButton' import { ProgressBar } from './controls/ProgressBar' @@ -9,12 +9,13 @@ import { PIPButton } from './controls/PIPButton' import { SettingsButton } from './controls/SettingsButton' import { LoadingSpinner } from './overlays/LoadingSpinner' import { CenterPlayButton } from './controls/CenterPlayButton' -import { SettingsMenu } from './menus/SettingsMenu' import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts' import { useTouchGestures } from '../hooks/useTouchGestures' import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types' import './ControlsLayer.css' +const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu }))) + interface ControlsLayerProps { keyboardShortcuts?: boolean pictureInPicture?: boolean @@ -225,7 +226,9 @@ export const ControlsLayer: React.FC = ({
- + + +
{pictureInPicture && } diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 69188a8..8d4a938 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useCallback, useState } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types' import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper' -import { getHlsAudioTracks, getHlsQualities, setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsLoader' +import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl' +import { setupHlsInstance } from '../utils/hlsSetup' import './VideoElement.css' interface VideoElementProps { @@ -219,80 +220,21 @@ export const VideoElement: React.FC = ({ const setupHls = async () => { if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') { - // Browser doesn't support HLS natively, load hls.js try { - // Dynamic import with CDN fallback - const { loadHls, isHlsSupported } = await import('../utils/hlsLoader') - const Hls = await loadHls() - - if (!isHlsSupported(Hls)) { - throw new Error('HLS.js is not supported in this browser') - } - - const hls = new Hls({ - enableWorker: true, - lowLatencyMode: false, - }) - - hls.loadSource(src) - hls.attachMedia(video) - - hls.on(Hls.Events.MANIFEST_PARSED, () => { - // Extract audio tracks after manifest is parsed - // Sometimes audio tracks are not immediately available, so we try with a small delay - setTimeout(() => { - const tracks = getHlsAudioTracks(hls) - const qualities = getHlsQualities(hls) - - if (tracks.length > 0) { - setAvailableAudioTracks(tracks) - onAudioTracksLoaded?.(tracks) - } - - setAvailableQualities(qualities) - onQualityLevelsLoaded?.(qualities) - }, 100) - - if (autoplay) { - void video.play().catch(() => undefined) - } - }) - - // Also listen to audio track updates - hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => { - const tracks = getHlsAudioTracks(hls) - if (tracks.length > 0) { + cleanupFn = await setupHlsInstance({ + video, + src, + autoplay, + onAudioTracksLoaded: (tracks) => { setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) - } + }, + onQualityLevelsLoaded: (qualities) => { + setAvailableQualities(qualities) + onQualityLevelsLoaded?.(qualities) + }, + onError: handleError, }) - - hls.on(Hls.Events.ERROR, (_event: any, data: any) => { - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - hls.startLoad() - break - case Hls.ErrorTypes.MEDIA_ERROR: - hls.recoverMediaError() - break - default: - handleError() - break - } - } - }) - - // Store hls instance for cleanup - ;(video as any).__hlsInstance = hls - - // Setup cleanup function - cleanupFn = () => { - if (hls) { - hls.destroy() - } - delete (video as any).__hlsInstance - } } catch (err) { let error: Error if (err instanceof Error && isCORSError(err)) { @@ -309,7 +251,6 @@ export const VideoElement: React.FC = ({ onError?.(error) } } else { - // Native support or regular video video.src = src if (autoplay) { void video.play().catch(() => undefined) diff --git a/src/components/VideoPlayer.test.tsx b/src/components/VideoPlayer.test.tsx new file mode 100644 index 0000000..173bbfb --- /dev/null +++ b/src/components/VideoPlayer.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { VideoPlayer } from './VideoPlayer'; + +describe('VideoPlayer', () => { + const defaultProps = { + src: 'https://example.com/video.mp4', + }; + + it('renders video player container', () => { + const { container } = render(); + expect(container.querySelector('.video-player')).toBeInTheDocument(); + }); + + it('renders video element', () => { + const { container } = render(); + const video = container.querySelector('video'); + expect(video).toBeInTheDocument(); + }); + + it('renders with autoplay prop', () => { + const { container } = render(); + const video = container.querySelector('video'); + // VideoElement handles autoplay programmatically via play() method + expect(video).toBeInTheDocument(); + }); + + it('renders with muted prop', () => { + const { container } = render(); + const video = container.querySelector('video'); + // Muted state is managed through VideoElement + expect(video).toBeInTheDocument(); + }); + + it('applies loop when enabled', () => { + const { container } = render(); + const video = container.querySelector('video'); + expect(video).toHaveAttribute('loop'); + }); + + it('applies custom className', () => { + const className = 'custom-player'; + const { container } = render(); + 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(); + + const video = container.querySelector('video') as HTMLVideoElement; + video.dispatchEvent(new Event('play')); + + await waitFor(() => { + expect(onPlay).toHaveBeenCalled(); + }); + }); + + it('calls onPause callback when pause event fires', async () => { + const onPause = vi.fn(); + const { container } = render(); + + const video = container.querySelector('video') as HTMLVideoElement; + video.dispatchEvent(new Event('pause')); + + await waitFor(() => { + expect(onPause).toHaveBeenCalled(); + }); + }); + + it('calls onEnded callback when ended event fires', async () => { + const onEnded = vi.fn(); + const { container } = render(); + + const video = container.querySelector('video') as HTMLVideoElement; + video.dispatchEvent(new Event('ended')); + + await waitFor(() => { + expect(onEnded).toHaveBeenCalled(); + }); + }); + + it('calls onTimeUpdate callback with current time', async () => { + const onTimeUpdate = vi.fn(); + const { container } = render(); + + const video = container.querySelector('video') as HTMLVideoElement; + Object.defineProperty(video, 'currentTime', { value: 10.5, configurable: true }); + video.dispatchEvent(new Event('timeupdate')); + + await waitFor(() => { + expect(onTimeUpdate).toHaveBeenCalledWith(10.5); + }); + }); + + it('renders with subtitles prop', () => { + const subtitles = [ + { src: 'subtitles-en.vtt', srcLang: 'en', label: 'English' }, + { src: 'subtitles-tr.vtt', srcLang: 'tr', label: 'Türkçe' }, + ]; + const { container } = render(); + + const video = container.querySelector('video') as HTMLVideoElement; + // Subtitles are added dynamically by VideoElement + expect(video).toBeInTheDocument(); + }); + + it('renders without errors', () => { + const onError = vi.fn(); + const { container } = render(); + + 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(); + const controls = container.querySelector('.controls'); + expect(controls).not.toBeInTheDocument(); + }); + + it('applies custom style', () => { + const style = { width: '800px', height: '450px' }; + const { container } = render(); + const playerElement = container.querySelector('.video-player') as HTMLElement; + expect(playerElement.style.width).toBe('800px'); + expect(playerElement.style.height).toBe('450px'); + }); +}); diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 931cfe1..262a30b 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -3,17 +3,28 @@ import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext' import { VideoElement } from './VideoElement' import { ControlsLayer } from './ControlsLayer' import type { VideoPlayerProps, AudioTrack, VideoQuality } from '../types' -import { initializePolyfills } from '../utils/polyfills' import '../styles/variables.css' import './VideoPlayer.css' -// Initialize polyfills once +// Lazy load polyfills only if needed let polyfillsInitialized = false -if (!polyfillsInitialized) { - initializePolyfills() - polyfillsInitialized = true +const initializePolyfillsIfNeeded = async () => { + if (polyfillsInitialized) return + + // Check if polyfills are needed + const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled + const needsPIPPolyfill = !('pictureInPictureEnabled' in document) + + if (needsFullscreenPolyfill || needsPIPPolyfill) { + const { initializePolyfills } = await import('../utils/polyfills') + initializePolyfills() + polyfillsInitialized = true + } } +// Initialize polyfills asynchronously +initializePolyfillsIfNeeded() + const VideoPlayerContent: React.FC< VideoPlayerProps & { audioTracks: AudioTrack[] @@ -92,6 +103,7 @@ export const VideoPlayer: React.FC = ({ controls = true, subtitles = [], theme, + language, keyboardShortcuts = true, pictureInPicture = true, className = '', @@ -129,7 +141,7 @@ export const VideoPlayer: React.FC = ({ }, []) return ( - + = ({ setAudioTrack, setQuality, toggleSettings, + translations, } = usePlayerContext() const menuRef = useRef(null) const [currentView, setCurrentView] = useState('main') @@ -66,7 +67,7 @@ export const SettingsMenu: React.FC = ({ {currentView === 'main' && ( <>
-

Ayarlar

+

{translations.settings}

{qualities.length > 0 && ( @@ -75,9 +76,9 @@ export const SettingsMenu: React.FC = ({
- Çözünürlük + {translations.quality} - {settings.quality ? settings.quality.label : 'Otomatik'} + {settings.quality ? settings.quality.label : translations.auto}
@@ -89,7 +90,7 @@ export const SettingsMenu: React.FC = ({
- Hız + {translations.speed} {videoState.playbackRate === 1 ? 'Normal' : `${videoState.playbackRate}x`} @@ -102,9 +103,9 @@ export const SettingsMenu: React.FC = ({
- Altyazı + {translations.subtitles} - {settings.subtitle ? settings.subtitle.label : 'Kapalı'} + {settings.subtitle ? settings.subtitle.label : translations.off}
@@ -116,9 +117,9 @@ export const SettingsMenu: React.FC = ({
- Ses + {translations.audioTrack} - {settings.audioTrack ? settings.audioTrack.name : 'Varsayılan'} + {settings.audioTrack ? settings.audioTrack.name : translations.default}
@@ -135,7 +136,7 @@ export const SettingsMenu: React.FC = ({ -

Oynatma Hızı

+

{translations.speed}

{playbackRates.map((rate) => ( @@ -162,7 +163,7 @@ export const SettingsMenu: React.FC = ({ -

Altyazı

+

{translations.subtitles}

{subtitles.length > 0 ? ( @@ -191,7 +192,7 @@ export const SettingsMenu: React.FC = ({ )) ) : (
- Altyazı mevcut değil + {translations.noSubtitlesAvailable}
)}
@@ -205,7 +206,7 @@ export const SettingsMenu: React.FC = ({ -

Ses

+

{translations.audioTrack}

{audioTracks.map((track) => ( @@ -234,7 +235,7 @@ export const SettingsMenu: React.FC = ({ -

Çözünürlük

+

{translations.quality}

{qualities.map((quality) => { diff --git a/src/constants/strings.ts b/src/constants/strings.ts new file mode 100644 index 0000000..f819bdd --- /dev/null +++ b/src/constants/strings.ts @@ -0,0 +1,11 @@ +export const STRINGS = { + SETTINGS: 'Settings', + QUALITY: 'Quality', + SPEED: 'Speed', + SUBTITLES: 'Subtitles', + AUDIO: 'Audio', + AUTO: 'Auto', + OFF: 'Off', + DEFAULT: 'Default', + LEVEL: 'Level', +} as const diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx index bdd6cec..62ec1f6 100644 --- a/src/contexts/PlayerContext.tsx +++ b/src/contexts/PlayerContext.tsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useRef, useState, useCallback } from 'react' import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types' +import type { Translations } from '../i18n' +import { getTranslations, detectBrowserLanguage } from '../i18n' type SelectedQuality = PlayerSettings['quality'] type SelectedSubtitle = PlayerSettings['subtitle'] @@ -7,6 +9,7 @@ type SelectedSubtitle = PlayerSettings['subtitle'] interface PlayerContextType extends PlayerContextValue { setVideoState: React.Dispatch> setUIState: React.Dispatch> + translations: Translations } const PlayerContext = createContext(null) @@ -25,6 +28,7 @@ interface PlayerProviderProps { initialVolume?: number initialMuted?: boolean initialPlaybackRate?: number + language?: string } export const PlayerProvider: React.FC = ({ @@ -32,10 +36,14 @@ export const PlayerProvider: React.FC = ({ initialVolume = 1, initialMuted = false, initialPlaybackRate = 1, + language, }) => { const videoRef = useRef(null) const containerRef = useRef(null) + // Get translations based on language prop or browser language + const translations = getTranslations(language || detectBrowserLanguage()) + const [videoState, setVideoState] = useState({ playing: false, currentTime: 0, @@ -169,6 +177,7 @@ export const PlayerProvider: React.FC = ({ containerRef, setVideoState, setUIState, + translations, play, pause, togglePlay, diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..655cab0 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,69 @@ +/** + * Simple i18n system for video player + */ + +export interface Translations { + noSubtitlesAvailable: string; + subtitles: string; + off: string; + auto: string; + quality: string; + speed: string; + normal: string; + default: string; + audioTrack: string; + settings: string; + level: string; +} + +export const translations: Record = { + en: { + noSubtitlesAvailable: 'No subtitles available', + subtitles: 'Subtitles', + off: 'Off', + auto: 'Auto', + quality: 'Quality', + speed: 'Speed', + normal: 'Normal', + default: 'Default', + audioTrack: 'Audio Track', + settings: 'Settings', + level: "Level", + }, + tr: { + noSubtitlesAvailable: 'Altyazı mevcut değil', + subtitles: 'Altyazı', + off: 'Kapalı', + auto: 'Otomatik', + quality: 'Kalite', + speed: 'Hız', + normal: 'Normal', + default: 'Varsayılan', + audioTrack: 'Ses', + settings: 'Ayarlar', + level: "Seviye", + }, +}; + +export const getTranslations = (language: string = 'en'): Translations => { + // Try exact match first + if (translations[language]) { + return translations[language]; + } + + // Try language code without region (e.g., "en" from "en-US") + const languageCode = language.split('-')[0]; + if (translations[languageCode]) { + return translations[languageCode]; + } + + // Default to English + return translations.en; +}; + +export const detectBrowserLanguage = (): string => { + if (typeof navigator !== 'undefined') { + return navigator.language || 'en'; + } + return 'en'; +}; diff --git a/src/icons/index.tsx b/src/icons/index.tsx index 12c23d9..eaa40e7 100644 --- a/src/icons/index.tsx +++ b/src/icons/index.tsx @@ -6,251 +6,118 @@ export interface IconProps { color?: string } -export const PlayIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - +interface BaseIconProps extends IconProps { + children: React.ReactNode +} + +const Icon: React.FC = ({ size = 24, className = '', color = 'currentColor', children }) => ( + + {React.Children.map(children, child => + React.isValidElement(child) ? React.cloneElement(child, { fill: color } as any) : child + )} ) -export const PauseIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const PlayIcon: React.FC = (props) => ( + + + ) -export const VolumeUpIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const PauseIcon: React.FC = (props) => ( + + + ) -export const VolumeDownIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const VolumeUpIcon: React.FC = (props) => ( + + + ) -export const VolumeMuteIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const VolumeDownIcon: React.FC = (props) => ( + + + ) -export const FullscreenIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const VolumeMuteIcon: React.FC = (props) => ( + + + ) -export const FullscreenExitIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const FullscreenIcon: React.FC = (props) => ( + + + ) -export const SettingsIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const FullscreenExitIcon: React.FC = (props) => ( + + + ) -export const PIPIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const SettingsIcon: React.FC = (props) => ( + + + ) -export const SubtitlesIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const PIPIcon: React.FC = (props) => ( + + + ) -export const SpeedIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - - +export const SubtitlesIcon: React.FC = (props) => ( + + + ) -export const QualityIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const SpeedIcon: React.FC = (props) => ( + + + + ) -export const ForwardIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const QualityIcon: React.FC = (props) => ( + + + ) -export const RewindIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const ForwardIcon: React.FC = (props) => ( + + + +) + +export const RewindIcon: React.FC = (props) => ( + + + ) export const LoadingIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - + + ) -export const CheckIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const CheckIcon: React.FC = (props) => ( + + + ) -export const AudioIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( - - - +export const AudioIcon: React.FC = (props) => ( + + + ) diff --git a/src/index.ts b/src/index.ts index 37029a9..60c154c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,11 @@ export type { // Utils export { formatTime, parseTime } from './utils/time' export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles' -export { initializePolyfills, features } from './utils/polyfills' export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper' -export { loadHls, isHlsSupported, hasNativeHlsSupport } from './utils/hlsLoader' + +// i18n +export { getTranslations, detectBrowserLanguage, translations } from './i18n' +export type { Translations } from './i18n' // Hooks (for advanced users) export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..bd119b0 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// Cleanup after each test +afterEach(() => { + cleanup(); +}); + +// Mock HTMLMediaElement methods that are not implemented in jsdom +Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: vi.fn().mockResolvedValue(undefined), +}); + +Object.defineProperty(HTMLMediaElement.prototype, 'pause', { + configurable: true, + value: vi.fn(), +}); + +Object.defineProperty(HTMLMediaElement.prototype, 'load', { + configurable: true, + value: vi.fn(), +}); + +// Mock requestFullscreen and exitFullscreen +Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { + configurable: true, + value: vi.fn().mockResolvedValue(undefined), +}); + +Object.defineProperty(Document.prototype, 'exitFullscreen', { + configurable: true, + value: vi.fn().mockResolvedValue(undefined), +}); + +// Mock Picture-in-Picture API +Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', { + configurable: true, + value: vi.fn().mockResolvedValue({}), +}); + +Object.defineProperty(Document.prototype, 'exitPictureInPicture', { + configurable: true, + value: vi.fn().mockResolvedValue(undefined), +}); + +// Mock IntersectionObserver +(globalThis as any).IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} +}; + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/src/types/index.ts b/src/types/index.ts index 9575e57..1b91010 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,6 +41,7 @@ export interface VideoPlayerProps { controls?: boolean subtitles?: SubtitleTrack[] theme?: PlayerTheme + language?: string keyboardShortcuts?: boolean pictureInPicture?: boolean className?: string diff --git a/src/utils/corsHelper.test.ts b/src/utils/corsHelper.test.ts new file mode 100644 index 0000000..f9d46c7 --- /dev/null +++ b/src/utils/corsHelper.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isSameOrigin, + isBlobOrDataURL, + validateVideoURL, + getCORSErrorMessage, + isCORSError, + checkVideoCORS, +} from './corsHelper'; + +describe('corsHelper', () => { + describe('isSameOrigin', () => { + it('returns true for same origin URLs', () => { + const sameOriginUrl = `${window.location.origin}/video.mp4`; + expect(isSameOrigin(sameOriginUrl)).toBe(true); + }); + + it('returns false for different origin URLs', () => { + expect(isSameOrigin('https://example.com/video.mp4')).toBe(false); + }); + + it('returns true for relative URLs', () => { + expect(isSameOrigin('/videos/test.mp4')).toBe(true); + }); + + it('returns true for relative path-like strings', () => { + // In browsers, "not-a-url" is treated as a relative URL + expect(isSameOrigin('not-a-url')).toBe(true); + }); + }); + + describe('isBlobOrDataURL', () => { + it('returns true for blob URLs', () => { + expect(isBlobOrDataURL('blob:http://example.com/123456')).toBe(true); + }); + + it('returns true for data URLs', () => { + expect(isBlobOrDataURL('data:video/mp4;base64,AAAA')).toBe(true); + }); + + it('returns false for regular URLs', () => { + expect(isBlobOrDataURL('https://example.com/video.mp4')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isBlobOrDataURL('')).toBe(false); + }); + }); + + describe('validateVideoURL', () => { + it('returns invalid for empty URL', () => { + const result = validateVideoURL(''); + expect(result.valid).toBe(false); + expect(result.error).toBe('Video URL is empty'); + }); + + it('returns invalid for whitespace-only URL', () => { + const result = validateVideoURL(' '); + expect(result.valid).toBe(false); + expect(result.error).toBe('Video URL is empty'); + }); + + it('returns valid for relative path strings', () => { + // Browser treats this as a relative URL + const result = validateVideoURL('not a valid url'); + expect(result.valid).toBe(true); + }); + + it('returns valid for same origin URL without warning', () => { + const result = validateVideoURL(`${window.location.origin}/video.mp4`); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('returns valid for blob URL without warning', () => { + const result = validateVideoURL('blob:http://example.com/123456'); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('returns valid with warning for external URL', () => { + const result = validateVideoURL('https://example.com/video.mp4'); + expect(result.valid).toBe(true); + expect(result.warning).toContain('CORS'); + }); + + it('returns valid for relative URLs', () => { + const result = validateVideoURL('/videos/test.mp4'); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + }); + + describe('getCORSErrorMessage', () => { + it('returns generic message for same origin', () => { + const message = getCORSErrorMessage(`${window.location.origin}/video.mp4`); + expect(message).toBe('Failed to load video. Please check the URL.'); + }); + + it('returns generic message for blob URLs', () => { + const message = getCORSErrorMessage('blob:http://example.com/123456'); + expect(message).toBe('Failed to load video. Please check the URL.'); + }); + + it('returns CORS-specific message for external URLs', () => { + const message = getCORSErrorMessage('https://example.com/video.mp4'); + expect(message).toContain('CORS Error'); + expect(message).toContain('example.com'); + expect(message).toContain('Access-Control-Allow-Origin'); + }); + }); + + describe('isCORSError', () => { + it('returns true for errors containing "cors"', () => { + const error = new Error('CORS policy blocked this request'); + expect(isCORSError(error)).toBe(true); + }); + + it('returns true for errors containing "cross-origin"', () => { + const error = new Error('Cross-origin request blocked'); + expect(isCORSError(error)).toBe(true); + }); + + it('returns true for errors containing "blocked by cors policy"', () => { + const error = new Error('Request blocked by CORS policy'); + expect(isCORSError(error)).toBe(true); + }); + + it('returns true for errors containing "access-control-allow-origin"', () => { + const error = new Error('No \'access-control-allow-origin\' header present'); + expect(isCORSError(error)).toBe(true); + }); + + it('returns false for non-CORS errors', () => { + const error = new Error('Network timeout'); + expect(isCORSError(error)).toBe(false); + }); + + it('is case insensitive', () => { + const error = new Error('BLOCKED BY CORS POLICY'); + expect(isCORSError(error)).toBe(true); + }); + }); + + describe('checkVideoCORS', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns supported when CORS headers are present', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + headers: new Map([ + ['Access-Control-Allow-Origin', '*'], + ['Accept-Ranges', 'bytes'], + ]), + }); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supported).toBe(true); + expect(result.needsProxy).toBe(false); + expect(result.supportsRange).toBe(true); + }); + + it('returns not supported when CORS headers are missing', async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + headers: new Map(), + }); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supported).toBe(false); + expect(result.needsProxy).toBe(true); + expect(result.error).toContain('CORS not enabled'); + }); + + it('detects range support', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + headers: new Map([ + ['Access-Control-Allow-Origin', '*'], + ['Accept-Ranges', 'bytes'], + ]), + }); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supportsRange).toBe(true); + }); + + it('detects no range support when header is "none"', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + headers: new Map([ + ['Access-Control-Allow-Origin', '*'], + ['Accept-Ranges', 'none'], + ]), + }); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supportsRange).toBe(false); + }); + + it('handles CORS fetch errors', async () => { + (global.fetch as any).mockRejectedValue(new TypeError('Failed to fetch (CORS)')); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supported).toBe(false); + expect(result.needsProxy).toBe(true); + expect(result.error).toContain('CORS blocked'); + }); + + it('handles general fetch errors', async () => { + (global.fetch as any).mockRejectedValue(new Error('Network error')); + + const result = await checkVideoCORS('https://example.com/video.mp4'); + expect(result.supported).toBe(false); + expect(result.needsProxy).toBe(true); + expect(result.error).toBe('Network error'); + }); + }); +}); diff --git a/src/utils/corsHelper.ts b/src/utils/corsHelper.ts index ebd7bc3..480c8b8 100644 --- a/src/utils/corsHelper.ts +++ b/src/utils/corsHelper.ts @@ -122,23 +122,7 @@ export const getCORSErrorMessage = (url: string): string => { return 'Failed to load video. Please check the URL.' } - return ` - ❌ CORS Error: Unable to load video from external source. - - The video server at "${new URL(url).origin}" does not allow cross-origin requests. - - To fix this issue: - 1. Add CORS headers to your video server: - Access-Control-Allow-Origin: * - Access-Control-Allow-Methods: GET, HEAD - Access-Control-Allow-Headers: Range - - 2. Use a proxy server to bypass CORS restrictions - - 3. Host the video on the same domain as your application - - 4. Use a CDN that supports CORS (e.g., Cloudflare, AWS CloudFront) - `.trim() + return `CORS Error: Unable to load video from ${new URL(url).origin}. The server must allow cross-origin requests with proper Access-Control-Allow-Origin headers.` } /** diff --git a/src/utils/hlsControl.ts b/src/utils/hlsControl.ts new file mode 100644 index 0000000..e2e7c86 --- /dev/null +++ b/src/utils/hlsControl.ts @@ -0,0 +1,43 @@ +/** + * HLS control utilities for audio tracks and quality levels + * Separated to avoid circular dependencies and enable better tree-shaking + */ + +/** + * Update active quality level in HLS instance. Passing null re-enables auto. + */ +export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => { + if (!hls || !Array.isArray(hls.levels)) { + return + } + + if (levelIndex === null || typeof levelIndex === 'undefined' || levelIndex < 0) { + if (hls.currentLevel !== -1) { + hls.currentLevel = -1 + } + return + } + + if (levelIndex >= hls.levels.length) { + return + } + + if (hls.currentLevel !== levelIndex) { + hls.currentLevel = levelIndex + } +} + +/** + * Set active audio track in HLS instance + */ +export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => { + if (!hls || !hls.audioTracks) { + return + } + + if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) { + return + } + + hls.audioTrack = audioTrackIndex +} diff --git a/src/utils/hlsLoader.ts b/src/utils/hlsLoader.ts index d7393df..0cfff08 100644 --- a/src/utils/hlsLoader.ts +++ b/src/utils/hlsLoader.ts @@ -4,6 +4,10 @@ */ import type { AudioTrack, VideoQuality } from '../types' +import { getTranslations, detectBrowserLanguage } from '../i18n' + +// Re-export control functions for backward compatibility +export { setHlsQualityLevel, setHlsAudioTrack } from './hlsControl' const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js' @@ -119,6 +123,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => { ? resolution.split('x').map((value: string) => parseInt(value, 10)) : [undefined, undefined] + const translations = getTranslations(detectBrowserLanguage()); const width = level.width || widthFromResolution const height = level.height || heightFromResolution const bitrate = typeof level.bitrate === 'number' ? level.bitrate : level.attrs?.BANDWIDTH @@ -131,7 +136,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => { } else if (typeof bitrate === 'number' && bitrate > 0) { label = `${Math.round(bitrate / 1000)} kbps` } else { - label = `Seviye ${index + 1}` + label = `${translations.level} ${index + 1}` } return { @@ -156,41 +161,3 @@ export const getHlsQualities = (hls: any): VideoQuality[] => { } } -/** - * Update active quality level in HLS instance. Passing null re-enables auto. - */ -export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => { - if (!hls || !Array.isArray(hls.levels)) { - return - } - - if (levelIndex === null || typeof levelIndex === 'undefined' || levelIndex < 0) { - if (hls.currentLevel !== -1) { - hls.currentLevel = -1 - } - return - } - - if (levelIndex >= hls.levels.length) { - return - } - - if (hls.currentLevel !== levelIndex) { - hls.currentLevel = levelIndex - } -} - -/** - * Set active audio track in HLS instance - */ -export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => { - if (!hls || !hls.audioTracks) { - return - } - - if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) { - return - } - - hls.audioTrack = audioTrackIndex -} diff --git a/src/utils/hlsSetup.ts b/src/utils/hlsSetup.ts new file mode 100644 index 0000000..8d4f684 --- /dev/null +++ b/src/utils/hlsSetup.ts @@ -0,0 +1,87 @@ +/** + * HLS setup and configuration utilities + */ + +import type { AudioTrack, VideoQuality } from '../types' + +interface HlsSetupOptions { + video: HTMLVideoElement + src: string + autoplay: boolean + onAudioTracksLoaded?: (tracks: AudioTrack[]) => void + onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void + onError?: (error: Error) => void +} + +export const setupHlsInstance = async ({ + video, + src, + autoplay, + onAudioTracksLoaded, + onQualityLevelsLoaded, + onError, +}: HlsSetupOptions): Promise<() => void> => { + const { loadHls, isHlsSupported, getHlsAudioTracks, getHlsQualities } = await import('./hlsLoader') + const Hls = await loadHls() + + if (!isHlsSupported(Hls)) { + throw new Error('HLS.js is not supported in this browser') + } + + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + }) + + hls.loadSource(src) + hls.attachMedia(video) + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setTimeout(() => { + const tracks = getHlsAudioTracks(hls) + const qualities = getHlsQualities(hls) + + if (tracks.length > 0) { + onAudioTracksLoaded?.(tracks) + } + + onQualityLevelsLoaded?.(qualities) + }, 100) + + if (autoplay) { + void video.play().catch(() => undefined) + } + }) + + hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => { + const tracks = getHlsAudioTracks(hls) + if (tracks.length > 0) { + onAudioTracksLoaded?.(tracks) + } + }) + + hls.on(Hls.Events.ERROR, (_event: any, data: any) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hls.startLoad() + break + case Hls.ErrorTypes.MEDIA_ERROR: + hls.recoverMediaError() + break + default: + onError?.(new Error('Fatal HLS error')) + break + } + } + }) + + ;(video as any).__hlsInstance = hls + + return () => { + if (hls) { + hls.destroy() + } + delete (video as any).__hlsInstance + } +} diff --git a/vite.config.lib.ts b/vite.config.lib.ts index 01aa99f..00a723a 100644 --- a/vite.config.lib.ts +++ b/vite.config.lib.ts @@ -26,15 +26,36 @@ export default defineConfig({ formats: ['es', 'umd'], fileName: (format) => `video-player.${format === 'es' ? 'js' : 'umd.cjs'}`, }, + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + passes: 2, + }, + mangle: { + safari10: true, + }, + format: { + comments: false, + }, + }, rollupOptions: { - external: ['react', 'react-dom'], + external: ['react', 'react-dom', 'hls.js'], output: { globals: { react: 'React', 'react-dom': 'ReactDOM', + 'hls.js': 'Hls', }, + compact: true, + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, }, }, cssCodeSplit: false, + cssMinify: true, }, }) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..373e6fc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'src/test/', + '**/*.d.ts', + '**/*.config.*', + '**/dist/', + ], + }, + }, +});