218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
/**
|
|
* HLS.js dynamic loader with CDN fallback
|
|
* Handles loading hls.js from npm or CDN
|
|
*/
|
|
|
|
import type { AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
|
import { getTranslations, detectBrowserLanguage } from '../i18n'
|
|
import { logger } from './logger'
|
|
|
|
// Re-export control functions for backward compatibility
|
|
export { setHlsQualityLevel, setHlsAudioTrack } from './hlsControl'
|
|
|
|
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.6.13/dist/hls.min.js'
|
|
|
|
/**
|
|
* Load hls.js from CDN as fallback
|
|
*/
|
|
const loadHlsFromCDN = (): Promise<HlsConstructor> => {
|
|
return new Promise((resolve, reject) => {
|
|
// Check if already loaded globally
|
|
if (window.Hls) {
|
|
resolve(window.Hls)
|
|
return
|
|
}
|
|
|
|
const script = document.createElement('script')
|
|
script.src = HLS_CDN_URL
|
|
script.async = true
|
|
|
|
script.onload = () => {
|
|
if (window.Hls) {
|
|
resolve(window.Hls)
|
|
} else {
|
|
reject(new Error('HLS.js CDN loaded but Hls global not found'))
|
|
}
|
|
}
|
|
|
|
script.onerror = () => {
|
|
reject(new Error('Failed to load hls.js from CDN'))
|
|
}
|
|
|
|
document.head.appendChild(script)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Load hls.js with npm fallback to CDN
|
|
*/
|
|
export const loadHls = async (): Promise<HlsConstructor> => {
|
|
try {
|
|
logger.log('[HLS Loader] Attempting to load from npm package...')
|
|
// Try loading from npm package first
|
|
const hlsModuleName = 'hls.js'
|
|
const hlsModule = await import(/* @vite-ignore */ hlsModuleName)
|
|
const moduleWithDefault = hlsModule as { default?: HlsConstructor }
|
|
logger.log('[HLS Loader] Successfully loaded from npm package')
|
|
return moduleWithDefault.default ?? (hlsModule as unknown as HlsConstructor)
|
|
} catch (npmError) {
|
|
logger.warn('[HLS Loader] Failed to load from npm, trying CDN...', npmError)
|
|
try {
|
|
// Fallback to CDN
|
|
const Hls = await loadHlsFromCDN()
|
|
logger.log('[HLS Loader] Successfully loaded from CDN')
|
|
return Hls
|
|
} catch (cdnError) {
|
|
logger.error('[HLS Loader] Failed to load from CDN:', cdnError)
|
|
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if HLS.js is supported in current browser
|
|
*/
|
|
export const isHlsSupported = (Hls: HlsConstructor): boolean => {
|
|
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
|
|
}
|
|
|
|
/**
|
|
* Check if browser has native HLS support (Safari)
|
|
*/
|
|
export const hasNativeHlsSupport = (): boolean => {
|
|
const video = document.createElement('video')
|
|
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
|
}
|
|
|
|
/**
|
|
* Extract audio tracks from HLS instance
|
|
*/
|
|
export const getHlsAudioTracks = (hls: HlsInstance): AudioTrack[] => {
|
|
try {
|
|
if (!hls) {
|
|
logger.warn('[HLS Loader] getHlsAudioTracks: No HLS instance provided')
|
|
return []
|
|
}
|
|
|
|
// Check if audioTracks property exists
|
|
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
|
|
logger.warn('[HLS Loader] getHlsAudioTracks: No audioTracks array found on HLS instance')
|
|
return []
|
|
}
|
|
|
|
logger.log('[HLS Loader] getHlsAudioTracks: Raw audioTracks from HLS:', hls.audioTracks)
|
|
|
|
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: HlsAudioTrack, index: number) => {
|
|
const audioTrack = {
|
|
name: track.name || track.label || `Audio ${index + 1}`,
|
|
language: track.lang || track.language || 'unknown',
|
|
url: track.url || '',
|
|
groupId: track.groupId || 'audio',
|
|
default: track.default || false,
|
|
autoselect: track.autoselect || false,
|
|
}
|
|
return audioTrack
|
|
})
|
|
|
|
logger.log('[HLS Loader] getHlsAudioTracks: Processed tracks:', audioTracks)
|
|
return audioTracks
|
|
} catch (error) {
|
|
logger.error('[HLS Loader] getHlsAudioTracks: Error extracting audio tracks:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract subtitle tracks from HLS instance
|
|
*/
|
|
export const getHlsSubtitleTracks = (hls: HlsInstance): 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: HlsSubtitleTrack, 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 {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract available quality levels from HLS instance
|
|
*/
|
|
export const getHlsQualities = (hls: HlsInstance): VideoQuality[] => {
|
|
try {
|
|
if (!hls) {
|
|
logger.warn('[HLS Loader] getHlsQualities: No HLS instance provided')
|
|
return []
|
|
}
|
|
|
|
if (!Array.isArray(hls.levels)) {
|
|
logger.warn('[HLS Loader] getHlsQualities: No levels array found on HLS instance')
|
|
return []
|
|
}
|
|
|
|
logger.log('[HLS Loader] getHlsQualities: Raw levels from HLS:', hls.levels)
|
|
|
|
const qualities: VideoQuality[] = hls.levels.map((level: HlsLevel, index: number) => {
|
|
const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined
|
|
const [widthFromResolution, heightFromResolution] = resolution
|
|
? 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 ? parseInt(level.attrs.BANDWIDTH, 10) : undefined)
|
|
|
|
let label: string
|
|
if (typeof level.name === 'string' && level.name.trim().length > 0) {
|
|
label = level.name
|
|
} else if (typeof height === 'number' && !Number.isNaN(height) && height > 0) {
|
|
label = `${height}p`
|
|
} else if (typeof bitrate === 'number' && bitrate > 0) {
|
|
label = `${Math.round(bitrate / 1000)} kbps`
|
|
} else {
|
|
label = `${translations.level} ${index + 1}`
|
|
}
|
|
|
|
return {
|
|
height,
|
|
width,
|
|
bitrate: typeof bitrate === 'number' ? bitrate : undefined,
|
|
label,
|
|
levelIndex: index,
|
|
url: level.url || level.uri || undefined,
|
|
}
|
|
})
|
|
|
|
const sortedQualities = qualities.sort((a, b) => {
|
|
const heightDifference = (b.height || 0) - (a.height || 0)
|
|
if (heightDifference !== 0) {
|
|
return heightDifference
|
|
}
|
|
return (b.bitrate || 0) - (a.bitrate || 0)
|
|
})
|
|
|
|
logger.log('[HLS Loader] getHlsQualities: Processed qualities:', sortedQualities)
|
|
return sortedQualities
|
|
} catch (error) {
|
|
logger.error('[HLS Loader] getHlsQualities: Error extracting qualities:', error)
|
|
return []
|
|
}
|
|
}
|