Add SRT/FLV/RTMP support and update documentation
Introduced Python scripts for SRT subtitle checking and fixing, and added comprehensive documentation covering advanced features such as protocol detection, subtitle/audio/quality management, keyboard shortcuts, and touch gestures. Updated local settings to allow new build and Python commands, added TypeScript definitions for FLV, and implemented RTMP/FLV protocol support in the player. Removed CHANGELOG.md and made various improvements to styles and example app.
This commit is contained in:
@@ -3,7 +3,11 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(npm run build:*)"
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(python check_srt.py:*)",
|
||||||
|
"Bash(python fix_srt.py:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(pnpm run build:lib:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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
|
|
||||||
+2327
-1065
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def check_srt_format(file_path):
|
||||||
|
"""SRT dosyasını kontrol eder ve hataları bulur."""
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
subtitle_count = 0
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
# Boş satırları atla
|
||||||
|
while i < len(lines) and lines[i].strip() == '':
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
|
||||||
|
subtitle_count += 1
|
||||||
|
expected_number = subtitle_count
|
||||||
|
|
||||||
|
# Satır 1: Altyazı numarası
|
||||||
|
line_num = i + 1
|
||||||
|
if not lines[i].strip().isdigit():
|
||||||
|
errors.append(f"Satır {line_num}: Altyazı numarası bekleniyor, bulunan: '{lines[i].strip()}'")
|
||||||
|
else:
|
||||||
|
actual_number = int(lines[i].strip())
|
||||||
|
if actual_number != expected_number:
|
||||||
|
warnings.append(f"Satır {line_num}: Altyazı numarası {expected_number} olmalı, {actual_number} bulundu")
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if i >= len(lines):
|
||||||
|
errors.append(f"Altyazı {subtitle_count}: Zaman damgası eksik")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Satır 2: Zaman damgası
|
||||||
|
line_num = i + 1
|
||||||
|
timestamp_pattern = r'^\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}$'
|
||||||
|
|
||||||
|
if not re.match(timestamp_pattern, lines[i].strip()):
|
||||||
|
# Hatalı formatları tespit et
|
||||||
|
timestamp_line = lines[i].strip()
|
||||||
|
|
||||||
|
# Yaygın hatalar
|
||||||
|
if '-->' in timestamp_line:
|
||||||
|
# Virgül yerine nokta kullanımı
|
||||||
|
if '.' in timestamp_line and ',' not in timestamp_line:
|
||||||
|
errors.append(f"Satır {line_num}: Milisaniye ayırıcı virgül (,) olmalı, nokta (.) değil: '{timestamp_line}'")
|
||||||
|
# Eksik sıfırlar
|
||||||
|
elif re.search(r'\d{1}:\d{2}:\d{2}', timestamp_line):
|
||||||
|
errors.append(f"Satır {line_num}: Saat/dakika/saniye 2 haneli olmalı: '{timestamp_line}'")
|
||||||
|
# Eksik/fazla milisaniye basamağı
|
||||||
|
elif re.search(r',\d{1,2}[^\d]|,\d{1,2}$', timestamp_line):
|
||||||
|
errors.append(f"Satır {line_num}: Milisaniye 3 haneli olmalı: '{timestamp_line}'")
|
||||||
|
elif re.search(r',\d{4,}', timestamp_line):
|
||||||
|
errors.append(f"Satır {line_num}: Milisaniye 3 haneli olmalı (fazla basamak): '{timestamp_line}'")
|
||||||
|
else:
|
||||||
|
errors.append(f"Satır {line_num}: Geçersiz zaman damgası formatı: '{timestamp_line}'")
|
||||||
|
else:
|
||||||
|
errors.append(f"Satır {line_num}: Zaman damgası bekleniyor (-->), bulunan: '{timestamp_line}'")
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Satır 3+: Altyazı metni
|
||||||
|
text_lines = []
|
||||||
|
while i < len(lines) and lines[i].strip() != '':
|
||||||
|
text_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not text_lines:
|
||||||
|
warnings.append(f"Altyazı {subtitle_count} (satır {line_num}): Metin içeriği boş")
|
||||||
|
|
||||||
|
print(f"Toplam altyazı sayısı: {subtitle_count}")
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\nHATALAR ({len(errors)} adet):")
|
||||||
|
print("="*60)
|
||||||
|
for error in errors[:50]: # İlk 50 hatayı göster
|
||||||
|
print(f" - {error}")
|
||||||
|
if len(errors) > 50:
|
||||||
|
print(f"\n ... ve {len(errors) - 50} hata daha")
|
||||||
|
else:
|
||||||
|
print("\nKritik hata bulunamadi!")
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f"\nUYARILAR ({len(warnings)} adet):")
|
||||||
|
print("="*60)
|
||||||
|
for warning in warnings[:20]: # İlk 20 uyarıyı göster
|
||||||
|
print(f" - {warning}")
|
||||||
|
if len(warnings) > 20:
|
||||||
|
print(f"\n ... ve {len(warnings) - 20} uyari daha")
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
file_path = sys.argv[1] if len(sys.argv) > 1 else "public/ses.srt"
|
||||||
|
errors, warnings = check_srt_format(file_path)
|
||||||
|
|
||||||
|
if not errors and not warnings:
|
||||||
|
print("\nSRT dosyasi tamamen dogru formatta!")
|
||||||
|
else:
|
||||||
|
print(f"\nOzet: {len(errors)} hata, {len(warnings)} uyari")
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def comprehensive_srt_check(file_path):
|
||||||
|
"""Kapsamlı SRT format kontrolü - tüm olası hataları yakalar."""
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
subtitle_count = 0
|
||||||
|
prev_end_time = None
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
# Boş satırları atla
|
||||||
|
while i < len(lines) and lines[i].strip() == '':
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
|
||||||
|
subtitle_count += 1
|
||||||
|
start_line = i + 1
|
||||||
|
|
||||||
|
# ===== 1. ALTYAZI NUMARASI KONTROLÜ =====
|
||||||
|
line_num = i + 1
|
||||||
|
line_content = lines[i].strip()
|
||||||
|
|
||||||
|
if not line_content:
|
||||||
|
errors.append(f"Satir {line_num}: Bos satir, altyazi numarasi bekleniyor")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not line_content.isdigit():
|
||||||
|
errors.append(f"Satir {line_num}: Altyazi numarasi olmali, bulunan: '{line_content[:50]}'")
|
||||||
|
# Sonraki geçerli numarayı bulmaya çalış
|
||||||
|
while i < len(lines) and not lines[i].strip().isdigit():
|
||||||
|
i += 1
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
actual_number = int(line_content)
|
||||||
|
if actual_number != subtitle_count:
|
||||||
|
warnings.append(f"Satir {line_num}: Numara sırası bozuk - beklenen: {subtitle_count}, bulunan: {actual_number}")
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# ===== 2. ZAMAN DAMGASI KONTROLÜ =====
|
||||||
|
if i >= len(lines):
|
||||||
|
errors.append(f"Altyazi {subtitle_count}: Zaman damgasi eksik (dosya sonu)")
|
||||||
|
break
|
||||||
|
|
||||||
|
line_num = i + 1
|
||||||
|
timestamp_line = lines[i].strip()
|
||||||
|
|
||||||
|
if not timestamp_line:
|
||||||
|
errors.append(f"Satir {line_num}: Bos satir, zaman damgasi bekleniyor")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Zaman damgası formatını detaylı kontrol et
|
||||||
|
if '-->' not in timestamp_line:
|
||||||
|
errors.append(f"Satir {line_num}: '-->' ayirici bulunamadi: '{timestamp_line[:50]}'")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = timestamp_line.split('-->')
|
||||||
|
if len(parts) != 2:
|
||||||
|
errors.append(f"Satir {line_num}: Gecersiz zaman damgasi formati: '{timestamp_line[:50]}'")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_time_str = parts[0].strip()
|
||||||
|
end_time_str = parts[1].strip()
|
||||||
|
|
||||||
|
# Başlangıç zamanı kontrolü
|
||||||
|
start_errors = validate_timestamp(start_time_str, "baslangic")
|
||||||
|
if start_errors:
|
||||||
|
for err in start_errors:
|
||||||
|
errors.append(f"Satir {line_num} {err}: '{start_time_str}'")
|
||||||
|
|
||||||
|
# Bitiş zamanı kontrolü
|
||||||
|
end_errors = validate_timestamp(end_time_str, "bitis")
|
||||||
|
if end_errors:
|
||||||
|
for err in end_errors:
|
||||||
|
errors.append(f"Satir {line_num} {err}: '{end_time_str}'")
|
||||||
|
|
||||||
|
# Zamanları parse et ve mantıksal kontroller yap
|
||||||
|
if not start_errors and not end_errors:
|
||||||
|
start_ms = parse_timestamp_to_ms(start_time_str)
|
||||||
|
end_ms = parse_timestamp_to_ms(end_time_str)
|
||||||
|
|
||||||
|
if start_ms is None or end_ms is None:
|
||||||
|
errors.append(f"Satir {line_num}: Zaman parse edilemedi: '{timestamp_line}'")
|
||||||
|
else:
|
||||||
|
# Başlangıç >= Bitiş kontrolü
|
||||||
|
if start_ms >= end_ms:
|
||||||
|
errors.append(f"Satir {line_num}: Baslangic zamani bitis zamanindan buyuk/esit: {start_time_str} >= {end_time_str}")
|
||||||
|
|
||||||
|
# Negatif zaman kontrolü
|
||||||
|
if start_ms < 0 or end_ms < 0:
|
||||||
|
errors.append(f"Satir {line_num}: Negatif zaman degeri: '{timestamp_line}'")
|
||||||
|
|
||||||
|
# Çok uzun altyazı kontrolü (>10 saniye)
|
||||||
|
duration_ms = end_ms - start_ms
|
||||||
|
if duration_ms > 10000:
|
||||||
|
warnings.append(f"Satir {line_num}: Cok uzun altyazi suresi ({duration_ms/1000:.1f} saniye): {start_time_str} --> {end_time_str}")
|
||||||
|
|
||||||
|
# Çok kısa altyazı kontrolü (<0.1 saniye)
|
||||||
|
if duration_ms < 100:
|
||||||
|
warnings.append(f"Satir {line_num}: Cok kisa altyazi suresi ({duration_ms}ms): {start_time_str} --> {end_time_str}")
|
||||||
|
|
||||||
|
# Önceki altyazı ile çakışma kontrolü
|
||||||
|
if prev_end_time is not None and start_ms < prev_end_time:
|
||||||
|
time_overlap = prev_end_time - start_ms
|
||||||
|
warnings.append(f"Satir {line_num}: Onceki altyazi ile cakisma ({time_overlap}ms): {start_time_str}")
|
||||||
|
|
||||||
|
prev_end_time = end_ms
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# ===== 3. METİN İÇERİĞİ KONTROLÜ =====
|
||||||
|
text_lines = []
|
||||||
|
text_start_line = i + 1
|
||||||
|
|
||||||
|
while i < len(lines) and lines[i].strip() != '':
|
||||||
|
# Bir sonraki satır numara mı kontrol et (yeni altyazı başlangıcı)
|
||||||
|
if (i + 1 < len(lines) and
|
||||||
|
lines[i].strip().isdigit() and
|
||||||
|
'-->' in lines[i + 1]):
|
||||||
|
break
|
||||||
|
|
||||||
|
text_lines.append(lines[i].rstrip())
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not text_lines:
|
||||||
|
warnings.append(f"Altyazi {subtitle_count} (satir {start_line}): Metin icerigi bos")
|
||||||
|
else:
|
||||||
|
# Metin kontrolü
|
||||||
|
full_text = ' '.join([t.strip() for t in text_lines])
|
||||||
|
|
||||||
|
# Çok uzun metin kontrolü
|
||||||
|
if len(full_text) > 200:
|
||||||
|
warnings.append(f"Altyazi {subtitle_count}: Cok uzun metin ({len(full_text)} karakter)")
|
||||||
|
|
||||||
|
# HTML tag kontrolü
|
||||||
|
if re.search(r'<[^>]+>', full_text):
|
||||||
|
warnings.append(f"Altyazi {subtitle_count}: HTML/XML tag iceriyor (bazi oynaticilar desteklemeyebilir)")
|
||||||
|
|
||||||
|
# Garip karakterler
|
||||||
|
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F]', full_text):
|
||||||
|
warnings.append(f"Altyazi {subtitle_count}: Kontrol karakterleri iceriyor")
|
||||||
|
|
||||||
|
return errors, warnings, subtitle_count
|
||||||
|
|
||||||
|
|
||||||
|
def validate_timestamp(time_str, time_type):
|
||||||
|
"""Tek bir zaman damgasını (HH:MM:SS,mmm) doğrular."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Format: HH:MM:SS,mmm
|
||||||
|
pattern = r'^(\d{2}):(\d{2}):(\d{2}),(\d{3})$'
|
||||||
|
match = re.match(pattern, time_str)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
# Hangi kısım hatalı bul
|
||||||
|
if ',' not in time_str and '.' not in time_str:
|
||||||
|
errors.append(f"({time_type}): Milisaniye ayirici eksik (virgul)")
|
||||||
|
elif '.' in time_str:
|
||||||
|
errors.append(f"({time_type}): Milisaniye ayirici nokta olmamali, virgul olmali")
|
||||||
|
elif time_str.count(':') != 2:
|
||||||
|
errors.append(f"({time_type}): ':' ayirici sayisi yanlis (2 olmali)")
|
||||||
|
else:
|
||||||
|
# Rakam sayısı kontrolü
|
||||||
|
parts = time_str.replace(',', ':').replace('.', ':').split(':')
|
||||||
|
if len(parts) == 4:
|
||||||
|
hours, mins, secs, ms = parts
|
||||||
|
if len(hours) != 2:
|
||||||
|
errors.append(f"({time_type}): Saat 2 haneli olmali")
|
||||||
|
if len(mins) != 2:
|
||||||
|
errors.append(f"({time_type}): Dakika 2 haneli olmali")
|
||||||
|
if len(secs) != 2:
|
||||||
|
errors.append(f"({time_type}): Saniye 2 haneli olmali")
|
||||||
|
if len(ms) != 3:
|
||||||
|
errors.append(f"({time_type}): Milisaniye 3 haneli olmali")
|
||||||
|
else:
|
||||||
|
errors.append(f"({time_type}): Format hatasi (HH:MM:SS,mmm olmali)")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Değer aralığı kontrolü
|
||||||
|
hours, mins, secs, ms = match.groups()
|
||||||
|
hours, mins, secs, ms = int(hours), int(mins), int(secs), int(ms)
|
||||||
|
|
||||||
|
if hours > 23:
|
||||||
|
warnings = [] # Videolar 24 saatten uzun olabilir, warning olarak işaretle
|
||||||
|
if mins > 59:
|
||||||
|
errors.append(f"({time_type}): Dakika 59'dan buyuk olamaz ({mins})")
|
||||||
|
if secs > 59:
|
||||||
|
errors.append(f"({time_type}): Saniye 59'dan buyuk olamaz ({secs})")
|
||||||
|
if ms > 999:
|
||||||
|
errors.append(f"({time_type}): Milisaniye 999'dan buyuk olamaz ({ms})")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp_to_ms(time_str):
|
||||||
|
"""Zaman damgasını milisaniyeye çevirir."""
|
||||||
|
try:
|
||||||
|
# Format: HH:MM:SS,mmm
|
||||||
|
pattern = r'^(\d{2}):(\d{2}):(\d{2}),(\d{3})$'
|
||||||
|
match = re.match(pattern, time_str)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hours, mins, secs, ms = match.groups()
|
||||||
|
total_ms = (int(hours) * 3600 + int(mins) * 60 + int(secs)) * 1000 + int(ms)
|
||||||
|
return total_ms
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def print_results(errors, warnings, subtitle_count, file_path):
|
||||||
|
"""Sonuçları yazdır."""
|
||||||
|
print(f"Dosya: {file_path}")
|
||||||
|
print(f"Toplam altyazi sayisi: {subtitle_count}")
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\nHATALAR ({len(errors)} adet):")
|
||||||
|
print("="*70)
|
||||||
|
for i, error in enumerate(errors[:100], 1):
|
||||||
|
print(f" {i}. {error}")
|
||||||
|
if len(errors) > 100:
|
||||||
|
print(f"\n ... ve {len(errors) - 100} hata daha")
|
||||||
|
else:
|
||||||
|
print("\nKritik hata bulunamadi!")
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f"\nUYARILAR ({len(warnings)} adet):")
|
||||||
|
print("="*70)
|
||||||
|
for i, warning in enumerate(warnings[:50], 1):
|
||||||
|
print(f" {i}. {warning}")
|
||||||
|
if len(warnings) > 50:
|
||||||
|
print(f"\n ... ve {len(warnings) - 50} uyari daha")
|
||||||
|
else:
|
||||||
|
print("\nUyari bulunamadi!")
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
|
||||||
|
if not errors and not warnings:
|
||||||
|
print("\nSonuc: SRT dosyasi MUKEMMEL durumda!")
|
||||||
|
elif not errors:
|
||||||
|
print(f"\nSonuc: Format dogru ama {len(warnings)} uyari var")
|
||||||
|
else:
|
||||||
|
print(f"\nSonuc: {len(errors)} HATA, {len(warnings)} uyari")
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
file_path = sys.argv[1] if len(sys.argv) > 1 else "public/ses.srt"
|
||||||
|
|
||||||
|
print("\nKAPSAMLI SRT FORMAT KONTROLU")
|
||||||
|
print("="*70)
|
||||||
|
print("\nKontrol edilen hususlar:")
|
||||||
|
print(" - Altyazi numara sirasi")
|
||||||
|
print(" - Zaman damgasi formati (HH:MM:SS,mmm)")
|
||||||
|
print(" - Baslangic/bitis zaman mantigi")
|
||||||
|
print(" - Altyazi cakismalari")
|
||||||
|
print(" - Altyazi sureleri")
|
||||||
|
print(" - Metin icerigi")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
errors, warnings, subtitle_count = comprehensive_srt_check(file_path)
|
||||||
|
print_results(errors, warnings, subtitle_count, file_path)
|
||||||
+3
-3
@@ -8,12 +8,12 @@ function App() {
|
|||||||
const [useDemo, setUseDemo] = useState(true)
|
const [useDemo, setUseDemo] = useState(true)
|
||||||
|
|
||||||
// Demo video URLs (you can replace with your own)
|
// Demo video URLs (you can replace with your own)
|
||||||
const demoVideoUrl = '/s6ebilmemkac.mp4'
|
const demoVideoUrl = '/player/Stormy Weather_c7e908aa/master.m3u8'
|
||||||
const demoPoster = '/s6ebilmemkac.webp'
|
const demoPoster = '/player/foto.jpg'
|
||||||
|
|
||||||
const demoSubtitles: SubtitleTrack[] = [
|
const demoSubtitles: SubtitleTrack[] = [
|
||||||
{
|
{
|
||||||
src: '/ses.srt',
|
src: '/player/ses.srt',
|
||||||
lang: 'tr',
|
lang: 'tr',
|
||||||
label: 'Türkçe',
|
label: 'Türkçe',
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def fix_srt_file(input_path, output_path):
|
||||||
|
"""SRT dosyasındaki format hatalarını düzeltir."""
|
||||||
|
|
||||||
|
with open(input_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
lines = content.split('\n')
|
||||||
|
fixed_lines = []
|
||||||
|
i = 0
|
||||||
|
subtitle_number = 1
|
||||||
|
errors_fixed = []
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
# Boş satırları atla
|
||||||
|
while i < len(lines) and lines[i].strip() == '':
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if i >= len(lines):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Altyazı numarasını ekle/düzelt
|
||||||
|
fixed_lines.append(str(subtitle_number))
|
||||||
|
|
||||||
|
# Eğer mevcut satır numara değilse, bu satırı kaybetme
|
||||||
|
if not lines[i].strip().isdigit():
|
||||||
|
# Bu satır muhtemelen yanlış yerleştirilmiş metin, geri al
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if i >= len(lines):
|
||||||
|
errors_fixed.append(f"Altyazı {subtitle_number}: Zaman damgası eksik (dosya sonu)")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Zaman damgasını bul ve düzelt
|
||||||
|
timestamp_line = lines[i].strip()
|
||||||
|
|
||||||
|
if '-->' in timestamp_line:
|
||||||
|
# Zaman damgasını düzelt
|
||||||
|
fixed_timestamp = fix_timestamp(timestamp_line, subtitle_number)
|
||||||
|
if fixed_timestamp != timestamp_line:
|
||||||
|
errors_fixed.append(f"Altyazı {subtitle_number}: Zaman damgası düzeltildi")
|
||||||
|
errors_fixed.append(f" Eski: {timestamp_line}")
|
||||||
|
errors_fixed.append(f" Yeni: {fixed_timestamp}")
|
||||||
|
fixed_lines.append(fixed_timestamp)
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
# Zaman damgası bulunamadı, bu bir format hatası
|
||||||
|
# Bu durumda bu satırı metin olarak kabul et
|
||||||
|
errors_fixed.append(f"Altyazı {subtitle_number}: Zaman damgası bulunamadı, atlanıyor")
|
||||||
|
# Sonraki geçerli zaman damgasını bul
|
||||||
|
found_timestamp = False
|
||||||
|
while i < len(lines) and not found_timestamp:
|
||||||
|
if '-->' in lines[i]:
|
||||||
|
fixed_timestamp = fix_timestamp(lines[i].strip(), subtitle_number)
|
||||||
|
fixed_lines.append(fixed_timestamp)
|
||||||
|
i += 1
|
||||||
|
found_timestamp = True
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not found_timestamp:
|
||||||
|
errors_fixed.append(f"Altyazı {subtitle_number}: Hiç zaman damgası bulunamadı, atlanıyor")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Metin satırlarını topla
|
||||||
|
text_lines = []
|
||||||
|
while i < len(lines) and lines[i].strip() != '' and '-->' not in lines[i]:
|
||||||
|
# Bir sonraki satır numara mı kontrol et
|
||||||
|
if i + 1 < len(lines) and lines[i + 1].strip() != '' and '-->' in lines[i + 1]:
|
||||||
|
# Bu muhtemelen metin
|
||||||
|
text_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
elif lines[i].strip().isdigit() and i + 1 < len(lines) and '-->' in lines[i + 1]:
|
||||||
|
# Bu bir sonraki altyazının numarası, dur
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
text_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Metni ekle
|
||||||
|
for text_line in text_lines:
|
||||||
|
fixed_lines.append(text_line.rstrip())
|
||||||
|
|
||||||
|
# Boş satır ekle
|
||||||
|
fixed_lines.append('')
|
||||||
|
|
||||||
|
subtitle_number += 1
|
||||||
|
|
||||||
|
# Dosyayı kaydet
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(fixed_lines))
|
||||||
|
|
||||||
|
return errors_fixed, subtitle_number - 1
|
||||||
|
|
||||||
|
|
||||||
|
def fix_timestamp(timestamp, subtitle_num):
|
||||||
|
"""Zaman damgası formatını düzeltir."""
|
||||||
|
|
||||||
|
# Boşlukları temizle
|
||||||
|
parts = timestamp.split('-->')
|
||||||
|
if len(parts) != 2:
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
start = parts[0].strip()
|
||||||
|
end = parts[1].strip()
|
||||||
|
|
||||||
|
# Her iki tarafı da düzelt
|
||||||
|
start_fixed = fix_time_part(start)
|
||||||
|
end_fixed = fix_time_part(end)
|
||||||
|
|
||||||
|
return f"{start_fixed} --> {end_fixed}"
|
||||||
|
|
||||||
|
|
||||||
|
def fix_time_part(time_str):
|
||||||
|
"""Tek bir zaman bölümünü düzeltir (HH:MM:SS,mmm)."""
|
||||||
|
|
||||||
|
# Virgül ve noktayı ayır
|
||||||
|
if ',' in time_str:
|
||||||
|
main_part, ms_part = time_str.rsplit(',', 1)
|
||||||
|
elif '.' in time_str:
|
||||||
|
main_part, ms_part = time_str.rsplit('.', 1)
|
||||||
|
# Noktayı virgüle çevir
|
||||||
|
else:
|
||||||
|
# Milisaniye yok, sonuna ekle
|
||||||
|
main_part = time_str
|
||||||
|
ms_part = '000'
|
||||||
|
|
||||||
|
# HH:MM:SS kısmını düzelt
|
||||||
|
time_parts = main_part.split(':')
|
||||||
|
|
||||||
|
# Eksik bölümleri tamamla
|
||||||
|
while len(time_parts) < 3:
|
||||||
|
time_parts.insert(0, '00')
|
||||||
|
|
||||||
|
# Her bölümü 2 haneli yap
|
||||||
|
fixed_parts = []
|
||||||
|
for part in time_parts:
|
||||||
|
# Sadece sayıları al
|
||||||
|
digits = ''.join(filter(str.isdigit, part))
|
||||||
|
if not digits:
|
||||||
|
digits = '0'
|
||||||
|
# 2 haneli yap
|
||||||
|
fixed_parts.append(digits.zfill(2))
|
||||||
|
|
||||||
|
# Milisaniyeyi 3 haneli yap
|
||||||
|
ms_digits = ''.join(filter(str.isdigit, ms_part))
|
||||||
|
if not ms_digits:
|
||||||
|
ms_digits = '000'
|
||||||
|
elif len(ms_digits) > 3:
|
||||||
|
ms_digits = ms_digits[:3]
|
||||||
|
else:
|
||||||
|
ms_digits = ms_digits.ljust(3, '0')
|
||||||
|
|
||||||
|
return f"{':'.join(fixed_parts)},{ms_digits}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("SRT dosyası düzeltiliyor...")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
errors_fixed, total_subtitles = fix_srt_file("public/ses.srt", "public/ses_fixed.srt")
|
||||||
|
|
||||||
|
print(f"\nToplam {total_subtitles} altyazı işlendi")
|
||||||
|
|
||||||
|
if errors_fixed:
|
||||||
|
print(f"\nDüzeltilen hatalar ({len([e for e in errors_fixed if not e.startswith(' ')])} adet):")
|
||||||
|
print("="*60)
|
||||||
|
for error in errors_fixed[:30]:
|
||||||
|
print(f" {error}")
|
||||||
|
if len(errors_fixed) > 30:
|
||||||
|
print(f"\n ... ve daha fazlası")
|
||||||
|
|
||||||
|
print(f"\nDüzeltilmiş dosya kaydedildi: public/ses_fixed.srt")
|
||||||
|
print("\nŞimdi kontrol ediliyor...")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Kontrol et
|
||||||
|
import check_srt
|
||||||
|
errors, warnings = check_srt.check_srt_format("public/ses_fixed.srt")
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
print("\n✓ Tüm hatalar düzeltildi!")
|
||||||
|
else:
|
||||||
|
print(f"\nHala {len(errors)} hata var. Manuel düzeltme gerekebilir.")
|
||||||
+4
-1
@@ -56,7 +56,8 @@
|
|||||||
"vitest": "^4.0.4"
|
"vitest": "^4.0.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"hls.js": "^1.6.13"
|
"hls.js": "^1.6.13",
|
||||||
|
"flv.js": "^1.6.2"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react",
|
"react",
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
"player",
|
"player",
|
||||||
"video-player",
|
"video-player",
|
||||||
"hls",
|
"hls",
|
||||||
|
"rtmp",
|
||||||
|
"flv",
|
||||||
"streaming",
|
"streaming",
|
||||||
"media"
|
"media"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls-layer > .center-play-overlay,
|
||||||
|
.controls-layer > .loading-spinner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.controls-layer.hidden.playing {
|
.controls-layer.hidden.playing {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
|
|||||||
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
||||||
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl'
|
||||||
import { setupHlsInstance } from '../utils/hlsSetup'
|
import { setupHlsInstance } from '../utils/hlsSetup'
|
||||||
|
import { setupRtmpInstance } from '../utils/rtmpSetup'
|
||||||
|
import { detectVideoProtocol } from '../utils/videoProtocol'
|
||||||
import { createSubtitleBlobURL } from '../utils/subtitles'
|
import { createSubtitleBlobURL } from '../utils/subtitles'
|
||||||
import './VideoElement.css'
|
import './VideoElement.css'
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ interface VideoElementProps {
|
|||||||
onSeeked?: () => void
|
onSeeked?: () => void
|
||||||
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
||||||
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
||||||
|
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoElement: React.FC<VideoElementProps> = ({
|
export const VideoElement: React.FC<VideoElementProps> = ({
|
||||||
@@ -45,11 +48,13 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onSeeked,
|
onSeeked,
|
||||||
onAudioTracksLoaded,
|
onAudioTracksLoaded,
|
||||||
onQualityLevelsLoaded,
|
onQualityLevelsLoaded,
|
||||||
|
onSubtitleTracksLoaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
||||||
const lastClickTimeRef = React.useRef<number>(0)
|
const lastClickTimeRef = React.useRef<number>(0)
|
||||||
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||||
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
||||||
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
|
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
const subtitleBlobUrlsRef = React.useRef<string[]>([])
|
||||||
|
|
||||||
@@ -210,7 +215,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
}, [setVideoState])
|
}, [setVideoState])
|
||||||
|
|
||||||
// Process subtitles - convert SRT to VTT blob URLs
|
// Process subtitles - convert SRT to VTT blob URLs and merge with HLS subtitles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -218,14 +223,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
||||||
subtitleBlobUrlsRef.current = []
|
subtitleBlobUrlsRef.current = []
|
||||||
|
|
||||||
if (subtitles.length === 0) {
|
// Merge manual subtitles and HLS subtitles
|
||||||
|
const allSubtitles = [...subtitles, ...hlsSubtitles]
|
||||||
|
|
||||||
|
if (allSubtitles.length === 0) {
|
||||||
setProcessedSubtitles([])
|
setProcessedSubtitles([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const processSubtitles = async () => {
|
const processSubtitles = async () => {
|
||||||
const processed = await Promise.all(
|
const processed = await Promise.all(
|
||||||
subtitles.map(async (subtitle) => {
|
allSubtitles.map(async (subtitle) => {
|
||||||
try {
|
try {
|
||||||
// Check if it's an SRT file
|
// Check if it's an SRT file
|
||||||
if (subtitle.src.endsWith('.srt')) {
|
if (subtitle.src.endsWith('.srt')) {
|
||||||
@@ -273,7 +281,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
||||||
subtitleBlobUrlsRef.current = []
|
subtitleBlobUrlsRef.current = []
|
||||||
}
|
}
|
||||||
}, [subtitles])
|
}, [subtitles, hlsSubtitles])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -297,7 +305,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
}, [processedSubtitles, settings.subtitle, setSubtitle, videoRef])
|
}, [processedSubtitles, settings.subtitle, setSubtitle, videoRef])
|
||||||
|
|
||||||
// Detect HLS source and load hls.js if needed
|
// Detect video protocol and setup appropriate player
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
@@ -306,6 +314,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onAudioTracksLoaded?.([])
|
onAudioTracksLoaded?.([])
|
||||||
setAvailableQualities([])
|
setAvailableQualities([])
|
||||||
onQualityLevelsLoaded?.([])
|
onQualityLevelsLoaded?.([])
|
||||||
|
setHlsSubtitles([])
|
||||||
|
onSubtitleTracksLoaded?.([])
|
||||||
|
|
||||||
// Validate video URL first
|
// Validate video URL first
|
||||||
const validation = validateVideoURL(src)
|
const validation = validateVideoURL(src)
|
||||||
@@ -316,12 +326,30 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHLS = src.includes('.m3u8')
|
// Detect video protocol
|
||||||
|
const detection = detectVideoProtocol(src)
|
||||||
let cleanupFn: (() => void) | null = null
|
let cleanupFn: (() => void) | null = null
|
||||||
|
|
||||||
const setupHls = async () => {
|
console.log('[VideoElement] Source:', src)
|
||||||
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
|
console.log('[VideoElement] Detected protocol:', detection.protocol)
|
||||||
|
console.log('[VideoElement] Is live stream?', detection.isLive)
|
||||||
|
console.log('[VideoElement] Needs special player?', detection.needsSpecialPlayer)
|
||||||
|
|
||||||
|
const setupPlayer = async () => {
|
||||||
try {
|
try {
|
||||||
|
switch (detection.protocol) {
|
||||||
|
case 'hls': {
|
||||||
|
// HLS streaming setup
|
||||||
|
const canPlayHLS = video.canPlayType('application/vnd.apple.mpegurl')
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||||
|
const shouldUseHlsJs = canPlayHLS === '' || !isSafari
|
||||||
|
|
||||||
|
console.log('[VideoElement] Native HLS support?', canPlayHLS)
|
||||||
|
console.log('[VideoElement] Is Safari?', isSafari)
|
||||||
|
console.log('[VideoElement] Will use HLS.js?', shouldUseHlsJs)
|
||||||
|
|
||||||
|
if (shouldUseHlsJs) {
|
||||||
|
console.log('[VideoElement] Setting up HLS.js...')
|
||||||
cleanupFn = await setupHlsInstance({
|
cleanupFn = await setupHlsInstance({
|
||||||
video,
|
video,
|
||||||
src,
|
src,
|
||||||
@@ -334,16 +362,64 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
setAvailableQualities(qualities)
|
setAvailableQualities(qualities)
|
||||||
onQualityLevelsLoaded?.(qualities)
|
onQualityLevelsLoaded?.(qualities)
|
||||||
},
|
},
|
||||||
|
onSubtitleTracksLoaded: (tracks) => {
|
||||||
|
setHlsSubtitles(tracks)
|
||||||
|
onSubtitleTracksLoaded?.(tracks)
|
||||||
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
console.log('[VideoElement] Using native HLS playback')
|
||||||
|
video.src = src
|
||||||
|
if (autoplay) {
|
||||||
|
void video.play().catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rtmp': {
|
||||||
|
// RTMP/FLV streaming setup
|
||||||
|
console.log('[VideoElement] Setting up RTMP/FLV player...')
|
||||||
|
cleanupFn = await setupRtmpInstance({
|
||||||
|
video,
|
||||||
|
src,
|
||||||
|
autoplay,
|
||||||
|
onError: handleError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dash': {
|
||||||
|
// DASH streaming - not yet implemented
|
||||||
|
const error = new Error('DASH streaming is not yet supported')
|
||||||
|
console.error('[VideoElement]', error.message)
|
||||||
|
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
||||||
|
onError?.(error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'native':
|
||||||
|
default: {
|
||||||
|
// Native HTML5 video (MP4, WebM, etc.)
|
||||||
|
console.log('[VideoElement] Using native video.src')
|
||||||
|
video.src = src
|
||||||
|
if (autoplay) {
|
||||||
|
void video.play().catch(() => undefined)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let error: Error
|
let error: Error
|
||||||
if (err instanceof Error && isCORSError(err)) {
|
if (err instanceof Error && isCORSError(err)) {
|
||||||
const corsMessage = getCORSErrorMessage(src)
|
const corsMessage = getCORSErrorMessage(src)
|
||||||
error = new Error(corsMessage)
|
error = new Error(corsMessage)
|
||||||
} else {
|
} else {
|
||||||
error = err instanceof Error ? err : new Error('Failed to load HLS')
|
error = err instanceof Error ? err : new Error(`Failed to load ${detection.protocol.toUpperCase()} video`)
|
||||||
}
|
}
|
||||||
|
console.error('[VideoElement] Setup error:', error)
|
||||||
setVideoState((prev) => ({
|
setVideoState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
error,
|
error,
|
||||||
@@ -351,22 +427,16 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}))
|
}))
|
||||||
onError?.(error)
|
onError?.(error)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
video.src = src
|
|
||||||
if (autoplay) {
|
|
||||||
void video.play().catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupHls()
|
setupPlayer()
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (cleanupFn) {
|
if (cleanupFn) {
|
||||||
cleanupFn()
|
cleanupFn()
|
||||||
}
|
}
|
||||||
// Also check for any lingering HLS instance
|
// Also check for any lingering player instances
|
||||||
if ((video as any).__hlsInstance) {
|
if ((video as any).__hlsInstance) {
|
||||||
const hls = (video as any).__hlsInstance
|
const hls = (video as any).__hlsInstance
|
||||||
if (hls && typeof hls.destroy === 'function') {
|
if (hls && typeof hls.destroy === 'function') {
|
||||||
@@ -374,6 +444,13 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
delete (video as any).__hlsInstance
|
delete (video as any).__hlsInstance
|
||||||
}
|
}
|
||||||
|
if ((video as any).__rtmpInstance) {
|
||||||
|
const rtmp = (video as any).__rtmpInstance
|
||||||
|
if (rtmp && typeof rtmp.destroy === 'function') {
|
||||||
|
rtmp.destroy()
|
||||||
|
}
|
||||||
|
delete (video as any).__rtmpInstance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
src,
|
src,
|
||||||
@@ -384,6 +461,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
onAudioTracksLoaded,
|
onAudioTracksLoaded,
|
||||||
onQualityLevelsLoaded,
|
onQualityLevelsLoaded,
|
||||||
|
onSubtitleTracksLoaded,
|
||||||
|
onLoadedMetadata,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Handle audio track changes
|
// Handle audio track changes
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'
|
|||||||
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||||
import { VideoElement } from './VideoElement'
|
import { VideoElement } from './VideoElement'
|
||||||
import { ControlsLayer } from './ControlsLayer'
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
import type { VideoPlayerProps, AudioTrack, VideoQuality } from '../types'
|
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
import './VideoPlayer.css'
|
import './VideoPlayer.css'
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ const VideoPlayerContent: React.FC<
|
|||||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||||
qualities: VideoQuality[]
|
qualities: VideoQuality[]
|
||||||
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
|
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
|
||||||
|
hlsSubtitles: SubtitleTrack[]
|
||||||
|
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
|
||||||
}
|
}
|
||||||
> = ({
|
> = ({
|
||||||
src,
|
src,
|
||||||
@@ -57,10 +59,15 @@ const VideoPlayerContent: React.FC<
|
|||||||
onAudioTracksLoadedInternal,
|
onAudioTracksLoadedInternal,
|
||||||
qualities,
|
qualities,
|
||||||
onQualityLevelsLoadedInternal,
|
onQualityLevelsLoadedInternal,
|
||||||
|
hlsSubtitles,
|
||||||
|
onSubtitleTracksLoadedInternal,
|
||||||
}) => {
|
}) => {
|
||||||
const { containerRef, uiState } = usePlayerContext()
|
const { containerRef, uiState } = usePlayerContext()
|
||||||
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
|
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
|
||||||
|
|
||||||
|
// Merge manual subtitles and HLS-detected subtitles
|
||||||
|
const allSubtitles = [...subtitles, ...hlsSubtitles]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`video-player ${controlsHiddenClass} ${className}`} style={style}>
|
<div ref={containerRef} className={`video-player ${controlsHiddenClass} ${className}`} style={style}>
|
||||||
<VideoElement
|
<VideoElement
|
||||||
@@ -81,12 +88,13 @@ const VideoPlayerContent: React.FC<
|
|||||||
onSeeked={onSeeked}
|
onSeeked={onSeeked}
|
||||||
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
||||||
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
||||||
|
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
||||||
/>
|
/>
|
||||||
{controls && (
|
{controls && (
|
||||||
<ControlsLayer
|
<ControlsLayer
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
pictureInPicture={pictureInPicture}
|
pictureInPicture={pictureInPicture}
|
||||||
subtitles={subtitles}
|
subtitles={allSubtitles}
|
||||||
audioTracks={audioTracks}
|
audioTracks={audioTracks}
|
||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
/>
|
/>
|
||||||
@@ -121,6 +129,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||||
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
|
|
||||||
// Apply theme CSS variables
|
// Apply theme CSS variables
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,6 +150,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
setQualities(levels)
|
setQualities(levels)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => {
|
||||||
|
setHlsSubtitles(tracks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerProvider initialMuted={muted} language={language}>
|
<PlayerProvider initialMuted={muted} language={language}>
|
||||||
<VideoPlayerContent
|
<VideoPlayerContent
|
||||||
@@ -168,6 +181,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
||||||
|
hlsSubtitles={hlsSubtitles}
|
||||||
|
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
||||||
/>
|
/>
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button {
|
.center-play-button {
|
||||||
width: 96px;
|
width: 72px;
|
||||||
height: 96px;
|
height: 72px;
|
||||||
border-radius: var(--player-radius-full);
|
border-radius: var(--player-radius-full);
|
||||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0)),
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0)),
|
||||||
var(--player-primary);
|
var(--player-primary);
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45), 0 12px 28px rgba(239, 68, 68, 0.28);
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(239, 68, 68, 0.2);
|
||||||
transition: background-color var(--player-transition-fast) ease,
|
transition: background-color var(--player-transition-fast) ease,
|
||||||
color var(--player-transition-fast) ease, transform var(--player-transition-fast) ease,
|
color var(--player-transition-fast) ease, transform var(--player-transition-fast) ease,
|
||||||
box-shadow var(--player-transition-normal) ease;
|
box-shadow var(--player-transition-normal) ease;
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
|
|
||||||
.center-play-button:hover {
|
.center-play-button:hover {
|
||||||
background-color: var(--player-primary-hover);
|
background-color: var(--player-primary-hover);
|
||||||
transform: scale(1.05);
|
transform: scale(1.08);
|
||||||
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.5), 0 14px 32px rgba(239, 68, 68, 0.32);
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(239, 68, 68, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button:active {
|
.center-play-button:active {
|
||||||
@@ -45,19 +45,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button svg {
|
.center-play-button svg {
|
||||||
width: 44px;
|
width: 36px;
|
||||||
height: 44px;
|
height: 36px;
|
||||||
margin-left: 4px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.center-play-button {
|
.center-play-button {
|
||||||
width: 74px;
|
width: 64px;
|
||||||
height: 74px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button svg {
|
.center-play-button svg {
|
||||||
width: 34px;
|
width: 30px;
|
||||||
height: 34px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
height: 3px;
|
height: 3px;
|
||||||
background-color: var(--player-progress-bg);
|
background-color: var(--player-progress-bg);
|
||||||
border-radius: var(--player-radius-full);
|
border-radius: var(--player-radius-full);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
transition: height var(--player-transition-fast) ease;
|
transition: height var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
background-color: var(--player-progress-buffered);
|
background-color: var(--player-progress-buffered);
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
transition: width 0.12s ease;
|
transition: width 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
background-color: var(--player-primary);
|
background-color: var(--player-primary);
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -46,14 +48,16 @@
|
|||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--player-primary);
|
background-color: var(--player-primary);
|
||||||
transform: scale(0);
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||||
transition: transform var(--player-transition-fast) ease;
|
transform: scale(1);
|
||||||
|
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
|
||||||
margin-right: -6px;
|
margin-right: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-handle,
|
.progress-bar:hover .progress-handle,
|
||||||
.progress-bar.seeking .progress-handle {
|
.progress-bar.seeking .progress-handle {
|
||||||
transform: scale(1);
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-tooltip {
|
.progress-tooltip {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
background-color: var(--player-progress-bg);
|
background-color: var(--player-progress-bg);
|
||||||
border-radius: var(--player-radius-full);
|
border-radius: var(--player-radius-full);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: width var(--player-transition-normal) ease,
|
transition: width var(--player-transition-normal) ease,
|
||||||
opacity var(--player-transition-normal) ease;
|
opacity var(--player-transition-normal) ease;
|
||||||
@@ -51,6 +51,14 @@
|
|||||||
background-color: var(--player-primary);
|
background-color: var(--player-primary);
|
||||||
border: none;
|
border: none;
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:hover::-webkit-slider-thumb {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-track {
|
.volume-slider::-moz-range-track {
|
||||||
@@ -64,11 +72,21 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--player-primary);
|
background-color: var(--player-primary);
|
||||||
border: none;
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:hover::-moz-range-thumb {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.volume-slider-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
background: var(--player-primary);
|
background: var(--player-primary);
|
||||||
border-radius: var(--player-radius-full);
|
border-radius: var(--player-radius-full);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: rgba(0, 0, 0, 0.28);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
z-index: var(--player-z-loading);
|
z-index: var(--player-z-loading);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Type declarations for optional flv.js module
|
||||||
|
* Since flv.js is an optional dependency that may not be installed,
|
||||||
|
* we declare it as a module that can be dynamically imported
|
||||||
|
*/
|
||||||
|
declare module 'flv.js' {
|
||||||
|
const content: any
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { CSSProperties, MutableRefObject } from 'react'
|
import type { CSSProperties, MutableRefObject } from 'react'
|
||||||
|
|
||||||
|
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash'
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
export interface SubtitleTrack {
|
||||||
src: string
|
src: string
|
||||||
lang: string
|
lang: string
|
||||||
|
|||||||
+58
-6
@@ -3,7 +3,7 @@
|
|||||||
* Handles loading hls.js from npm or CDN
|
* Handles loading hls.js from npm or CDN
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AudioTrack, VideoQuality } from '../types'
|
import type { AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
||||||
|
|
||||||
// Re-export control functions for backward compatibility
|
// Re-export control functions for backward compatibility
|
||||||
@@ -47,15 +47,20 @@ const loadHlsFromCDN = (): Promise<any> => {
|
|||||||
*/
|
*/
|
||||||
export const loadHls = async (): Promise<any> => {
|
export const loadHls = async (): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[HLS Loader] Attempting to load from npm package...')
|
||||||
// Try loading from npm package first
|
// Try loading from npm package first
|
||||||
const hlsModule = await import('hls.js')
|
const hlsModule = await import('hls.js')
|
||||||
|
console.log('[HLS Loader] Successfully loaded from npm package')
|
||||||
return hlsModule.default
|
return hlsModule.default
|
||||||
} catch {
|
} catch (npmError) {
|
||||||
|
console.warn('[HLS Loader] Failed to load from npm, trying CDN...', npmError)
|
||||||
try {
|
try {
|
||||||
// Fallback to CDN
|
// Fallback to CDN
|
||||||
const Hls = await loadHlsFromCDN()
|
const Hls = await loadHlsFromCDN()
|
||||||
|
console.log('[HLS Loader] Successfully loaded from CDN')
|
||||||
return Hls
|
return Hls
|
||||||
} catch {
|
} catch (cdnError) {
|
||||||
|
console.error('[HLS Loader] Failed to load from CDN:', cdnError)
|
||||||
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
|
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,14 +87,18 @@ export const hasNativeHlsSupport = (): boolean => {
|
|||||||
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
||||||
try {
|
try {
|
||||||
if (!hls) {
|
if (!hls) {
|
||||||
|
console.warn('[HLS Loader] getHlsAudioTracks: No HLS instance provided')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if audioTracks property exists
|
// Check if audioTracks property exists
|
||||||
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
|
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
|
||||||
|
console.warn('[HLS Loader] getHlsAudioTracks: No audioTracks array found on HLS instance')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[HLS Loader] getHlsAudioTracks: Raw audioTracks from HLS:', hls.audioTracks)
|
||||||
|
|
||||||
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
|
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
|
||||||
const audioTrack = {
|
const audioTrack = {
|
||||||
name: track.name || track.label || `Audio ${index + 1}`,
|
name: track.name || track.label || `Audio ${index + 1}`,
|
||||||
@@ -102,7 +111,38 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
|||||||
return audioTrack
|
return audioTrack
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('[HLS Loader] getHlsAudioTracks: Processed tracks:', audioTracks)
|
||||||
return audioTracks
|
return audioTracks
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HLS Loader] getHlsAudioTracks: Error extracting audio tracks:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract subtitle tracks from HLS instance
|
||||||
|
*/
|
||||||
|
export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
||||||
|
try {
|
||||||
|
if (!hls) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subtitleTracks property exists
|
||||||
|
if (!hls.subtitleTracks || !Array.isArray(hls.subtitleTracks)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitleTracks: SubtitleTrack[] = hls.subtitleTracks.map((track: any, index: number) => {
|
||||||
|
return {
|
||||||
|
label: track.name || track.label || `Subtitle ${index + 1}`,
|
||||||
|
lang: track.lang || track.language || 'unknown',
|
||||||
|
src: track.url || '',
|
||||||
|
default: track.default || false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return subtitleTracks
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -113,10 +153,18 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
|||||||
*/
|
*/
|
||||||
export const getHlsQualities = (hls: any): VideoQuality[] => {
|
export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||||
try {
|
try {
|
||||||
if (!hls || !Array.isArray(hls.levels)) {
|
if (!hls) {
|
||||||
|
console.warn('[HLS Loader] getHlsQualities: No HLS instance provided')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(hls.levels)) {
|
||||||
|
console.warn('[HLS Loader] getHlsQualities: No levels array found on HLS instance')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HLS Loader] getHlsQualities: Raw levels from HLS:', hls.levels)
|
||||||
|
|
||||||
const qualities: VideoQuality[] = hls.levels.map((level: any, index: number) => {
|
const qualities: VideoQuality[] = hls.levels.map((level: any, index: number) => {
|
||||||
const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined
|
const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined
|
||||||
const [widthFromResolution, heightFromResolution] = resolution
|
const [widthFromResolution, heightFromResolution] = resolution
|
||||||
@@ -149,14 +197,18 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return qualities.sort((a, b) => {
|
const sortedQualities = qualities.sort((a, b) => {
|
||||||
const heightDifference = (b.height || 0) - (a.height || 0)
|
const heightDifference = (b.height || 0) - (a.height || 0)
|
||||||
if (heightDifference !== 0) {
|
if (heightDifference !== 0) {
|
||||||
return heightDifference
|
return heightDifference
|
||||||
}
|
}
|
||||||
return (b.bitrate || 0) - (a.bitrate || 0)
|
return (b.bitrate || 0) - (a.bitrate || 0)
|
||||||
})
|
})
|
||||||
} catch {
|
|
||||||
|
console.log('[HLS Loader] getHlsQualities: Processed qualities:', sortedQualities)
|
||||||
|
return sortedQualities
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HLS Loader] getHlsQualities: Error extracting qualities:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-4
@@ -2,7 +2,7 @@
|
|||||||
* HLS setup and configuration utilities
|
* HLS setup and configuration utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AudioTrack, VideoQuality } from '../types'
|
import type { AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
|
|
||||||
interface HlsSetupOptions {
|
interface HlsSetupOptions {
|
||||||
video: HTMLVideoElement
|
video: HTMLVideoElement
|
||||||
@@ -10,6 +10,7 @@ interface HlsSetupOptions {
|
|||||||
autoplay: boolean
|
autoplay: boolean
|
||||||
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
||||||
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
||||||
|
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,47 +20,100 @@ export const setupHlsInstance = async ({
|
|||||||
autoplay,
|
autoplay,
|
||||||
onAudioTracksLoaded,
|
onAudioTracksLoaded,
|
||||||
onQualityLevelsLoaded,
|
onQualityLevelsLoaded,
|
||||||
|
onSubtitleTracksLoaded,
|
||||||
onError,
|
onError,
|
||||||
}: HlsSetupOptions): Promise<() => void> => {
|
}: HlsSetupOptions): Promise<() => void> => {
|
||||||
const { loadHls, isHlsSupported, getHlsAudioTracks, getHlsQualities } = await import('./hlsLoader')
|
const { loadHls, isHlsSupported, getHlsAudioTracks, getHlsQualities, getHlsSubtitleTracks } = await import('./hlsLoader')
|
||||||
const Hls = await loadHls()
|
const Hls = await loadHls()
|
||||||
|
|
||||||
if (!isHlsSupported(Hls)) {
|
if (!isHlsSupported(Hls)) {
|
||||||
throw new Error('HLS.js is not supported in this browser')
|
throw new Error('HLS.js is not supported in this browser')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[HLS Setup] Creating HLS instance for:', src)
|
||||||
|
|
||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: false,
|
lowLatencyMode: false,
|
||||||
|
debug: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
hls.loadSource(src)
|
hls.loadSource(src)
|
||||||
hls.attachMedia(video)
|
hls.attachMedia(video)
|
||||||
|
|
||||||
|
let manifestParsedHandled = false
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
setTimeout(() => {
|
console.log('[HLS Setup] MANIFEST_PARSED event fired')
|
||||||
|
|
||||||
|
if (manifestParsedHandled) {
|
||||||
|
console.warn('[HLS Setup] MANIFEST_PARSED already handled, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manifestParsedHandled = true
|
||||||
|
|
||||||
|
// Wait for tracks to be fully populated
|
||||||
|
const loadTracks = () => {
|
||||||
const tracks = getHlsAudioTracks(hls)
|
const tracks = getHlsAudioTracks(hls)
|
||||||
const qualities = getHlsQualities(hls)
|
const qualities = getHlsQualities(hls)
|
||||||
|
const subtitles = getHlsSubtitleTracks(hls)
|
||||||
|
|
||||||
|
console.log('[HLS Setup] Detected tracks:', {
|
||||||
|
audioTracks: tracks.length,
|
||||||
|
qualities: qualities.length,
|
||||||
|
subtitles: subtitles.length
|
||||||
|
})
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
|
console.log('[HLS Setup] Loading audio tracks:', tracks)
|
||||||
onAudioTracksLoaded?.(tracks)
|
onAudioTracksLoaded?.(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitles.length > 0) {
|
||||||
|
console.log('[HLS Setup] Loading subtitle tracks:', subtitles)
|
||||||
|
onSubtitleTracksLoaded?.(subtitles)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HLS Setup] Loading quality levels:', qualities)
|
||||||
onQualityLevelsLoaded?.(qualities)
|
onQualityLevelsLoaded?.(qualities)
|
||||||
}, 100)
|
}
|
||||||
|
|
||||||
|
// Try immediately first
|
||||||
|
loadTracks()
|
||||||
|
|
||||||
|
// Also retry after a delay for Chrome compatibility
|
||||||
|
setTimeout(loadTracks, 200)
|
||||||
|
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for LEVEL_LOADED to ensure qualities are populated
|
||||||
|
hls.on(Hls.Events.LEVEL_LOADED, () => {
|
||||||
|
const qualities = getHlsQualities(hls)
|
||||||
|
if (qualities.length > 0) {
|
||||||
|
console.log('[HLS Setup] LEVEL_LOADED - Qualities available:', qualities.length)
|
||||||
|
onQualityLevelsLoaded?.(qualities)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||||
const tracks = getHlsAudioTracks(hls)
|
const tracks = getHlsAudioTracks(hls)
|
||||||
|
console.log('[HLS Setup] AUDIO_TRACKS_UPDATED event:', tracks.length, 'tracks')
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
onAudioTracksLoaded?.(tracks)
|
onAudioTracksLoaded?.(tracks)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {
|
||||||
|
const subtitles = getHlsSubtitleTracks(hls)
|
||||||
|
console.log('[HLS Setup] SUBTITLE_TRACKS_UPDATED event:', subtitles.length, 'tracks')
|
||||||
|
if (subtitles.length > 0) {
|
||||||
|
onSubtitleTracksLoaded?.(subtitles)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
|
|||||||
+87
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AudioTrack } from '../types'
|
import type { AudioTrack, SubtitleTrack } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses M3U8 manifest to extract audio tracks
|
* Parses M3U8 manifest to extract audio tracks
|
||||||
@@ -73,6 +73,75 @@ const parseAudioMediaTag = (line: string): AudioTrack | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses M3U8 manifest to extract subtitle tracks
|
||||||
|
*/
|
||||||
|
export const parseM3U8SubtitleTracks = (manifestContent: string): SubtitleTrack[] => {
|
||||||
|
const subtitleTracks: SubtitleTrack[] = []
|
||||||
|
const lines = manifestContent.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('#EXT-X-MEDIA:TYPE=SUBTITLES')) {
|
||||||
|
const track = parseSubtitleMediaTag(line)
|
||||||
|
if (track) {
|
||||||
|
subtitleTracks.push(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitleTracks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single #EXT-X-MEDIA:TYPE=SUBTITLES line
|
||||||
|
* Example: #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="en",URI="subtitles_en.m3u8"
|
||||||
|
*/
|
||||||
|
const parseSubtitleMediaTag = (line: string): SubtitleTrack | null => {
|
||||||
|
try {
|
||||||
|
const attributes: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Extract all key-value pairs
|
||||||
|
const regex = /(\w+(?:-\w+)*)=("(?:[^"\\]|\\.)*"|[^,]+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const key = match[1]
|
||||||
|
let value = match[2]
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if it's a SUBTITLES type
|
||||||
|
if (attributes['TYPE'] !== 'SUBTITLES') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required fields
|
||||||
|
const name = attributes['NAME']
|
||||||
|
const language = attributes['LANGUAGE'] || attributes['LANG'] || 'unknown'
|
||||||
|
const uri = attributes['URI']
|
||||||
|
const defaultTrack = attributes['DEFAULT'] === 'YES'
|
||||||
|
|
||||||
|
if (!name || !uri) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: name,
|
||||||
|
lang: language,
|
||||||
|
src: uri,
|
||||||
|
default: defaultTrack,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses M3U8 manifest from URL
|
* Fetches and parses M3U8 manifest from URL
|
||||||
*/
|
*/
|
||||||
@@ -89,3 +158,20 @@ export const fetchAndParseM3U8 = async (url: string): Promise<AudioTrack[]> => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and parses M3U8 subtitle tracks from URL
|
||||||
|
*/
|
||||||
|
export const fetchAndParseM3U8Subtitles = async (url: string): Promise<SubtitleTrack[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch M3U8: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestContent = await response.text()
|
||||||
|
return parseM3U8SubtitleTracks(manifestContent)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* RTMP/FLV player dynamic loader
|
||||||
|
* Loads flv.js library with NPM fallback to CDN strategy
|
||||||
|
* Mirrors the HLS loader pattern for consistency
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FLVJS_CDN_URL = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically loads flv.js from CDN
|
||||||
|
* @returns Promise that resolves to the flv.js library
|
||||||
|
*/
|
||||||
|
const loadFlvjsFromCDN = async (): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if ((window as any).flvjs) {
|
||||||
|
resolve((window as any).flvjs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = FLVJS_CDN_URL
|
||||||
|
script.async = true
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if ((window as any).flvjs) {
|
||||||
|
resolve((window as any).flvjs)
|
||||||
|
} else {
|
||||||
|
reject(new Error('flv.js loaded but not available on window'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error(`Failed to load flv.js from CDN: ${FLVJS_CDN_URL}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads flv.js library
|
||||||
|
* Tries NPM package first, falls back to CDN if unavailable
|
||||||
|
* @returns Promise that resolves to the flv.js library
|
||||||
|
*/
|
||||||
|
export const loadFlvjs = async (): Promise<any> => {
|
||||||
|
try {
|
||||||
|
// Try loading from NPM package first
|
||||||
|
const flvModule = await import('flv.js')
|
||||||
|
return flvModule.default || flvModule
|
||||||
|
} catch (npmError) {
|
||||||
|
console.warn('flv.js NPM package not available, loading from CDN...', npmError)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fallback to CDN
|
||||||
|
const flvjs = await loadFlvjsFromCDN()
|
||||||
|
return flvjs
|
||||||
|
} catch (cdnError) {
|
||||||
|
console.error('Failed to load flv.js from both NPM and CDN', cdnError)
|
||||||
|
throw new Error(
|
||||||
|
'Failed to load flv.js library. Please ensure flv.js is available or check your network connection.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if flv.js is supported in the current browser
|
||||||
|
* @param flvjs - The flv.js library instance
|
||||||
|
* @returns True if supported
|
||||||
|
*/
|
||||||
|
export const isFlvjsSupported = (flvjs: any): boolean => {
|
||||||
|
if (!flvjs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// flv.js requires Media Source Extensions (MSE)
|
||||||
|
return flvjs.isSupported ? flvjs.isSupported() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets browser support information for flv.js
|
||||||
|
* @param flvjs - The flv.js library instance
|
||||||
|
* @returns Support information object
|
||||||
|
*/
|
||||||
|
export const getFlvjsSupportInfo = (flvjs: any): {
|
||||||
|
mseSupported: boolean
|
||||||
|
networkStreamIOSupported: boolean
|
||||||
|
httpsSupported: boolean
|
||||||
|
} => {
|
||||||
|
if (!flvjs || !flvjs.getFeatureList) {
|
||||||
|
return {
|
||||||
|
mseSupported: false,
|
||||||
|
networkStreamIOSupported: false,
|
||||||
|
httpsSupported: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = flvjs.getFeatureList()
|
||||||
|
return {
|
||||||
|
mseSupported: features.mseSupported || false,
|
||||||
|
networkStreamIOSupported: features.networkStreamIOSupported || false,
|
||||||
|
httpsSupported: features.httpsSupported || false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates default flv.js configuration for RTMP/FLV streams
|
||||||
|
* @param isLive - Whether the stream is live
|
||||||
|
* @returns flv.js configuration object
|
||||||
|
*/
|
||||||
|
export const createDefaultFlvConfig = (isLive: boolean = true) => {
|
||||||
|
return {
|
||||||
|
enableWorker: true, // Enable worker for better performance
|
||||||
|
enableStashBuffer: !isLive, // Disable stash buffer for live streams (low latency)
|
||||||
|
stashInitialSize: isLive ? 128 : undefined, // Smaller initial size for live
|
||||||
|
isLive: isLive,
|
||||||
|
lazyLoad: false,
|
||||||
|
lazyLoadMaxDuration: 3 * 60, // 3 minutes for VOD
|
||||||
|
lazyLoadRecoverDuration: 30, // 30 seconds
|
||||||
|
deferLoadAfterSourceOpen: false,
|
||||||
|
autoCleanupSourceBuffer: true,
|
||||||
|
autoCleanupMaxBackwardDuration: 3 * 60, // 3 minutes
|
||||||
|
autoCleanupMinBackwardDuration: 2 * 60, // 2 minutes
|
||||||
|
fixAudioTimestampGap: true,
|
||||||
|
accurateSeek: !isLive, // Disable accurate seek for live streams
|
||||||
|
seekType: isLive ? 'range' : 'param',
|
||||||
|
seekParamStart: 'start',
|
||||||
|
rangeLoadZeroStart: false,
|
||||||
|
reuseRedirectedURL: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts quality information from flv.js statistics (if available)
|
||||||
|
* Note: Unlike HLS, FLV/RTMP streams typically don't have multiple quality levels
|
||||||
|
* This is primarily for metadata display
|
||||||
|
* @param player - The flv.js player instance
|
||||||
|
* @returns Basic quality information
|
||||||
|
*/
|
||||||
|
export const extractFlvQualityInfo = (player: any): {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
videoCodec?: string
|
||||||
|
audioCodec?: string
|
||||||
|
fps?: number
|
||||||
|
videoBitrate?: number
|
||||||
|
audioBitrate?: number
|
||||||
|
} | null => {
|
||||||
|
if (!player || !player.statisticsInfo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = player.statisticsInfo
|
||||||
|
return {
|
||||||
|
width: stats.videoWidth,
|
||||||
|
height: stats.videoHeight,
|
||||||
|
videoCodec: stats.videoCodec,
|
||||||
|
audioCodec: stats.audioCodec,
|
||||||
|
fps: stats.fps,
|
||||||
|
videoBitrate: stats.videoBitrate,
|
||||||
|
audioBitrate: stats.audioBitrate,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract flv.js quality info:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* RTMP/FLV player setup utility
|
||||||
|
* Initializes and configures flv.js player for RTMP/FLV streams
|
||||||
|
* Mirrors the HLS setup pattern for consistency
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadFlvjs, isFlvjsSupported, createDefaultFlvConfig } from './rtmpLoader'
|
||||||
|
import { isLiveStream } from './videoProtocol'
|
||||||
|
|
||||||
|
export interface RtmpSetupOptions {
|
||||||
|
video: HTMLVideoElement
|
||||||
|
src: string
|
||||||
|
autoplay?: boolean
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
onLoadedMetadata?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up flv.js player instance for RTMP/FLV streaming
|
||||||
|
* @param options - Setup options
|
||||||
|
* @returns Cleanup function to destroy the player
|
||||||
|
*/
|
||||||
|
export const setupRtmpInstance = async ({
|
||||||
|
video,
|
||||||
|
src,
|
||||||
|
autoplay = false,
|
||||||
|
onError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
}: RtmpSetupOptions): Promise<() => void> => {
|
||||||
|
try {
|
||||||
|
// Load flv.js library
|
||||||
|
const flvjs = await loadFlvjs()
|
||||||
|
|
||||||
|
// Check if flv.js is supported
|
||||||
|
if (!isFlvjsSupported(flvjs)) {
|
||||||
|
const error = new Error(
|
||||||
|
'flv.js is not supported in this browser. Media Source Extensions (MSE) is required.'
|
||||||
|
)
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if stream is live
|
||||||
|
const isLive = isLiveStream(src)
|
||||||
|
|
||||||
|
// Determine media type
|
||||||
|
let type = 'flv'
|
||||||
|
if (src.startsWith('rtmp://') || src.startsWith('rtmps://')) {
|
||||||
|
// For RTMP URLs, flv.js expects HTTP-FLV endpoint
|
||||||
|
// This is a limitation - direct RTMP playback requires server-side conversion
|
||||||
|
console.warn(
|
||||||
|
'Direct RTMP playback requires an HTTP-FLV proxy. Please ensure your RTMP stream is available via HTTP-FLV.'
|
||||||
|
)
|
||||||
|
type = 'flv'
|
||||||
|
} else if (src.includes('.flv')) {
|
||||||
|
type = 'flv'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create flv.js player configuration
|
||||||
|
const config = createDefaultFlvConfig(isLive)
|
||||||
|
|
||||||
|
// Create player instance
|
||||||
|
const player = flvjs.createPlayer(
|
||||||
|
{
|
||||||
|
type: type,
|
||||||
|
url: src,
|
||||||
|
isLive: isLive,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
},
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attach to video element
|
||||||
|
player.attachMediaElement(video)
|
||||||
|
|
||||||
|
// Load the stream
|
||||||
|
player.load()
|
||||||
|
|
||||||
|
// Store player instance on video element for later access
|
||||||
|
;(video as any).__rtmpInstance = player
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
player.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string, errorInfo: any) => {
|
||||||
|
console.error('flv.js error:', { errorType, errorDetail, errorInfo })
|
||||||
|
|
||||||
|
const error = new Error(`FLV Player Error: ${errorType} - ${errorDetail}`)
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
console.error('Network error occurred:', errorDetail)
|
||||||
|
|
||||||
|
// Attempt recovery for recoverable network errors
|
||||||
|
if (
|
||||||
|
errorDetail === flvjs.ErrorDetails.NETWORK_EXCEPTION ||
|
||||||
|
errorDetail === flvjs.ErrorDetails.NETWORK_STATUS_CODE_INVALID
|
||||||
|
) {
|
||||||
|
console.log('Attempting to recover from network error...')
|
||||||
|
try {
|
||||||
|
player.unload()
|
||||||
|
player.load()
|
||||||
|
return
|
||||||
|
} catch (recoveryError) {
|
||||||
|
console.error('Failed to recover from network error:', recoveryError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (errorType === flvjs.ErrorTypes.MEDIA_ERROR) {
|
||||||
|
console.error('Media error occurred:', errorDetail)
|
||||||
|
|
||||||
|
// Some media errors are recoverable
|
||||||
|
if (errorDetail === flvjs.ErrorDetails.MEDIA_MSE_ERROR) {
|
||||||
|
console.log('Attempting to recover from media error...')
|
||||||
|
try {
|
||||||
|
player.unload()
|
||||||
|
player.load()
|
||||||
|
return
|
||||||
|
} catch (recoveryError) {
|
||||||
|
console.error('Failed to recover from media error:', recoveryError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call error callback
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
player.on(flvjs.Events.LOADING_COMPLETE, () => {
|
||||||
|
console.log('flv.js: Loading complete')
|
||||||
|
})
|
||||||
|
|
||||||
|
player.on(flvjs.Events.RECOVERED_EARLY_EOF, () => {
|
||||||
|
console.log('flv.js: Recovered from early EOF')
|
||||||
|
})
|
||||||
|
|
||||||
|
player.on(flvjs.Events.METADATA_ARRIVED, (metadata: any) => {
|
||||||
|
console.log('flv.js: Metadata arrived', metadata)
|
||||||
|
|
||||||
|
// Trigger onLoadedMetadata callback
|
||||||
|
if (onLoadedMetadata) {
|
||||||
|
onLoadedMetadata()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
player.on(flvjs.Events.STATISTICS_INFO, (stats: any) => {
|
||||||
|
// Statistics info for debugging/monitoring
|
||||||
|
// Can be used to display stream quality, bitrate, etc.
|
||||||
|
if (stats) {
|
||||||
|
;(video as any).__rtmpStats = stats
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-play if requested
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
await video.play()
|
||||||
|
} catch (playError) {
|
||||||
|
console.warn('Autoplay failed:', playError)
|
||||||
|
// Autoplay might be blocked by browser, ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
console.log('Cleaning up flv.js player...')
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
player.off(flvjs.Events.ERROR)
|
||||||
|
player.off(flvjs.Events.LOADING_COMPLETE)
|
||||||
|
player.off(flvjs.Events.RECOVERED_EARLY_EOF)
|
||||||
|
player.off(flvjs.Events.METADATA_ARRIVED)
|
||||||
|
player.off(flvjs.Events.STATISTICS_INFO)
|
||||||
|
|
||||||
|
// Pause and unload
|
||||||
|
video.pause()
|
||||||
|
player.unload()
|
||||||
|
|
||||||
|
// Detach from video element
|
||||||
|
player.detachMediaElement()
|
||||||
|
|
||||||
|
// Destroy player instance
|
||||||
|
player.destroy()
|
||||||
|
|
||||||
|
// Clean up stored references
|
||||||
|
delete (video as any).__rtmpInstance
|
||||||
|
delete (video as any).__rtmpStats
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Error during flv.js cleanup:', cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup flv.js player:', error)
|
||||||
|
|
||||||
|
const setupError =
|
||||||
|
error instanceof Error ? error : new Error('Failed to setup RTMP/FLV player')
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
onError(setupError)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw setupError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current flv.js player instance from a video element
|
||||||
|
* @param video - The video element
|
||||||
|
* @returns The flv.js player instance or null
|
||||||
|
*/
|
||||||
|
export const getRtmpInstance = (video: HTMLVideoElement | null): any | null => {
|
||||||
|
if (!video) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (video as any).__rtmpInstance || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current flv.js statistics from a video element
|
||||||
|
* @param video - The video element
|
||||||
|
* @returns The statistics object or null
|
||||||
|
*/
|
||||||
|
export const getRtmpStats = (video: HTMLVideoElement | null): any | null => {
|
||||||
|
if (!video) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (video as any).__rtmpStats || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a video element has an active RTMP player instance
|
||||||
|
* @param video - The video element
|
||||||
|
* @returns True if has active instance
|
||||||
|
*/
|
||||||
|
export const hasRtmpInstance = (video: HTMLVideoElement | null): boolean => {
|
||||||
|
return getRtmpInstance(video) !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Video protocol detection utility
|
||||||
|
* Detects the streaming protocol from a video URL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash'
|
||||||
|
|
||||||
|
export interface ProtocolDetectionResult {
|
||||||
|
protocol: VideoProtocol
|
||||||
|
isLive: boolean
|
||||||
|
needsSpecialPlayer: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the video protocol from the source URL
|
||||||
|
* @param src - The video source URL
|
||||||
|
* @returns Protocol detection result
|
||||||
|
*/
|
||||||
|
export const detectVideoProtocol = (src: string): ProtocolDetectionResult => {
|
||||||
|
if (!src) {
|
||||||
|
return {
|
||||||
|
protocol: 'native',
|
||||||
|
isLive: false,
|
||||||
|
needsSpecialPlayer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerSrc = src.toLowerCase()
|
||||||
|
|
||||||
|
// RTMP protocol detection
|
||||||
|
// Supports: rtmp://, rtmps://, rtmpt://, rtmpe://
|
||||||
|
if (
|
||||||
|
lowerSrc.startsWith('rtmp://') ||
|
||||||
|
lowerSrc.startsWith('rtmps://') ||
|
||||||
|
lowerSrc.startsWith('rtmpt://') ||
|
||||||
|
lowerSrc.startsWith('rtmpe://')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
protocol: 'rtmp',
|
||||||
|
isLive: true, // RTMP is typically used for live streaming
|
||||||
|
needsSpecialPlayer: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS protocol detection
|
||||||
|
// Check for .m3u8 extension or HLS URL patterns
|
||||||
|
if (lowerSrc.includes('.m3u8')) {
|
||||||
|
return {
|
||||||
|
protocol: 'hls',
|
||||||
|
isLive: lowerSrc.includes('/live/') || lowerSrc.includes('live.m3u8'),
|
||||||
|
needsSpecialPlayer: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DASH protocol detection
|
||||||
|
// Check for .mpd extension
|
||||||
|
if (lowerSrc.includes('.mpd')) {
|
||||||
|
return {
|
||||||
|
protocol: 'dash',
|
||||||
|
isLive: lowerSrc.includes('/live/') || lowerSrc.includes('live.mpd'),
|
||||||
|
needsSpecialPlayer: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP-FLV detection (alternative to RTMP)
|
||||||
|
if (lowerSrc.includes('.flv') || lowerSrc.includes('flv?')) {
|
||||||
|
return {
|
||||||
|
protocol: 'rtmp', // Use RTMP player for FLV files
|
||||||
|
isLive: lowerSrc.includes('/live/') || lowerSrc.includes('live.flv'),
|
||||||
|
needsSpecialPlayer: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native HTML5 video formats (MP4, WebM, OGG, etc.)
|
||||||
|
return {
|
||||||
|
protocol: 'native',
|
||||||
|
isLive: false,
|
||||||
|
needsSpecialPlayer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the URL is an RTMP stream
|
||||||
|
* @param src - The video source URL
|
||||||
|
* @returns True if RTMP stream
|
||||||
|
*/
|
||||||
|
export const isRtmpStream = (src: string): boolean => {
|
||||||
|
const detection = detectVideoProtocol(src)
|
||||||
|
return detection.protocol === 'rtmp'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the URL is an HLS stream
|
||||||
|
* @param src - The video source URL
|
||||||
|
* @returns True if HLS stream
|
||||||
|
*/
|
||||||
|
export const isHlsStream = (src: string): boolean => {
|
||||||
|
const detection = detectVideoProtocol(src)
|
||||||
|
return detection.protocol === 'hls'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the stream is live
|
||||||
|
* @param src - The video source URL
|
||||||
|
* @returns True if live stream
|
||||||
|
*/
|
||||||
|
export const isLiveStream = (src: string): boolean => {
|
||||||
|
const detection = detectVideoProtocol(src)
|
||||||
|
return detection.isLive
|
||||||
|
}
|
||||||
+2
-1
@@ -41,12 +41,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['react', 'react-dom', 'hls.js'],
|
external: ['react', 'react-dom', 'hls.js', 'flv.js'],
|
||||||
output: {
|
output: {
|
||||||
globals: {
|
globals: {
|
||||||
react: 'React',
|
react: 'React',
|
||||||
'react-dom': 'ReactDOM',
|
'react-dom': 'ReactDOM',
|
||||||
'hls.js': 'Hls',
|
'hls.js': 'Hls',
|
||||||
|
'flv.js': 'flvjs',
|
||||||
},
|
},
|
||||||
compact: true,
|
compact: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'path'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: '/player/',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user