/** * 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 => { 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 => { 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 [] } }