vidply 1.2.0 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +974 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
- package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
- package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
- package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
- package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
- package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
- package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
- package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
- package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
- package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
- package/dist/dev/vidply.esm.js +648 -35
- package/dist/dev/vidply.esm.js.map +3 -3
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
- package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
- package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
- package/dist/legacy/vidply.js +1062 -34
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +127 -55
- package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
- package/dist/vidply.css +5852 -5796
- package/dist/vidply.esm.min.meta.json +204 -149
- package/dist/vidply.min.css +1 -1
- package/package.json +11 -11
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4191 -4159
- package/src/controls/KeyboardHelp.ts +326 -0
- package/src/controls/KeyboardManager.ts +387 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MediaSessionManager.ts +341 -0
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2973 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +244 -217
- package/src/i18n/languages/en.ts +245 -218
- package/src/i18n/languages/es.ts +241 -214
- package/src/i18n/languages/fr.ts +241 -214
- package/src/i18n/languages/ja.ts +241 -214
- package/src/icons/Icons.ts +168 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5852 -5796
- package/src/types/events.ts +128 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +199 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
|
@@ -1,944 +1,944 @@
|
|
|
1
|
-
import type { Renderer, QualityLevel } from '../types/renderer.js';
|
|
2
|
-
import type { Player } from '../core/Player.js';
|
|
3
|
-
import { loadScriptOnce } from '../utils/ScriptLoader.js';
|
|
4
|
-
|
|
5
|
-
/** Subset of payloads emitted by hls.js events that we actually consume. */
|
|
6
|
-
interface HlsManifestParsedData {
|
|
7
|
-
levels: HlsLevel[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface HlsLevelSwitchedData {
|
|
11
|
-
level: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface HlsSubtitleTracksUpdatedData {
|
|
15
|
-
subtitleTracks: HlsSubtitleTrack[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface HlsSubtitleTrackSwitchData {
|
|
19
|
-
id: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface HlsSubtitleFragProcessedData {
|
|
23
|
-
success?: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class HLSRenderer implements Renderer {
|
|
27
|
-
player: Player;
|
|
28
|
-
media: HTMLMediaElement;
|
|
29
|
-
hls: HlsInstance | null;
|
|
30
|
-
|
|
31
|
-
// True once hls.js is driving playback via MSE. Native HLS playback on
|
|
32
|
-
// iOS / iPadOS keeps a real HTTP URL on the <video> and does not need the
|
|
33
|
-
// streaming path to be forced.
|
|
34
|
-
get isStreaming(): boolean {
|
|
35
|
-
return this.hls !== null && this.hls !== undefined;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
_hlsSourceLoaded: boolean;
|
|
39
|
-
_pendingSrc: string | null;
|
|
40
|
-
_hlsSubtitleTracksCount: number | undefined;
|
|
41
|
-
_cueUpdateTimer: ReturnType<typeof setInterval> | null;
|
|
42
|
-
_lastKnownCueCount: number;
|
|
43
|
-
_nativeTrackListenersDestroyed?: boolean;
|
|
44
|
-
_didDeferredLoad?: boolean;
|
|
45
|
-
_manifestUrl: string | null;
|
|
46
|
-
/**
|
|
47
|
-
* True when the most recent startLoad() call was triggered by a seek on a
|
|
48
|
-
* paused media element (not by play()). The FRAG_BUFFERED handler uses this
|
|
49
|
-
* to call stopLoad() once the seek target is buffered, so hls.js does not
|
|
50
|
-
* keep pre-fetching subsequent segments while the user is still paused.
|
|
51
|
-
*/
|
|
52
|
-
_loadingForSeekOnly?: boolean;
|
|
53
|
-
_cleanupNativeTextTrackListeners: () => void;
|
|
54
|
-
// Detaches all hls.js-path media listeners (attachMediaEvents) in destroy().
|
|
55
|
-
private _listenerController: AbortController;
|
|
56
|
-
// Pending setTimeout ids so destroy() can cancel retries that would
|
|
57
|
-
// otherwise fire (and touch a torn-down player) after teardown.
|
|
58
|
-
private _timers: Set<ReturnType<typeof setTimeout>>;
|
|
59
|
-
// Tracked 'ready' listener registered by updateCaptionButtonsForHls when the
|
|
60
|
-
// control bar isn't built yet; removed on destroy if it never fired.
|
|
61
|
-
private _pendingReadyHandler: (() => void) | null;
|
|
62
|
-
|
|
63
|
-
constructor(player: Player) {
|
|
64
|
-
this.player = player;
|
|
65
|
-
this.media = player.element;
|
|
66
|
-
this.hls = null;
|
|
67
|
-
this._hlsSourceLoaded = false;
|
|
68
|
-
this._pendingSrc = null;
|
|
69
|
-
this._hlsSubtitleTracksCount = undefined;
|
|
70
|
-
this._cueUpdateTimer = null;
|
|
71
|
-
this._lastKnownCueCount = 0;
|
|
72
|
-
this._manifestUrl = null;
|
|
73
|
-
this._cleanupNativeTextTrackListeners = () => {};
|
|
74
|
-
this._listenerController = new AbortController();
|
|
75
|
-
this._timers = new Set();
|
|
76
|
-
this._pendingReadyHandler = null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Schedule a timeout that is automatically cancelled by destroy(). Prevents
|
|
81
|
-
* caption-retry / error-recovery callbacks from running after teardown.
|
|
82
|
-
*/
|
|
83
|
-
private _setTimeout(handler: () => void, ms: number): void {
|
|
84
|
-
const id = setTimeout(() => {
|
|
85
|
-
this._timers.delete(id);
|
|
86
|
-
handler();
|
|
87
|
-
}, ms);
|
|
88
|
-
this._timers.add(id);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private _clearTimers(): void {
|
|
92
|
-
for (const id of this._timers) {
|
|
93
|
-
clearTimeout(id);
|
|
94
|
-
}
|
|
95
|
-
this._timers.clear();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async init() {
|
|
99
|
-
// Check if browser natively supports HLS (Safari)
|
|
100
|
-
if (this.canPlayNatively()) {
|
|
101
|
-
this.player.log('Using native HLS support');
|
|
102
|
-
await this.initNative();
|
|
103
|
-
} else {
|
|
104
|
-
this.player.log('Using hls.js for HLS support');
|
|
105
|
-
await this.initHlsJs();
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
canPlayNatively() {
|
|
110
|
-
// Use native HLS only on iOS/iPadOS (MSE unavailable); desktop macOS Safari uses hls.js
|
|
111
|
-
// for quality switching and parity with Chrome/Firefox.
|
|
112
|
-
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
|
113
|
-
// iPad in desktop mode reports MacIntel but has touch
|
|
114
|
-
const isIPadDesktopMode = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
|
115
|
-
|
|
116
|
-
if (!isIOS && !isIPadDesktopMode) {
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const video = document.createElement('video');
|
|
121
|
-
return video.canPlayType('application/vnd.apple.mpegurl') !== '';
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async initNative() {
|
|
125
|
-
// Native HLS (iOS / iPadOS): the <video> element plays the HLS URL
|
|
126
|
-
// directly, so we delegate the playback surface to an HTML5Renderer via
|
|
127
|
-
// composition rather than grafting its prototype methods onto this
|
|
128
|
-
// instance. hls.js is never created on this path (this.hls stays null),
|
|
129
|
-
// and this.media already references the same player.element the delegate
|
|
130
|
-
// drives, so quality/caption inspection keeps working.
|
|
131
|
-
const { HTML5Renderer } = await import('./HTML5Renderer.js');
|
|
132
|
-
const native = new HTML5Renderer(this.player);
|
|
133
|
-
await native.init();
|
|
134
|
-
|
|
135
|
-
// Forward the externally-invoked Renderer surface to the delegate. The
|
|
136
|
-
// delegate's own internal helpers (attachEvents, pauseOtherPlayers, …)
|
|
137
|
-
// run via these calls, so they don't need explicit forwarding. HLS-only
|
|
138
|
-
// methods (attachHlsEvents, handleHlsError, supportsAutoQuality, …) keep
|
|
139
|
-
// their HLSRenderer implementations.
|
|
140
|
-
this.play = () => native.play();
|
|
141
|
-
this.pause = () => native.pause();
|
|
142
|
-
this.seek = (time: number) => native.seek(time);
|
|
143
|
-
this.setVolume = (volume: number) => native.setVolume(volume);
|
|
144
|
-
this.setMuted = (muted: boolean) => native.setMuted(muted);
|
|
145
|
-
this.setPlaybackSpeed = (speed: number) => native.setPlaybackSpeed(speed);
|
|
146
|
-
this.ensureLoaded = () => native.ensureLoaded?.();
|
|
147
|
-
this.getQualities = () => native.getQualities?.() ?? [];
|
|
148
|
-
this.switchQuality = (index: number) => native.switchQuality?.(index);
|
|
149
|
-
this.getCurrentQuality = () => native.getCurrentQuality?.() ?? 0;
|
|
150
|
-
|
|
151
|
-
this._attachNativeTextTrackListeners();
|
|
152
|
-
|
|
153
|
-
// destroy() delegates to the native renderer but also tears down the
|
|
154
|
-
// native text-track listeners, caption-retry timers and any deferred
|
|
155
|
-
// 'ready' handler scheduled by updateCaptionButtonsForHls.
|
|
156
|
-
this.destroy = () => {
|
|
157
|
-
this._cleanupNativeTextTrackListeners();
|
|
158
|
-
this._clearTimers();
|
|
159
|
-
if (this._pendingReadyHandler) {
|
|
160
|
-
this.player.off('ready', this._pendingReadyHandler);
|
|
161
|
-
this._pendingReadyHandler = null;
|
|
162
|
-
}
|
|
163
|
-
native.destroy();
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Listen for HLS-exposed text tracks so captions/transcript buttons appear on native HLS.
|
|
169
|
-
* Debounces rapid addtrack bursts (one per subtitle rendition in the manifest).
|
|
170
|
-
*/
|
|
171
|
-
_attachNativeTextTrackListeners() {
|
|
172
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
173
|
-
|
|
174
|
-
const checkTracks = () => {
|
|
175
|
-
clearTimeout(debounceTimer);
|
|
176
|
-
debounceTimer = setTimeout(() => {
|
|
177
|
-
if (this._nativeTrackListenersDestroyed) return;
|
|
178
|
-
const tracks = this.media.textTracks;
|
|
179
|
-
let count = 0;
|
|
180
|
-
for (let i = 0; i < tracks.length; i++) {
|
|
181
|
-
const k = tracks[i]?.kind;
|
|
182
|
-
if (k === 'subtitles' || k === 'captions') {
|
|
183
|
-
count++;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
this._hlsSubtitleTracksCount = count;
|
|
187
|
-
this.updateCaptionButtonsForHls();
|
|
188
|
-
}, 150);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
this.media.textTracks.addEventListener('addtrack', checkTracks);
|
|
192
|
-
this.media.textTracks.addEventListener('removetrack', checkTracks);
|
|
193
|
-
this.media.addEventListener('loadedmetadata', checkTracks);
|
|
194
|
-
|
|
195
|
-
this._cleanupNativeTextTrackListeners = () => {
|
|
196
|
-
this._nativeTrackListenersDestroyed = true;
|
|
197
|
-
clearTimeout(debounceTimer);
|
|
198
|
-
this.media.textTracks.removeEventListener('addtrack', checkTracks);
|
|
199
|
-
this.media.textTracks.removeEventListener('removetrack', checkTracks);
|
|
200
|
-
this.media.removeEventListener('loadedmetadata', checkTracks);
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async initHlsJs() {
|
|
205
|
-
// Hide native controls
|
|
206
|
-
this.media.controls = false;
|
|
207
|
-
this.media.removeAttribute('controls');
|
|
208
|
-
|
|
209
|
-
// Load hls.js if not already loaded
|
|
210
|
-
if (!window.Hls) {
|
|
211
|
-
await this.loadHlsJs();
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const HlsCtor = window.Hls;
|
|
215
|
-
if (!HlsCtor?.isSupported()) {
|
|
216
|
-
throw new Error('HLS is not supported in this browser');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// HTML5 spec: If video has src attribute, <source> children are not allowed.
|
|
220
|
-
// hls.js sets a blob: URL on the src attribute, so we must remove any <source> elements
|
|
221
|
-
// to maintain valid HTML. Store the original source URL first.
|
|
222
|
-
const sourceElements = Array.from(this.media.querySelectorAll('source'));
|
|
223
|
-
let originalSrc = null;
|
|
224
|
-
if (sourceElements.length > 0) {
|
|
225
|
-
originalSrc = sourceElements[0]?.getAttribute('src') ?? null;
|
|
226
|
-
sourceElements.forEach(source => source.remove());
|
|
227
|
-
this.player.log('Removed <source> elements for HTML5 validity (hls.js uses src attribute)');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Create hls.js instance with better error recovery
|
|
231
|
-
this.hls = new HlsCtor({
|
|
232
|
-
debug: this.player.options.debug,
|
|
233
|
-
// Never let hls.js auto-start segment loading. loadSource() alone fetches
|
|
234
|
-
// the manifest (needed for duration, quality levels, subtitle tracks) but
|
|
235
|
-
// startLoad() is what kicks off media fragment downloads. We defer that
|
|
236
|
-
// to the first play() (or ensureLoaded() for playlists) so paused HLS
|
|
237
|
-
// players don't pre-download the entire stream the way hls.js does by
|
|
238
|
-
// default. This matches dash.js behavior where initialize(media, null, false)
|
|
239
|
-
// only loads the init segment + minimal startup buffer.
|
|
240
|
-
autoStartLoad: false,
|
|
241
|
-
enableWorker: true,
|
|
242
|
-
lowLatencyMode: false,
|
|
243
|
-
backBufferLength: 90,
|
|
244
|
-
// Buffer ceilings tuned to roughly match dash.js defaults so HLS and DASH
|
|
245
|
-
// behave similarly in terms of pre-fetched data:
|
|
246
|
-
// - maxBufferLength (12s) ≈ dash.js bufferTimeDefault: 12
|
|
247
|
-
// - maxMaxBufferLength (60s) ≈ dash.js bufferTimeAtTopQualityLongForm: 60
|
|
248
|
-
// - maxBufferSize (30 MB) — byte cap, hit first on high-bitrate streams.
|
|
249
|
-
// For typical 6s segments this keeps ~2 segments buffered ahead during
|
|
250
|
-
// playback. Combined with stopLoad() on pause(), zero segments are
|
|
251
|
-
// pre-fetched when paused.
|
|
252
|
-
maxBufferLength: 12,
|
|
253
|
-
maxMaxBufferLength: 60,
|
|
254
|
-
maxBufferSize: 30 * 1000 * 1000,
|
|
255
|
-
maxBufferHole: 0.5,
|
|
256
|
-
// Network retry settings
|
|
257
|
-
manifestLoadingTimeOut: 10000,
|
|
258
|
-
manifestLoadingMaxRetry: 4,
|
|
259
|
-
manifestLoadingRetryDelay: 1000,
|
|
260
|
-
manifestLoadingMaxRetryTimeout: 64000,
|
|
261
|
-
levelLoadingTimeOut: 10000,
|
|
262
|
-
levelLoadingMaxRetry: 4,
|
|
263
|
-
levelLoadingRetryDelay: 1000,
|
|
264
|
-
levelLoadingMaxRetryTimeout: 64000,
|
|
265
|
-
fragLoadingTimeOut: 20000,
|
|
266
|
-
fragLoadingMaxRetry: 6,
|
|
267
|
-
fragLoadingRetryDelay: 1000,
|
|
268
|
-
fragLoadingMaxRetryTimeout: 64000
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// Attach media element
|
|
272
|
-
this.hls.attachMedia(this.media);
|
|
273
|
-
|
|
274
|
-
// Load source - use currentSource for external renderers, or get from attribute
|
|
275
|
-
// Priority: currentSource > originalSrc (from removed <source>) > data-vidply-src > src attribute
|
|
276
|
-
let src = this.player.currentSource;
|
|
277
|
-
|
|
278
|
-
if (!src && originalSrc) {
|
|
279
|
-
src = originalSrc;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (!src) {
|
|
283
|
-
// Try data-vidply-src attribute (used by TYPO3 integration)
|
|
284
|
-
src = this.player.element.getAttribute('data-vidply-src');
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!src) {
|
|
288
|
-
// Fallback to element's src attribute (but not blob: URLs)
|
|
289
|
-
const elementSrc = this.player.element.getAttribute('src') || this.player.element.src;
|
|
290
|
-
if (elementSrc && !elementSrc.startsWith('blob:')) {
|
|
291
|
-
src = elementSrc;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
this.player.log(`Loading HLS source: ${src}`, 'log');
|
|
296
|
-
|
|
297
|
-
if (!src) {
|
|
298
|
-
throw new Error('No HLS source found');
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Always load the manifest immediately so duration/quality levels/subtitle
|
|
302
|
-
// tracks are available in the UI before playback. Because autoStartLoad is
|
|
303
|
-
// false, this does NOT trigger media fragment downloads; startLoad() in
|
|
304
|
-
// play() / ensureLoaded() does.
|
|
305
|
-
this._pendingSrc = src;
|
|
306
|
-
this._manifestUrl = src;
|
|
307
|
-
this.hls.loadSource(src);
|
|
308
|
-
this._hlsSourceLoaded = true;
|
|
309
|
-
|
|
310
|
-
// Attach events
|
|
311
|
-
this.attachHlsEvents();
|
|
312
|
-
this.attachMediaEvents();
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Load hls.js. Pinned to an exact version by default (no more `@latest`).
|
|
317
|
-
* Embedders who self-host or who want SRI protection can override via:
|
|
318
|
-
* - `options.hlsScriptUrl` (URL to load from)
|
|
319
|
-
* - `options.hlsScriptIntegrity` (Subresource Integrity hash, e.g.
|
|
320
|
-
* `sha384-XXXX`)
|
|
321
|
-
*
|
|
322
|
-
* Generate the SRI hash with:
|
|
323
|
-
* curl -sSL <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
|
324
|
-
* and prefix with `sha384-`. SRI is opt-in because hash drift would
|
|
325
|
-
* silently break playback for consumers who upgrade hls.js.
|
|
326
|
-
*/
|
|
327
|
-
async loadHlsJs(): Promise<void> {
|
|
328
|
-
const defaultUrl = 'https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js';
|
|
329
|
-
const url: string = (this.player.options.hlsScriptUrl as string | undefined) || defaultUrl;
|
|
330
|
-
const integrity = this.player.options.hlsScriptIntegrity as string | undefined;
|
|
331
|
-
|
|
332
|
-
return loadScriptOnce(url, { integrity });
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
attachHlsEvents() {
|
|
336
|
-
const hls = this.hls;
|
|
337
|
-
const Hls = window.Hls;
|
|
338
|
-
if (!hls || !Hls) return;
|
|
339
|
-
|
|
340
|
-
hls.on(Hls.Events.MANIFEST_PARSED, (...args: unknown[]) => {
|
|
341
|
-
const data = args[1] as HlsManifestParsedData;
|
|
342
|
-
this.player.log('HLS manifest loaded, found ' + data.levels.length + ' quality levels');
|
|
343
|
-
this.player.emit('hlsmanifestparsed', data);
|
|
344
|
-
|
|
345
|
-
// Show VidPly controls (remove external controls class if present)
|
|
346
|
-
if (this.player.container) {
|
|
347
|
-
this.player.container.classList.remove('vidply-external-controls');
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Check for subtitle tracks after manifest parse
|
|
351
|
-
// This handles streams without subtitles (SUBTITLE_TRACKS_UPDATED won't fire for them)
|
|
352
|
-
this._setTimeout(() => {
|
|
353
|
-
if (this._hlsSubtitleTracksCount === undefined || this._hlsSubtitleTracksCount === 0) {
|
|
354
|
-
const currentCount = this.hls?.subtitleTracks?.length || 0;
|
|
355
|
-
if (currentCount === 0) {
|
|
356
|
-
this._hlsSubtitleTracksCount = 0;
|
|
357
|
-
this.updateCaptionButtonsForHls();
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}, 500);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
hls.on(Hls.Events.LEVEL_SWITCHED, (...args: unknown[]) => {
|
|
364
|
-
const data = args[1] as HlsLevelSwitchedData;
|
|
365
|
-
this.player.log('HLS level switched to ' + data.level);
|
|
366
|
-
this.player.emit('hlslevelswitched', data);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
// Handle HLS subtitle tracks
|
|
370
|
-
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (...args: unknown[]) => {
|
|
371
|
-
const data = args[1] as HlsSubtitleTracksUpdatedData;
|
|
372
|
-
this.player.log('HLS subtitle tracks updated, found ' + data.subtitleTracks.length + ' tracks');
|
|
373
|
-
this.player.emit('hlssubtitletracksupdated', data);
|
|
374
|
-
this._hlsSubtitleTracksCount = data.subtitleTracks.length;
|
|
375
|
-
this.updateCaptionButtonsForHls();
|
|
376
|
-
if (data.subtitleTracks.length > 0) {
|
|
377
|
-
this._startCueUpdatePolling();
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (...args: unknown[]) => {
|
|
382
|
-
const data = args[1] as HlsSubtitleTrackSwitchData;
|
|
383
|
-
this.player.log('HLS subtitle track switched to ' + data.id);
|
|
384
|
-
this.player.emit('hlssubtitletrackswitch', data);
|
|
385
|
-
this._lastKnownCueCount = 0;
|
|
386
|
-
this._startCueUpdatePolling();
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
hls.on(Hls.Events.ERROR, (...args: unknown[]) => {
|
|
390
|
-
this.handleHlsError(args[1] as HlsErrorData);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
|
394
|
-
this.player.state.buffering = false;
|
|
395
|
-
|
|
396
|
-
// Seek-only loading: the user scrubbed the seekbar on a paused (or
|
|
397
|
-
// never-played) media element. We only want hls.js to fetch enough
|
|
398
|
-
// fragments to render the seek target frame and then stop, instead of
|
|
399
|
-
// continuing to pre-fetch upcoming segments.
|
|
400
|
-
//
|
|
401
|
-
// FRAG_BUFFERED fires per track (audio and video have separate
|
|
402
|
-
// SourceBuffers in hls.js), so the first event may fire after just the
|
|
403
|
-
// audio fragment is appended — at which point media.buffered does not
|
|
404
|
-
// yet cover the seek position because the video track is still loading.
|
|
405
|
-
// Stopping there would abort the video fetch and leave the player on a
|
|
406
|
-
// frozen frame. Checking _isTimeBuffered(currentTime) makes sure both
|
|
407
|
-
// tracks actually cover the playhead before we call stopLoad.
|
|
408
|
-
//
|
|
409
|
-
// Playback resuming before this fires clears the flag so a later
|
|
410
|
-
// mid-playback fragment doesn't accidentally stop the loader.
|
|
411
|
-
if (!this.media.paused) {
|
|
412
|
-
this._loadingForSeekOnly = false;
|
|
413
|
-
} else if (this._loadingForSeekOnly && this._isTimeBuffered(this.media.currentTime)) {
|
|
414
|
-
this._loadingForSeekOnly = false;
|
|
415
|
-
try {
|
|
416
|
-
hls.stopLoad();
|
|
417
|
-
} catch {
|
|
418
|
-
// ignore
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// Subtitle fragments do NOT go through the media source buffer, so
|
|
424
|
-
// FRAG_BUFFERED is unreliable for them. SUBTITLE_FRAG_PROCESSED fires
|
|
425
|
-
// immediately after hls.js has parsed the WebVTT and appended cues to
|
|
426
|
-
// the TextTrack via `addCueToTrack`, which is exactly when we need to
|
|
427
|
-
// refresh captions/transcript UIs.
|
|
428
|
-
hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, (...args: unknown[]) => {
|
|
429
|
-
const data = args[1] as HlsSubtitleFragProcessedData | undefined;
|
|
430
|
-
if (!data || !data.success) return;
|
|
431
|
-
const count = this._getTotalCueCount();
|
|
432
|
-
if (count > this._lastKnownCueCount) {
|
|
433
|
-
this._lastKnownCueCount = count;
|
|
434
|
-
this.player.emit('textcuesupdate');
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// Some streams (e.g. IMSC1/TTML rendered externally by hls.js) deliver
|
|
439
|
-
// their cues via CUES_PARSED instead of through a native TextTrack. Echo
|
|
440
|
-
// that through to the transcript so it can refresh regardless of the
|
|
441
|
-
// underlying subtitle format.
|
|
442
|
-
hls.on(Hls.Events.CUES_PARSED, () => {
|
|
443
|
-
this.player.emit('textcuesupdate');
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
_getTotalCueCount() {
|
|
448
|
-
const textTracks = this.media.textTracks;
|
|
449
|
-
let total = 0;
|
|
450
|
-
if (!textTracks) return total;
|
|
451
|
-
for (let i = 0; i < textTracks.length; i++) {
|
|
452
|
-
const track = textTracks[i];
|
|
453
|
-
if (track && (track.kind === 'subtitles' || track.kind === 'captions') && track.cues) {
|
|
454
|
-
total += track.cues.length;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return total;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Return true if `time` falls inside any TimeRange the SourceBuffer already
|
|
462
|
-
* holds, with a small tolerance to absorb GOP boundaries. Used by the
|
|
463
|
-
* seeking handler to decide whether to surface a 'waiting' event for the
|
|
464
|
-
* spinner UI.
|
|
465
|
-
*/
|
|
466
|
-
_isTimeBuffered(time: number): boolean {
|
|
467
|
-
const buffered = this.media.buffered;
|
|
468
|
-
if (!buffered || buffered.length === 0) return false;
|
|
469
|
-
const tolerance = 0.25;
|
|
470
|
-
for (let i = 0; i < buffered.length; i++) {
|
|
471
|
-
if (time >= buffered.start(i) - tolerance && time <= buffered.end(i) + tolerance) {
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return false;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
_startCueUpdatePolling() {
|
|
479
|
-
this._stopCueUpdatePolling();
|
|
480
|
-
let prevCueCount = 0;
|
|
481
|
-
let stableRounds = 0;
|
|
482
|
-
|
|
483
|
-
this._cueUpdateTimer = setInterval(() => {
|
|
484
|
-
const count = this._getTotalCueCount();
|
|
485
|
-
|
|
486
|
-
if (count > prevCueCount) {
|
|
487
|
-
prevCueCount = count;
|
|
488
|
-
stableRounds = 0;
|
|
489
|
-
this.player.emit('textcuesupdate');
|
|
490
|
-
} else {
|
|
491
|
-
stableRounds++;
|
|
492
|
-
if (stableRounds >= 8) {
|
|
493
|
-
this._stopCueUpdatePolling();
|
|
494
|
-
if (count > 0) {
|
|
495
|
-
this.player.emit('textcuesupdate');
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}, 500);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
_stopCueUpdatePolling() {
|
|
503
|
-
if (this._cueUpdateTimer) {
|
|
504
|
-
clearInterval(this._cueUpdateTimer);
|
|
505
|
-
this._cueUpdateTimer = null;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Update caption buttons based on HLS subtitle tracks
|
|
511
|
-
* Handles the case where control bar may not exist yet
|
|
512
|
-
*/
|
|
513
|
-
updateCaptionButtonsForHls(retryCount = 0) {
|
|
514
|
-
const tracksCount = this._hlsSubtitleTracksCount || 0;
|
|
515
|
-
|
|
516
|
-
const doUpdate = () => {
|
|
517
|
-
this.player.invalidateTrackCache();
|
|
518
|
-
|
|
519
|
-
if (tracksCount > 0) {
|
|
520
|
-
if (this.player.captionManager) {
|
|
521
|
-
const found = this.player.captionManager.refreshTracks();
|
|
522
|
-
|
|
523
|
-
// hls.js fires SUBTITLE_TRACKS_UPDATED when it knows about the
|
|
524
|
-
// tracks, but the browser TextTrackList may not yet enumerate
|
|
525
|
-
// the new TextTrack objects synchronously. Retry with increasing
|
|
526
|
-
// delays until the tracks appear.
|
|
527
|
-
if (found === 0 && retryCount < 5) {
|
|
528
|
-
const delay = (retryCount + 1) * 200;
|
|
529
|
-
this.player.log(`HLS caption tracks not yet on video element, retrying in ${delay}ms (attempt ${retryCount + 1})`, 'info');
|
|
530
|
-
this._setTimeout(() => {
|
|
531
|
-
this.updateCaptionButtonsForHls(retryCount + 1);
|
|
532
|
-
}, delay);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (this.player.transcriptManager?.isVisible) {
|
|
538
|
-
this.player.transcriptManager.loadTranscriptData();
|
|
539
|
-
this.player.transcriptManager.updateLanguageSelector();
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (this.player.controlBar) {
|
|
543
|
-
this.player.controlBar.ensureCaptionsButton();
|
|
544
|
-
this.player.controlBar.ensureCaptionStyleButton();
|
|
545
|
-
this.player.controlBar.ensureTranscriptButton();
|
|
546
|
-
}
|
|
547
|
-
} else {
|
|
548
|
-
if (this.player.captionManager) {
|
|
549
|
-
this.player.captionManager.refreshTracks();
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (this.player.transcriptManager?.isVisible) {
|
|
553
|
-
this.player.transcriptManager.hideTranscript();
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (this.player.controlBar) {
|
|
557
|
-
this.player.controlBar.removeHlsCaptionButtons(true);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
};
|
|
561
|
-
|
|
562
|
-
if (this.player.controlBar) {
|
|
563
|
-
doUpdate();
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const onReady = () => {
|
|
568
|
-
this.player.off('ready', onReady);
|
|
569
|
-
this._pendingReadyHandler = null;
|
|
570
|
-
doUpdate();
|
|
571
|
-
};
|
|
572
|
-
// Track so destroy() can detach it if 'ready' never fires.
|
|
573
|
-
this._pendingReadyHandler = onReady;
|
|
574
|
-
this.player.on('ready', onReady);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
attachMediaEvents() {
|
|
578
|
-
const { signal } = this._listenerController;
|
|
579
|
-
|
|
580
|
-
// Use same events as HTML5 renderer
|
|
581
|
-
this.media.addEventListener('loadedmetadata', () => {
|
|
582
|
-
this.player.state.duration = this.media.duration;
|
|
583
|
-
this.player.emit('loadedmetadata');
|
|
584
|
-
}, { signal });
|
|
585
|
-
|
|
586
|
-
this.media.addEventListener('durationchange', () => {
|
|
587
|
-
const duration = this.media.duration;
|
|
588
|
-
if (duration && isFinite(duration) && duration > 0) {
|
|
589
|
-
this.player.state.duration = duration;
|
|
590
|
-
this.player.emit('durationchange', duration);
|
|
591
|
-
}
|
|
592
|
-
}, { signal });
|
|
593
|
-
|
|
594
|
-
this.media.addEventListener('play', () => {
|
|
595
|
-
this.player.state.playing = true;
|
|
596
|
-
this.player.state.paused = false;
|
|
597
|
-
this.player.state.ended = false;
|
|
598
|
-
this.player.emit('play');
|
|
599
|
-
|
|
600
|
-
if (this.player.options.onPlay) {
|
|
601
|
-
this.player.options.onPlay.call(this.player);
|
|
602
|
-
}
|
|
603
|
-
}, { signal });
|
|
604
|
-
|
|
605
|
-
this.media.addEventListener('pause', () => {
|
|
606
|
-
this.player.state.playing = false;
|
|
607
|
-
this.player.state.paused = true;
|
|
608
|
-
this.player.emit('pause');
|
|
609
|
-
|
|
610
|
-
if (this.player.options.onPause) {
|
|
611
|
-
this.player.options.onPause.call(this.player);
|
|
612
|
-
}
|
|
613
|
-
}, { signal });
|
|
614
|
-
|
|
615
|
-
this.media.addEventListener('ended', () => {
|
|
616
|
-
this.player.state.playing = false;
|
|
617
|
-
this.player.state.paused = true;
|
|
618
|
-
this.player.state.ended = true;
|
|
619
|
-
this.player.emit('ended');
|
|
620
|
-
|
|
621
|
-
if (this.player.options.onEnded) {
|
|
622
|
-
this.player.options.onEnded.call(this.player);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (this.player.options.loop) {
|
|
626
|
-
this.player.seek(0);
|
|
627
|
-
this.player.play();
|
|
628
|
-
}
|
|
629
|
-
}, { signal });
|
|
630
|
-
|
|
631
|
-
this.media.addEventListener('timeupdate', () => {
|
|
632
|
-
this.player.state.currentTime = this.media.currentTime;
|
|
633
|
-
this.player.emit('timeupdate', this.media.currentTime);
|
|
634
|
-
|
|
635
|
-
if (this.player.options.onTimeUpdate) {
|
|
636
|
-
this.player.options.onTimeUpdate.call(this.player, this.media.currentTime);
|
|
637
|
-
}
|
|
638
|
-
}, { signal });
|
|
639
|
-
|
|
640
|
-
this.media.addEventListener('volumechange', () => {
|
|
641
|
-
this.player.state.volume = this.media.volume;
|
|
642
|
-
this.player.state.muted = this.media.muted;
|
|
643
|
-
this.player.emit('volumechange', this.media.volume);
|
|
644
|
-
}, { signal });
|
|
645
|
-
|
|
646
|
-
this.media.addEventListener('waiting', () => {
|
|
647
|
-
this.player.state.buffering = true;
|
|
648
|
-
this.player.emit('waiting');
|
|
649
|
-
}, { signal });
|
|
650
|
-
|
|
651
|
-
this.media.addEventListener('seeking', () => {
|
|
652
|
-
this.player.state.seeking = true;
|
|
653
|
-
this.player.emit('seeking');
|
|
654
|
-
|
|
655
|
-
// Browsers fire `waiting` when playback can't continue due to missing
|
|
656
|
-
// data, but they DO NOT fire it when seeking on a paused element. With
|
|
657
|
-
// hls.js + stopLoad-on-pause, scrubbing while paused triggers a real
|
|
658
|
-
// segment download, but the spinner stays hidden because no `waiting`
|
|
659
|
-
// event reaches the buffering UI. Detect a seek into an unbuffered
|
|
660
|
-
// range here and surface it as 'waiting' so the spinner appears. The
|
|
661
|
-
// spinner is cleared again by the existing `canplay` / `seeked` paths.
|
|
662
|
-
if (!this._isTimeBuffered(this.media.currentTime)) {
|
|
663
|
-
this.player.state.buffering = true;
|
|
664
|
-
this.player.emit('waiting');
|
|
665
|
-
}
|
|
666
|
-
}, { signal });
|
|
667
|
-
|
|
668
|
-
this.media.addEventListener('seeked', () => {
|
|
669
|
-
this.player.state.seeking = false;
|
|
670
|
-
this.player.emit('seeked');
|
|
671
|
-
|
|
672
|
-
// After a seek finishes while paused, hls.js otherwise keeps fetching
|
|
673
|
-
// subsequent segments to fill maxBufferLength. The user explicitly chose
|
|
674
|
-
// not to play, so calling stopLoad() halts the loader once the segment(s)
|
|
675
|
-
// required to render the new playhead frame are buffered. play() and
|
|
676
|
-
// future seeks re-arm via startLoad(-1).
|
|
677
|
-
if (this.media.paused && this.hls) {
|
|
678
|
-
try {
|
|
679
|
-
this.hls.stopLoad();
|
|
680
|
-
} catch {
|
|
681
|
-
// ignore
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}, { signal });
|
|
685
|
-
|
|
686
|
-
this.media.addEventListener('canplay', () => {
|
|
687
|
-
this.player.state.buffering = false;
|
|
688
|
-
this.player.emit('canplay');
|
|
689
|
-
}, { signal });
|
|
690
|
-
|
|
691
|
-
this.media.addEventListener('error', () => {
|
|
692
|
-
this.player.handleError(this.media.error);
|
|
693
|
-
}, { signal });
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
handleHlsError(data: HlsErrorData) {
|
|
697
|
-
// Log detailed error info
|
|
698
|
-
this.player.log(`HLS Error - Type: ${data.type}, Details: ${data.details}, Fatal: ${data.fatal}`, 'warn');
|
|
699
|
-
if (data.response) {
|
|
700
|
-
this.player.log(`Response code: ${data.response.code}, URL: ${data.response.url}`, 'warn');
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
if (data.fatal) {
|
|
704
|
-
const ErrorTypes = window.Hls?.ErrorTypes;
|
|
705
|
-
switch (data.type) {
|
|
706
|
-
case ErrorTypes?.NETWORK_ERROR:
|
|
707
|
-
this.player.log('Fatal network error, trying to recover...', 'error');
|
|
708
|
-
this.player.log(`Network error details: ${data.details}`, 'error');
|
|
709
|
-
this._setTimeout(() => {
|
|
710
|
-
this.hls?.startLoad();
|
|
711
|
-
}, 1000);
|
|
712
|
-
break;
|
|
713
|
-
|
|
714
|
-
case ErrorTypes?.MEDIA_ERROR:
|
|
715
|
-
this.player.log('Fatal media error, trying to recover...', 'error');
|
|
716
|
-
this.hls?.recoverMediaError();
|
|
717
|
-
break;
|
|
718
|
-
|
|
719
|
-
default:
|
|
720
|
-
this.player.log('Fatal error, cannot recover', 'error');
|
|
721
|
-
this.player.handleError(new Error(`HLS Error: ${data.type} - ${data.details}`));
|
|
722
|
-
this.hls?.destroy();
|
|
723
|
-
break;
|
|
724
|
-
}
|
|
725
|
-
} else {
|
|
726
|
-
this.player.log('Non-fatal HLS error: ' + data.details, 'warn');
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Begin fetching media fragments without starting playback. Used by the
|
|
732
|
-
* playlist manager when a track is selected so playback can start quickly
|
|
733
|
-
* once the user hits play. The manifest was already loaded in initHlsJs();
|
|
734
|
-
* this call is just the equivalent of "press play without playing".
|
|
735
|
-
*/
|
|
736
|
-
ensureLoaded() {
|
|
737
|
-
// Native HLS path delegates to HTML5Renderer; if we got here and have no
|
|
738
|
-
// hls.js instance, there is nothing to do.
|
|
739
|
-
if (!this.hls) {
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (this._didDeferredLoad) {
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
try {
|
|
748
|
-
this.hls.startLoad(-1);
|
|
749
|
-
} catch {
|
|
750
|
-
// ignore
|
|
751
|
-
}
|
|
752
|
-
this._didDeferredLoad = true;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
play() {
|
|
756
|
-
// Save scroll position to prevent browser from scrolling to video
|
|
757
|
-
const scrollX = window.scrollX;
|
|
758
|
-
const scrollY = window.scrollY;
|
|
759
|
-
|
|
760
|
-
// (Re)start segment loading on every play. Cheap when already loading
|
|
761
|
-
// (hls.js no-ops); needed after pause() called stopLoad() so the buffer
|
|
762
|
-
// resumes filling. Passing -1 lets hls.js pick the starting position from
|
|
763
|
-
// the current playhead instead of forcing one. Clearing the seek-only
|
|
764
|
-
// flag here ensures FRAG_BUFFERED does not stop the loader mid-playback.
|
|
765
|
-
if (this.hls) {
|
|
766
|
-
this._loadingForSeekOnly = false;
|
|
767
|
-
try {
|
|
768
|
-
this.hls.startLoad(-1);
|
|
769
|
-
} catch {
|
|
770
|
-
// ignore and let media.play() surface errors if any
|
|
771
|
-
}
|
|
772
|
-
this._didDeferredLoad = true;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const promise = this.media.play();
|
|
776
|
-
|
|
777
|
-
// Restore scroll position immediately to prevent auto-scroll
|
|
778
|
-
window.scrollTo(scrollX, scrollY);
|
|
779
|
-
|
|
780
|
-
if (promise !== undefined) {
|
|
781
|
-
promise.catch(error => {
|
|
782
|
-
this.player.log('Play failed:', error, 'warn');
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
pause() {
|
|
788
|
-
this.media.pause();
|
|
789
|
-
// Stop downloading further segments while paused. Already buffered data
|
|
790
|
-
// stays in the SourceBuffer so resume is instant for short pauses; longer
|
|
791
|
-
// pauses (or stop() = pause() + seek(0)) save bandwidth by not pre-fetching
|
|
792
|
-
// content the user may never watch. play() / seek() re-arm via startLoad().
|
|
793
|
-
if (this.hls) {
|
|
794
|
-
try {
|
|
795
|
-
this.hls.stopLoad();
|
|
796
|
-
} catch {
|
|
797
|
-
// ignore
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
seek(time: number) {
|
|
803
|
-
this.media.currentTime = time;
|
|
804
|
-
// If we're paused with the loader stopped and the user seeks outside the
|
|
805
|
-
// currently buffered range, hls.js needs to fetch new fragments to render
|
|
806
|
-
// the target frame. Calling startLoad(-1) here is safe regardless: when
|
|
807
|
-
// already loading, hls.js no-ops; when stopped, it resumes from the new
|
|
808
|
-
// currentTime so the seek shows a real frame instead of a frozen one.
|
|
809
|
-
// The _loadingForSeekOnly flag tells FRAG_BUFFERED to stop the loader
|
|
810
|
-
// again once the target segment is buffered, so paused/never-played
|
|
811
|
-
// players don't keep pre-fetching beyond the seek point.
|
|
812
|
-
if (this.hls) {
|
|
813
|
-
if (this.media.paused) {
|
|
814
|
-
this._loadingForSeekOnly = true;
|
|
815
|
-
}
|
|
816
|
-
try {
|
|
817
|
-
this.hls.startLoad(-1);
|
|
818
|
-
} catch {
|
|
819
|
-
// ignore
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
setVolume(volume: number) {
|
|
825
|
-
this.media.volume = volume;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
setMuted(muted: boolean) {
|
|
829
|
-
this.media.muted = muted;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
setPlaybackSpeed(speed: number) {
|
|
833
|
-
this.media.playbackRate = speed;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
switchQuality(levelIndex: number) {
|
|
837
|
-
if (this.hls) {
|
|
838
|
-
this.hls.currentLevel = levelIndex;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
getQualities(): QualityLevel[] {
|
|
843
|
-
if (this.hls && this.hls.levels) {
|
|
844
|
-
// hls.js creates separate levels for each video+audio-group combination,
|
|
845
|
-
// producing duplicates (e.g. three "720p" entries). Deduplicate by
|
|
846
|
-
// resolution height, keeping the entry with the highest bitrate per height.
|
|
847
|
-
type LevelEntry = { index: number; height: number; width: number; bitrate: number; level: HlsLevel };
|
|
848
|
-
const byHeight = new Map<string | number, LevelEntry>();
|
|
849
|
-
|
|
850
|
-
this.hls.levels.forEach((level: HlsLevel, index: number) => {
|
|
851
|
-
const height = Number(level.height) || 0;
|
|
852
|
-
const bitrate = Number(level.bitrate) || 0;
|
|
853
|
-
const key: string | number = height > 0 ? height : `br_${bitrate}`;
|
|
854
|
-
const existing = byHeight.get(key);
|
|
855
|
-
|
|
856
|
-
if (!existing || bitrate > (existing.bitrate || 0)) {
|
|
857
|
-
byHeight.set(key, { index, height: level.height, width: level.width, bitrate, level });
|
|
858
|
-
}
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
return Array.from(byHeight.values()).map((entry) => {
|
|
862
|
-
const height = Number(entry.height) || 0;
|
|
863
|
-
const kb = entry.bitrate > 0 ? Math.round(entry.bitrate / 1000) : 0;
|
|
864
|
-
const name = height > 0 ? `${height}p` : (kb > 0 ? `${kb} kb` : 'Auto');
|
|
865
|
-
return { index: entry.index, height: entry.height, width: entry.width, bitrate: entry.bitrate, name };
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
return [];
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
getCurrentQuality() {
|
|
872
|
-
if (this.hls) {
|
|
873
|
-
return this.hls.currentLevel;
|
|
874
|
-
}
|
|
875
|
-
return -1;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
activateTextTrackForLanguage(lang: string): boolean {
|
|
879
|
-
if (!this.hls || !lang) return false;
|
|
880
|
-
|
|
881
|
-
const tracks = this.hls.subtitleTracks;
|
|
882
|
-
if (!tracks || tracks.length === 0) return false;
|
|
883
|
-
|
|
884
|
-
const idx = tracks.findIndex((t: HlsSubtitleTrack) => {
|
|
885
|
-
const tLang = t.lang || t.language || '';
|
|
886
|
-
return tLang === lang || tLang.startsWith(lang) || lang.startsWith(tLang);
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
if (idx < 0) return false;
|
|
890
|
-
|
|
891
|
-
this.player.log(`Activating HLS subtitle track index ${idx} for language "${lang}"`);
|
|
892
|
-
this.hls.subtitleTrack = idx;
|
|
893
|
-
|
|
894
|
-
this._lastKnownCueCount = 0;
|
|
895
|
-
this._startCueUpdatePolling();
|
|
896
|
-
return true;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
getTextTrackURLs(): { lang: string; url: string }[] {
|
|
900
|
-
if (!this.hls || !this._manifestUrl) return [];
|
|
901
|
-
try {
|
|
902
|
-
const tracks = this.hls.subtitleTracks;
|
|
903
|
-
if (!tracks || tracks.length === 0) return [];
|
|
904
|
-
|
|
905
|
-
const results: { lang: string; url: string }[] = [];
|
|
906
|
-
for (const track of tracks) {
|
|
907
|
-
const lang = track.lang || track.language || '';
|
|
908
|
-
const playlistUrl = track.url;
|
|
909
|
-
if (!lang || !playlistUrl) continue;
|
|
910
|
-
results.push({ lang, url: playlistUrl });
|
|
911
|
-
}
|
|
912
|
-
return results;
|
|
913
|
-
} catch {
|
|
914
|
-
return [];
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
supportsAutoQuality() {
|
|
919
|
-
return true;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
isAutoQuality() {
|
|
923
|
-
return this.hls?.currentLevel === -1;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
destroy() {
|
|
927
|
-
this._stopCueUpdatePolling();
|
|
928
|
-
this._clearTimers();
|
|
929
|
-
// Detach the media listeners registered in attachMediaEvents().
|
|
930
|
-
this._listenerController.abort();
|
|
931
|
-
// Remove the deferred 'ready' listener if it never fired.
|
|
932
|
-
if (this._pendingReadyHandler) {
|
|
933
|
-
this.player.off('ready', this._pendingReadyHandler);
|
|
934
|
-
this._pendingReadyHandler = null;
|
|
935
|
-
}
|
|
936
|
-
this._lastKnownCueCount = 0;
|
|
937
|
-
this._manifestUrl = null;
|
|
938
|
-
if (this.hls) {
|
|
939
|
-
this.hls.destroy();
|
|
940
|
-
this.hls = null;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
1
|
+
import type { Renderer, QualityLevel } from '../types/renderer.js';
|
|
2
|
+
import type { Player } from '../core/Player.js';
|
|
3
|
+
import { loadScriptOnce } from '../utils/ScriptLoader.js';
|
|
4
|
+
|
|
5
|
+
/** Subset of payloads emitted by hls.js events that we actually consume. */
|
|
6
|
+
interface HlsManifestParsedData {
|
|
7
|
+
levels: HlsLevel[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface HlsLevelSwitchedData {
|
|
11
|
+
level: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface HlsSubtitleTracksUpdatedData {
|
|
15
|
+
subtitleTracks: HlsSubtitleTrack[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface HlsSubtitleTrackSwitchData {
|
|
19
|
+
id: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface HlsSubtitleFragProcessedData {
|
|
23
|
+
success?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class HLSRenderer implements Renderer {
|
|
27
|
+
player: Player;
|
|
28
|
+
media: HTMLMediaElement;
|
|
29
|
+
hls: HlsInstance | null;
|
|
30
|
+
|
|
31
|
+
// True once hls.js is driving playback via MSE. Native HLS playback on
|
|
32
|
+
// iOS / iPadOS keeps a real HTTP URL on the <video> and does not need the
|
|
33
|
+
// streaming path to be forced.
|
|
34
|
+
get isStreaming(): boolean {
|
|
35
|
+
return this.hls !== null && this.hls !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_hlsSourceLoaded: boolean;
|
|
39
|
+
_pendingSrc: string | null;
|
|
40
|
+
_hlsSubtitleTracksCount: number | undefined;
|
|
41
|
+
_cueUpdateTimer: ReturnType<typeof setInterval> | null;
|
|
42
|
+
_lastKnownCueCount: number;
|
|
43
|
+
_nativeTrackListenersDestroyed?: boolean;
|
|
44
|
+
_didDeferredLoad?: boolean;
|
|
45
|
+
_manifestUrl: string | null;
|
|
46
|
+
/**
|
|
47
|
+
* True when the most recent startLoad() call was triggered by a seek on a
|
|
48
|
+
* paused media element (not by play()). The FRAG_BUFFERED handler uses this
|
|
49
|
+
* to call stopLoad() once the seek target is buffered, so hls.js does not
|
|
50
|
+
* keep pre-fetching subsequent segments while the user is still paused.
|
|
51
|
+
*/
|
|
52
|
+
_loadingForSeekOnly?: boolean;
|
|
53
|
+
_cleanupNativeTextTrackListeners: () => void;
|
|
54
|
+
// Detaches all hls.js-path media listeners (attachMediaEvents) in destroy().
|
|
55
|
+
private _listenerController: AbortController;
|
|
56
|
+
// Pending setTimeout ids so destroy() can cancel retries that would
|
|
57
|
+
// otherwise fire (and touch a torn-down player) after teardown.
|
|
58
|
+
private _timers: Set<ReturnType<typeof setTimeout>>;
|
|
59
|
+
// Tracked 'ready' listener registered by updateCaptionButtonsForHls when the
|
|
60
|
+
// control bar isn't built yet; removed on destroy if it never fired.
|
|
61
|
+
private _pendingReadyHandler: (() => void) | null;
|
|
62
|
+
|
|
63
|
+
constructor(player: Player) {
|
|
64
|
+
this.player = player;
|
|
65
|
+
this.media = player.element;
|
|
66
|
+
this.hls = null;
|
|
67
|
+
this._hlsSourceLoaded = false;
|
|
68
|
+
this._pendingSrc = null;
|
|
69
|
+
this._hlsSubtitleTracksCount = undefined;
|
|
70
|
+
this._cueUpdateTimer = null;
|
|
71
|
+
this._lastKnownCueCount = 0;
|
|
72
|
+
this._manifestUrl = null;
|
|
73
|
+
this._cleanupNativeTextTrackListeners = () => {};
|
|
74
|
+
this._listenerController = new AbortController();
|
|
75
|
+
this._timers = new Set();
|
|
76
|
+
this._pendingReadyHandler = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Schedule a timeout that is automatically cancelled by destroy(). Prevents
|
|
81
|
+
* caption-retry / error-recovery callbacks from running after teardown.
|
|
82
|
+
*/
|
|
83
|
+
private _setTimeout(handler: () => void, ms: number): void {
|
|
84
|
+
const id = setTimeout(() => {
|
|
85
|
+
this._timers.delete(id);
|
|
86
|
+
handler();
|
|
87
|
+
}, ms);
|
|
88
|
+
this._timers.add(id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private _clearTimers(): void {
|
|
92
|
+
for (const id of this._timers) {
|
|
93
|
+
clearTimeout(id);
|
|
94
|
+
}
|
|
95
|
+
this._timers.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async init() {
|
|
99
|
+
// Check if browser natively supports HLS (Safari)
|
|
100
|
+
if (this.canPlayNatively()) {
|
|
101
|
+
this.player.log('Using native HLS support');
|
|
102
|
+
await this.initNative();
|
|
103
|
+
} else {
|
|
104
|
+
this.player.log('Using hls.js for HLS support');
|
|
105
|
+
await this.initHlsJs();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
canPlayNatively() {
|
|
110
|
+
// Use native HLS only on iOS/iPadOS (MSE unavailable); desktop macOS Safari uses hls.js
|
|
111
|
+
// for quality switching and parity with Chrome/Firefox.
|
|
112
|
+
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
|
113
|
+
// iPad in desktop mode reports MacIntel but has touch
|
|
114
|
+
const isIPadDesktopMode = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
|
115
|
+
|
|
116
|
+
if (!isIOS && !isIPadDesktopMode) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const video = document.createElement('video');
|
|
121
|
+
return video.canPlayType('application/vnd.apple.mpegurl') !== '';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async initNative() {
|
|
125
|
+
// Native HLS (iOS / iPadOS): the <video> element plays the HLS URL
|
|
126
|
+
// directly, so we delegate the playback surface to an HTML5Renderer via
|
|
127
|
+
// composition rather than grafting its prototype methods onto this
|
|
128
|
+
// instance. hls.js is never created on this path (this.hls stays null),
|
|
129
|
+
// and this.media already references the same player.element the delegate
|
|
130
|
+
// drives, so quality/caption inspection keeps working.
|
|
131
|
+
const { HTML5Renderer } = await import('./HTML5Renderer.js');
|
|
132
|
+
const native = new HTML5Renderer(this.player);
|
|
133
|
+
await native.init();
|
|
134
|
+
|
|
135
|
+
// Forward the externally-invoked Renderer surface to the delegate. The
|
|
136
|
+
// delegate's own internal helpers (attachEvents, pauseOtherPlayers, …)
|
|
137
|
+
// run via these calls, so they don't need explicit forwarding. HLS-only
|
|
138
|
+
// methods (attachHlsEvents, handleHlsError, supportsAutoQuality, …) keep
|
|
139
|
+
// their HLSRenderer implementations.
|
|
140
|
+
this.play = () => native.play();
|
|
141
|
+
this.pause = () => native.pause();
|
|
142
|
+
this.seek = (time: number) => native.seek(time);
|
|
143
|
+
this.setVolume = (volume: number) => native.setVolume(volume);
|
|
144
|
+
this.setMuted = (muted: boolean) => native.setMuted(muted);
|
|
145
|
+
this.setPlaybackSpeed = (speed: number) => native.setPlaybackSpeed(speed);
|
|
146
|
+
this.ensureLoaded = () => native.ensureLoaded?.();
|
|
147
|
+
this.getQualities = () => native.getQualities?.() ?? [];
|
|
148
|
+
this.switchQuality = (index: number) => native.switchQuality?.(index);
|
|
149
|
+
this.getCurrentQuality = () => native.getCurrentQuality?.() ?? 0;
|
|
150
|
+
|
|
151
|
+
this._attachNativeTextTrackListeners();
|
|
152
|
+
|
|
153
|
+
// destroy() delegates to the native renderer but also tears down the
|
|
154
|
+
// native text-track listeners, caption-retry timers and any deferred
|
|
155
|
+
// 'ready' handler scheduled by updateCaptionButtonsForHls.
|
|
156
|
+
this.destroy = () => {
|
|
157
|
+
this._cleanupNativeTextTrackListeners();
|
|
158
|
+
this._clearTimers();
|
|
159
|
+
if (this._pendingReadyHandler) {
|
|
160
|
+
this.player.off('ready', this._pendingReadyHandler);
|
|
161
|
+
this._pendingReadyHandler = null;
|
|
162
|
+
}
|
|
163
|
+
native.destroy();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Listen for HLS-exposed text tracks so captions/transcript buttons appear on native HLS.
|
|
169
|
+
* Debounces rapid addtrack bursts (one per subtitle rendition in the manifest).
|
|
170
|
+
*/
|
|
171
|
+
_attachNativeTextTrackListeners() {
|
|
172
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
173
|
+
|
|
174
|
+
const checkTracks = () => {
|
|
175
|
+
clearTimeout(debounceTimer);
|
|
176
|
+
debounceTimer = setTimeout(() => {
|
|
177
|
+
if (this._nativeTrackListenersDestroyed) return;
|
|
178
|
+
const tracks = this.media.textTracks;
|
|
179
|
+
let count = 0;
|
|
180
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
181
|
+
const k = tracks[i]?.kind;
|
|
182
|
+
if (k === 'subtitles' || k === 'captions') {
|
|
183
|
+
count++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this._hlsSubtitleTracksCount = count;
|
|
187
|
+
this.updateCaptionButtonsForHls();
|
|
188
|
+
}, 150);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
this.media.textTracks.addEventListener('addtrack', checkTracks);
|
|
192
|
+
this.media.textTracks.addEventListener('removetrack', checkTracks);
|
|
193
|
+
this.media.addEventListener('loadedmetadata', checkTracks);
|
|
194
|
+
|
|
195
|
+
this._cleanupNativeTextTrackListeners = () => {
|
|
196
|
+
this._nativeTrackListenersDestroyed = true;
|
|
197
|
+
clearTimeout(debounceTimer);
|
|
198
|
+
this.media.textTracks.removeEventListener('addtrack', checkTracks);
|
|
199
|
+
this.media.textTracks.removeEventListener('removetrack', checkTracks);
|
|
200
|
+
this.media.removeEventListener('loadedmetadata', checkTracks);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async initHlsJs() {
|
|
205
|
+
// Hide native controls
|
|
206
|
+
this.media.controls = false;
|
|
207
|
+
this.media.removeAttribute('controls');
|
|
208
|
+
|
|
209
|
+
// Load hls.js if not already loaded
|
|
210
|
+
if (!window.Hls) {
|
|
211
|
+
await this.loadHlsJs();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const HlsCtor = window.Hls;
|
|
215
|
+
if (!HlsCtor?.isSupported()) {
|
|
216
|
+
throw new Error('HLS is not supported in this browser');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// HTML5 spec: If video has src attribute, <source> children are not allowed.
|
|
220
|
+
// hls.js sets a blob: URL on the src attribute, so we must remove any <source> elements
|
|
221
|
+
// to maintain valid HTML. Store the original source URL first.
|
|
222
|
+
const sourceElements = Array.from(this.media.querySelectorAll('source'));
|
|
223
|
+
let originalSrc = null;
|
|
224
|
+
if (sourceElements.length > 0) {
|
|
225
|
+
originalSrc = sourceElements[0]?.getAttribute('src') ?? null;
|
|
226
|
+
sourceElements.forEach(source => source.remove());
|
|
227
|
+
this.player.log('Removed <source> elements for HTML5 validity (hls.js uses src attribute)');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Create hls.js instance with better error recovery
|
|
231
|
+
this.hls = new HlsCtor({
|
|
232
|
+
debug: this.player.options.debug,
|
|
233
|
+
// Never let hls.js auto-start segment loading. loadSource() alone fetches
|
|
234
|
+
// the manifest (needed for duration, quality levels, subtitle tracks) but
|
|
235
|
+
// startLoad() is what kicks off media fragment downloads. We defer that
|
|
236
|
+
// to the first play() (or ensureLoaded() for playlists) so paused HLS
|
|
237
|
+
// players don't pre-download the entire stream the way hls.js does by
|
|
238
|
+
// default. This matches dash.js behavior where initialize(media, null, false)
|
|
239
|
+
// only loads the init segment + minimal startup buffer.
|
|
240
|
+
autoStartLoad: false,
|
|
241
|
+
enableWorker: true,
|
|
242
|
+
lowLatencyMode: false,
|
|
243
|
+
backBufferLength: 90,
|
|
244
|
+
// Buffer ceilings tuned to roughly match dash.js defaults so HLS and DASH
|
|
245
|
+
// behave similarly in terms of pre-fetched data:
|
|
246
|
+
// - maxBufferLength (12s) ≈ dash.js bufferTimeDefault: 12
|
|
247
|
+
// - maxMaxBufferLength (60s) ≈ dash.js bufferTimeAtTopQualityLongForm: 60
|
|
248
|
+
// - maxBufferSize (30 MB) — byte cap, hit first on high-bitrate streams.
|
|
249
|
+
// For typical 6s segments this keeps ~2 segments buffered ahead during
|
|
250
|
+
// playback. Combined with stopLoad() on pause(), zero segments are
|
|
251
|
+
// pre-fetched when paused.
|
|
252
|
+
maxBufferLength: 12,
|
|
253
|
+
maxMaxBufferLength: 60,
|
|
254
|
+
maxBufferSize: 30 * 1000 * 1000,
|
|
255
|
+
maxBufferHole: 0.5,
|
|
256
|
+
// Network retry settings
|
|
257
|
+
manifestLoadingTimeOut: 10000,
|
|
258
|
+
manifestLoadingMaxRetry: 4,
|
|
259
|
+
manifestLoadingRetryDelay: 1000,
|
|
260
|
+
manifestLoadingMaxRetryTimeout: 64000,
|
|
261
|
+
levelLoadingTimeOut: 10000,
|
|
262
|
+
levelLoadingMaxRetry: 4,
|
|
263
|
+
levelLoadingRetryDelay: 1000,
|
|
264
|
+
levelLoadingMaxRetryTimeout: 64000,
|
|
265
|
+
fragLoadingTimeOut: 20000,
|
|
266
|
+
fragLoadingMaxRetry: 6,
|
|
267
|
+
fragLoadingRetryDelay: 1000,
|
|
268
|
+
fragLoadingMaxRetryTimeout: 64000
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Attach media element
|
|
272
|
+
this.hls.attachMedia(this.media);
|
|
273
|
+
|
|
274
|
+
// Load source - use currentSource for external renderers, or get from attribute
|
|
275
|
+
// Priority: currentSource > originalSrc (from removed <source>) > data-vidply-src > src attribute
|
|
276
|
+
let src = this.player.currentSource;
|
|
277
|
+
|
|
278
|
+
if (!src && originalSrc) {
|
|
279
|
+
src = originalSrc;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!src) {
|
|
283
|
+
// Try data-vidply-src attribute (used by TYPO3 integration)
|
|
284
|
+
src = this.player.element.getAttribute('data-vidply-src');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!src) {
|
|
288
|
+
// Fallback to element's src attribute (but not blob: URLs)
|
|
289
|
+
const elementSrc = this.player.element.getAttribute('src') || this.player.element.src;
|
|
290
|
+
if (elementSrc && !elementSrc.startsWith('blob:')) {
|
|
291
|
+
src = elementSrc;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.player.log(`Loading HLS source: ${src}`, 'log');
|
|
296
|
+
|
|
297
|
+
if (!src) {
|
|
298
|
+
throw new Error('No HLS source found');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Always load the manifest immediately so duration/quality levels/subtitle
|
|
302
|
+
// tracks are available in the UI before playback. Because autoStartLoad is
|
|
303
|
+
// false, this does NOT trigger media fragment downloads; startLoad() in
|
|
304
|
+
// play() / ensureLoaded() does.
|
|
305
|
+
this._pendingSrc = src;
|
|
306
|
+
this._manifestUrl = src;
|
|
307
|
+
this.hls.loadSource(src);
|
|
308
|
+
this._hlsSourceLoaded = true;
|
|
309
|
+
|
|
310
|
+
// Attach events
|
|
311
|
+
this.attachHlsEvents();
|
|
312
|
+
this.attachMediaEvents();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load hls.js. Pinned to an exact version by default (no more `@latest`).
|
|
317
|
+
* Embedders who self-host or who want SRI protection can override via:
|
|
318
|
+
* - `options.hlsScriptUrl` (URL to load from)
|
|
319
|
+
* - `options.hlsScriptIntegrity` (Subresource Integrity hash, e.g.
|
|
320
|
+
* `sha384-XXXX`)
|
|
321
|
+
*
|
|
322
|
+
* Generate the SRI hash with:
|
|
323
|
+
* curl -sSL <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
|
324
|
+
* and prefix with `sha384-`. SRI is opt-in because hash drift would
|
|
325
|
+
* silently break playback for consumers who upgrade hls.js.
|
|
326
|
+
*/
|
|
327
|
+
async loadHlsJs(): Promise<void> {
|
|
328
|
+
const defaultUrl = 'https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js';
|
|
329
|
+
const url: string = (this.player.options.hlsScriptUrl as string | undefined) || defaultUrl;
|
|
330
|
+
const integrity = this.player.options.hlsScriptIntegrity as string | undefined;
|
|
331
|
+
|
|
332
|
+
return loadScriptOnce(url, { integrity });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
attachHlsEvents() {
|
|
336
|
+
const hls = this.hls;
|
|
337
|
+
const Hls = window.Hls;
|
|
338
|
+
if (!hls || !Hls) return;
|
|
339
|
+
|
|
340
|
+
hls.on(Hls.Events.MANIFEST_PARSED, (...args: unknown[]) => {
|
|
341
|
+
const data = args[1] as HlsManifestParsedData;
|
|
342
|
+
this.player.log('HLS manifest loaded, found ' + data.levels.length + ' quality levels');
|
|
343
|
+
this.player.emit('hlsmanifestparsed', data);
|
|
344
|
+
|
|
345
|
+
// Show VidPly controls (remove external controls class if present)
|
|
346
|
+
if (this.player.container) {
|
|
347
|
+
this.player.container.classList.remove('vidply-external-controls');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check for subtitle tracks after manifest parse
|
|
351
|
+
// This handles streams without subtitles (SUBTITLE_TRACKS_UPDATED won't fire for them)
|
|
352
|
+
this._setTimeout(() => {
|
|
353
|
+
if (this._hlsSubtitleTracksCount === undefined || this._hlsSubtitleTracksCount === 0) {
|
|
354
|
+
const currentCount = this.hls?.subtitleTracks?.length || 0;
|
|
355
|
+
if (currentCount === 0) {
|
|
356
|
+
this._hlsSubtitleTracksCount = 0;
|
|
357
|
+
this.updateCaptionButtonsForHls();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}, 500);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
hls.on(Hls.Events.LEVEL_SWITCHED, (...args: unknown[]) => {
|
|
364
|
+
const data = args[1] as HlsLevelSwitchedData;
|
|
365
|
+
this.player.log('HLS level switched to ' + data.level);
|
|
366
|
+
this.player.emit('hlslevelswitched', data);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Handle HLS subtitle tracks
|
|
370
|
+
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (...args: unknown[]) => {
|
|
371
|
+
const data = args[1] as HlsSubtitleTracksUpdatedData;
|
|
372
|
+
this.player.log('HLS subtitle tracks updated, found ' + data.subtitleTracks.length + ' tracks');
|
|
373
|
+
this.player.emit('hlssubtitletracksupdated', data);
|
|
374
|
+
this._hlsSubtitleTracksCount = data.subtitleTracks.length;
|
|
375
|
+
this.updateCaptionButtonsForHls();
|
|
376
|
+
if (data.subtitleTracks.length > 0) {
|
|
377
|
+
this._startCueUpdatePolling();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (...args: unknown[]) => {
|
|
382
|
+
const data = args[1] as HlsSubtitleTrackSwitchData;
|
|
383
|
+
this.player.log('HLS subtitle track switched to ' + data.id);
|
|
384
|
+
this.player.emit('hlssubtitletrackswitch', data);
|
|
385
|
+
this._lastKnownCueCount = 0;
|
|
386
|
+
this._startCueUpdatePolling();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
hls.on(Hls.Events.ERROR, (...args: unknown[]) => {
|
|
390
|
+
this.handleHlsError(args[1] as HlsErrorData);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
|
394
|
+
this.player.state.buffering = false;
|
|
395
|
+
|
|
396
|
+
// Seek-only loading: the user scrubbed the seekbar on a paused (or
|
|
397
|
+
// never-played) media element. We only want hls.js to fetch enough
|
|
398
|
+
// fragments to render the seek target frame and then stop, instead of
|
|
399
|
+
// continuing to pre-fetch upcoming segments.
|
|
400
|
+
//
|
|
401
|
+
// FRAG_BUFFERED fires per track (audio and video have separate
|
|
402
|
+
// SourceBuffers in hls.js), so the first event may fire after just the
|
|
403
|
+
// audio fragment is appended — at which point media.buffered does not
|
|
404
|
+
// yet cover the seek position because the video track is still loading.
|
|
405
|
+
// Stopping there would abort the video fetch and leave the player on a
|
|
406
|
+
// frozen frame. Checking _isTimeBuffered(currentTime) makes sure both
|
|
407
|
+
// tracks actually cover the playhead before we call stopLoad.
|
|
408
|
+
//
|
|
409
|
+
// Playback resuming before this fires clears the flag so a later
|
|
410
|
+
// mid-playback fragment doesn't accidentally stop the loader.
|
|
411
|
+
if (!this.media.paused) {
|
|
412
|
+
this._loadingForSeekOnly = false;
|
|
413
|
+
} else if (this._loadingForSeekOnly && this._isTimeBuffered(this.media.currentTime)) {
|
|
414
|
+
this._loadingForSeekOnly = false;
|
|
415
|
+
try {
|
|
416
|
+
hls.stopLoad();
|
|
417
|
+
} catch {
|
|
418
|
+
// ignore
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Subtitle fragments do NOT go through the media source buffer, so
|
|
424
|
+
// FRAG_BUFFERED is unreliable for them. SUBTITLE_FRAG_PROCESSED fires
|
|
425
|
+
// immediately after hls.js has parsed the WebVTT and appended cues to
|
|
426
|
+
// the TextTrack via `addCueToTrack`, which is exactly when we need to
|
|
427
|
+
// refresh captions/transcript UIs.
|
|
428
|
+
hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, (...args: unknown[]) => {
|
|
429
|
+
const data = args[1] as HlsSubtitleFragProcessedData | undefined;
|
|
430
|
+
if (!data || !data.success) return;
|
|
431
|
+
const count = this._getTotalCueCount();
|
|
432
|
+
if (count > this._lastKnownCueCount) {
|
|
433
|
+
this._lastKnownCueCount = count;
|
|
434
|
+
this.player.emit('textcuesupdate');
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Some streams (e.g. IMSC1/TTML rendered externally by hls.js) deliver
|
|
439
|
+
// their cues via CUES_PARSED instead of through a native TextTrack. Echo
|
|
440
|
+
// that through to the transcript so it can refresh regardless of the
|
|
441
|
+
// underlying subtitle format.
|
|
442
|
+
hls.on(Hls.Events.CUES_PARSED, () => {
|
|
443
|
+
this.player.emit('textcuesupdate');
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_getTotalCueCount() {
|
|
448
|
+
const textTracks = this.media.textTracks;
|
|
449
|
+
let total = 0;
|
|
450
|
+
if (!textTracks) return total;
|
|
451
|
+
for (let i = 0; i < textTracks.length; i++) {
|
|
452
|
+
const track = textTracks[i];
|
|
453
|
+
if (track && (track.kind === 'subtitles' || track.kind === 'captions') && track.cues) {
|
|
454
|
+
total += track.cues.length;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return total;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Return true if `time` falls inside any TimeRange the SourceBuffer already
|
|
462
|
+
* holds, with a small tolerance to absorb GOP boundaries. Used by the
|
|
463
|
+
* seeking handler to decide whether to surface a 'waiting' event for the
|
|
464
|
+
* spinner UI.
|
|
465
|
+
*/
|
|
466
|
+
_isTimeBuffered(time: number): boolean {
|
|
467
|
+
const buffered = this.media.buffered;
|
|
468
|
+
if (!buffered || buffered.length === 0) return false;
|
|
469
|
+
const tolerance = 0.25;
|
|
470
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
471
|
+
if (time >= buffered.start(i) - tolerance && time <= buffered.end(i) + tolerance) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_startCueUpdatePolling() {
|
|
479
|
+
this._stopCueUpdatePolling();
|
|
480
|
+
let prevCueCount = 0;
|
|
481
|
+
let stableRounds = 0;
|
|
482
|
+
|
|
483
|
+
this._cueUpdateTimer = setInterval(() => {
|
|
484
|
+
const count = this._getTotalCueCount();
|
|
485
|
+
|
|
486
|
+
if (count > prevCueCount) {
|
|
487
|
+
prevCueCount = count;
|
|
488
|
+
stableRounds = 0;
|
|
489
|
+
this.player.emit('textcuesupdate');
|
|
490
|
+
} else {
|
|
491
|
+
stableRounds++;
|
|
492
|
+
if (stableRounds >= 8) {
|
|
493
|
+
this._stopCueUpdatePolling();
|
|
494
|
+
if (count > 0) {
|
|
495
|
+
this.player.emit('textcuesupdate');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}, 500);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
_stopCueUpdatePolling() {
|
|
503
|
+
if (this._cueUpdateTimer) {
|
|
504
|
+
clearInterval(this._cueUpdateTimer);
|
|
505
|
+
this._cueUpdateTimer = null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Update caption buttons based on HLS subtitle tracks
|
|
511
|
+
* Handles the case where control bar may not exist yet
|
|
512
|
+
*/
|
|
513
|
+
updateCaptionButtonsForHls(retryCount = 0) {
|
|
514
|
+
const tracksCount = this._hlsSubtitleTracksCount || 0;
|
|
515
|
+
|
|
516
|
+
const doUpdate = () => {
|
|
517
|
+
this.player.invalidateTrackCache();
|
|
518
|
+
|
|
519
|
+
if (tracksCount > 0) {
|
|
520
|
+
if (this.player.captionManager) {
|
|
521
|
+
const found = this.player.captionManager.refreshTracks();
|
|
522
|
+
|
|
523
|
+
// hls.js fires SUBTITLE_TRACKS_UPDATED when it knows about the
|
|
524
|
+
// tracks, but the browser TextTrackList may not yet enumerate
|
|
525
|
+
// the new TextTrack objects synchronously. Retry with increasing
|
|
526
|
+
// delays until the tracks appear.
|
|
527
|
+
if (found === 0 && retryCount < 5) {
|
|
528
|
+
const delay = (retryCount + 1) * 200;
|
|
529
|
+
this.player.log(`HLS caption tracks not yet on video element, retrying in ${delay}ms (attempt ${retryCount + 1})`, 'info');
|
|
530
|
+
this._setTimeout(() => {
|
|
531
|
+
this.updateCaptionButtonsForHls(retryCount + 1);
|
|
532
|
+
}, delay);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (this.player.transcriptManager?.isVisible) {
|
|
538
|
+
this.player.transcriptManager.loadTranscriptData();
|
|
539
|
+
this.player.transcriptManager.updateLanguageSelector();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (this.player.controlBar) {
|
|
543
|
+
this.player.controlBar.ensureCaptionsButton();
|
|
544
|
+
this.player.controlBar.ensureCaptionStyleButton();
|
|
545
|
+
this.player.controlBar.ensureTranscriptButton();
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
if (this.player.captionManager) {
|
|
549
|
+
this.player.captionManager.refreshTracks();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (this.player.transcriptManager?.isVisible) {
|
|
553
|
+
this.player.transcriptManager.hideTranscript();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (this.player.controlBar) {
|
|
557
|
+
this.player.controlBar.removeHlsCaptionButtons(true);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
if (this.player.controlBar) {
|
|
563
|
+
doUpdate();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const onReady = () => {
|
|
568
|
+
this.player.off('ready', onReady);
|
|
569
|
+
this._pendingReadyHandler = null;
|
|
570
|
+
doUpdate();
|
|
571
|
+
};
|
|
572
|
+
// Track so destroy() can detach it if 'ready' never fires.
|
|
573
|
+
this._pendingReadyHandler = onReady;
|
|
574
|
+
this.player.on('ready', onReady);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
attachMediaEvents() {
|
|
578
|
+
const { signal } = this._listenerController;
|
|
579
|
+
|
|
580
|
+
// Use same events as HTML5 renderer
|
|
581
|
+
this.media.addEventListener('loadedmetadata', () => {
|
|
582
|
+
this.player.state.duration = this.media.duration;
|
|
583
|
+
this.player.emit('loadedmetadata');
|
|
584
|
+
}, { signal });
|
|
585
|
+
|
|
586
|
+
this.media.addEventListener('durationchange', () => {
|
|
587
|
+
const duration = this.media.duration;
|
|
588
|
+
if (duration && isFinite(duration) && duration > 0) {
|
|
589
|
+
this.player.state.duration = duration;
|
|
590
|
+
this.player.emit('durationchange', duration);
|
|
591
|
+
}
|
|
592
|
+
}, { signal });
|
|
593
|
+
|
|
594
|
+
this.media.addEventListener('play', () => {
|
|
595
|
+
this.player.state.playing = true;
|
|
596
|
+
this.player.state.paused = false;
|
|
597
|
+
this.player.state.ended = false;
|
|
598
|
+
this.player.emit('play');
|
|
599
|
+
|
|
600
|
+
if (this.player.options.onPlay) {
|
|
601
|
+
this.player.options.onPlay.call(this.player);
|
|
602
|
+
}
|
|
603
|
+
}, { signal });
|
|
604
|
+
|
|
605
|
+
this.media.addEventListener('pause', () => {
|
|
606
|
+
this.player.state.playing = false;
|
|
607
|
+
this.player.state.paused = true;
|
|
608
|
+
this.player.emit('pause');
|
|
609
|
+
|
|
610
|
+
if (this.player.options.onPause) {
|
|
611
|
+
this.player.options.onPause.call(this.player);
|
|
612
|
+
}
|
|
613
|
+
}, { signal });
|
|
614
|
+
|
|
615
|
+
this.media.addEventListener('ended', () => {
|
|
616
|
+
this.player.state.playing = false;
|
|
617
|
+
this.player.state.paused = true;
|
|
618
|
+
this.player.state.ended = true;
|
|
619
|
+
this.player.emit('ended');
|
|
620
|
+
|
|
621
|
+
if (this.player.options.onEnded) {
|
|
622
|
+
this.player.options.onEnded.call(this.player);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (this.player.options.loop) {
|
|
626
|
+
this.player.seek(0);
|
|
627
|
+
this.player.play();
|
|
628
|
+
}
|
|
629
|
+
}, { signal });
|
|
630
|
+
|
|
631
|
+
this.media.addEventListener('timeupdate', () => {
|
|
632
|
+
this.player.state.currentTime = this.media.currentTime;
|
|
633
|
+
this.player.emit('timeupdate', this.media.currentTime);
|
|
634
|
+
|
|
635
|
+
if (this.player.options.onTimeUpdate) {
|
|
636
|
+
this.player.options.onTimeUpdate.call(this.player, this.media.currentTime);
|
|
637
|
+
}
|
|
638
|
+
}, { signal });
|
|
639
|
+
|
|
640
|
+
this.media.addEventListener('volumechange', () => {
|
|
641
|
+
this.player.state.volume = this.media.volume;
|
|
642
|
+
this.player.state.muted = this.media.muted;
|
|
643
|
+
this.player.emit('volumechange', this.media.volume);
|
|
644
|
+
}, { signal });
|
|
645
|
+
|
|
646
|
+
this.media.addEventListener('waiting', () => {
|
|
647
|
+
this.player.state.buffering = true;
|
|
648
|
+
this.player.emit('waiting');
|
|
649
|
+
}, { signal });
|
|
650
|
+
|
|
651
|
+
this.media.addEventListener('seeking', () => {
|
|
652
|
+
this.player.state.seeking = true;
|
|
653
|
+
this.player.emit('seeking');
|
|
654
|
+
|
|
655
|
+
// Browsers fire `waiting` when playback can't continue due to missing
|
|
656
|
+
// data, but they DO NOT fire it when seeking on a paused element. With
|
|
657
|
+
// hls.js + stopLoad-on-pause, scrubbing while paused triggers a real
|
|
658
|
+
// segment download, but the spinner stays hidden because no `waiting`
|
|
659
|
+
// event reaches the buffering UI. Detect a seek into an unbuffered
|
|
660
|
+
// range here and surface it as 'waiting' so the spinner appears. The
|
|
661
|
+
// spinner is cleared again by the existing `canplay` / `seeked` paths.
|
|
662
|
+
if (!this._isTimeBuffered(this.media.currentTime)) {
|
|
663
|
+
this.player.state.buffering = true;
|
|
664
|
+
this.player.emit('waiting');
|
|
665
|
+
}
|
|
666
|
+
}, { signal });
|
|
667
|
+
|
|
668
|
+
this.media.addEventListener('seeked', () => {
|
|
669
|
+
this.player.state.seeking = false;
|
|
670
|
+
this.player.emit('seeked');
|
|
671
|
+
|
|
672
|
+
// After a seek finishes while paused, hls.js otherwise keeps fetching
|
|
673
|
+
// subsequent segments to fill maxBufferLength. The user explicitly chose
|
|
674
|
+
// not to play, so calling stopLoad() halts the loader once the segment(s)
|
|
675
|
+
// required to render the new playhead frame are buffered. play() and
|
|
676
|
+
// future seeks re-arm via startLoad(-1).
|
|
677
|
+
if (this.media.paused && this.hls) {
|
|
678
|
+
try {
|
|
679
|
+
this.hls.stopLoad();
|
|
680
|
+
} catch {
|
|
681
|
+
// ignore
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}, { signal });
|
|
685
|
+
|
|
686
|
+
this.media.addEventListener('canplay', () => {
|
|
687
|
+
this.player.state.buffering = false;
|
|
688
|
+
this.player.emit('canplay');
|
|
689
|
+
}, { signal });
|
|
690
|
+
|
|
691
|
+
this.media.addEventListener('error', () => {
|
|
692
|
+
this.player.handleError(this.media.error);
|
|
693
|
+
}, { signal });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
handleHlsError(data: HlsErrorData) {
|
|
697
|
+
// Log detailed error info
|
|
698
|
+
this.player.log(`HLS Error - Type: ${data.type}, Details: ${data.details}, Fatal: ${data.fatal}`, 'warn');
|
|
699
|
+
if (data.response) {
|
|
700
|
+
this.player.log(`Response code: ${data.response.code}, URL: ${data.response.url}`, 'warn');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (data.fatal) {
|
|
704
|
+
const ErrorTypes = window.Hls?.ErrorTypes;
|
|
705
|
+
switch (data.type) {
|
|
706
|
+
case ErrorTypes?.NETWORK_ERROR:
|
|
707
|
+
this.player.log('Fatal network error, trying to recover...', 'error');
|
|
708
|
+
this.player.log(`Network error details: ${data.details}`, 'error');
|
|
709
|
+
this._setTimeout(() => {
|
|
710
|
+
this.hls?.startLoad();
|
|
711
|
+
}, 1000);
|
|
712
|
+
break;
|
|
713
|
+
|
|
714
|
+
case ErrorTypes?.MEDIA_ERROR:
|
|
715
|
+
this.player.log('Fatal media error, trying to recover...', 'error');
|
|
716
|
+
this.hls?.recoverMediaError();
|
|
717
|
+
break;
|
|
718
|
+
|
|
719
|
+
default:
|
|
720
|
+
this.player.log('Fatal error, cannot recover', 'error');
|
|
721
|
+
this.player.handleError(new Error(`HLS Error: ${data.type} - ${data.details}`));
|
|
722
|
+
this.hls?.destroy();
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
} else {
|
|
726
|
+
this.player.log('Non-fatal HLS error: ' + data.details, 'warn');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Begin fetching media fragments without starting playback. Used by the
|
|
732
|
+
* playlist manager when a track is selected so playback can start quickly
|
|
733
|
+
* once the user hits play. The manifest was already loaded in initHlsJs();
|
|
734
|
+
* this call is just the equivalent of "press play without playing".
|
|
735
|
+
*/
|
|
736
|
+
ensureLoaded() {
|
|
737
|
+
// Native HLS path delegates to HTML5Renderer; if we got here and have no
|
|
738
|
+
// hls.js instance, there is nothing to do.
|
|
739
|
+
if (!this.hls) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (this._didDeferredLoad) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
this.hls.startLoad(-1);
|
|
749
|
+
} catch {
|
|
750
|
+
// ignore
|
|
751
|
+
}
|
|
752
|
+
this._didDeferredLoad = true;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
play() {
|
|
756
|
+
// Save scroll position to prevent browser from scrolling to video
|
|
757
|
+
const scrollX = window.scrollX;
|
|
758
|
+
const scrollY = window.scrollY;
|
|
759
|
+
|
|
760
|
+
// (Re)start segment loading on every play. Cheap when already loading
|
|
761
|
+
// (hls.js no-ops); needed after pause() called stopLoad() so the buffer
|
|
762
|
+
// resumes filling. Passing -1 lets hls.js pick the starting position from
|
|
763
|
+
// the current playhead instead of forcing one. Clearing the seek-only
|
|
764
|
+
// flag here ensures FRAG_BUFFERED does not stop the loader mid-playback.
|
|
765
|
+
if (this.hls) {
|
|
766
|
+
this._loadingForSeekOnly = false;
|
|
767
|
+
try {
|
|
768
|
+
this.hls.startLoad(-1);
|
|
769
|
+
} catch {
|
|
770
|
+
// ignore and let media.play() surface errors if any
|
|
771
|
+
}
|
|
772
|
+
this._didDeferredLoad = true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const promise = this.media.play();
|
|
776
|
+
|
|
777
|
+
// Restore scroll position immediately to prevent auto-scroll
|
|
778
|
+
window.scrollTo(scrollX, scrollY);
|
|
779
|
+
|
|
780
|
+
if (promise !== undefined) {
|
|
781
|
+
promise.catch(error => {
|
|
782
|
+
this.player.log('Play failed:', error, 'warn');
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
pause() {
|
|
788
|
+
this.media.pause();
|
|
789
|
+
// Stop downloading further segments while paused. Already buffered data
|
|
790
|
+
// stays in the SourceBuffer so resume is instant for short pauses; longer
|
|
791
|
+
// pauses (or stop() = pause() + seek(0)) save bandwidth by not pre-fetching
|
|
792
|
+
// content the user may never watch. play() / seek() re-arm via startLoad().
|
|
793
|
+
if (this.hls) {
|
|
794
|
+
try {
|
|
795
|
+
this.hls.stopLoad();
|
|
796
|
+
} catch {
|
|
797
|
+
// ignore
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
seek(time: number) {
|
|
803
|
+
this.media.currentTime = time;
|
|
804
|
+
// If we're paused with the loader stopped and the user seeks outside the
|
|
805
|
+
// currently buffered range, hls.js needs to fetch new fragments to render
|
|
806
|
+
// the target frame. Calling startLoad(-1) here is safe regardless: when
|
|
807
|
+
// already loading, hls.js no-ops; when stopped, it resumes from the new
|
|
808
|
+
// currentTime so the seek shows a real frame instead of a frozen one.
|
|
809
|
+
// The _loadingForSeekOnly flag tells FRAG_BUFFERED to stop the loader
|
|
810
|
+
// again once the target segment is buffered, so paused/never-played
|
|
811
|
+
// players don't keep pre-fetching beyond the seek point.
|
|
812
|
+
if (this.hls) {
|
|
813
|
+
if (this.media.paused) {
|
|
814
|
+
this._loadingForSeekOnly = true;
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
this.hls.startLoad(-1);
|
|
818
|
+
} catch {
|
|
819
|
+
// ignore
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
setVolume(volume: number) {
|
|
825
|
+
this.media.volume = volume;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
setMuted(muted: boolean) {
|
|
829
|
+
this.media.muted = muted;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
setPlaybackSpeed(speed: number) {
|
|
833
|
+
this.media.playbackRate = speed;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
switchQuality(levelIndex: number) {
|
|
837
|
+
if (this.hls) {
|
|
838
|
+
this.hls.currentLevel = levelIndex;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
getQualities(): QualityLevel[] {
|
|
843
|
+
if (this.hls && this.hls.levels) {
|
|
844
|
+
// hls.js creates separate levels for each video+audio-group combination,
|
|
845
|
+
// producing duplicates (e.g. three "720p" entries). Deduplicate by
|
|
846
|
+
// resolution height, keeping the entry with the highest bitrate per height.
|
|
847
|
+
type LevelEntry = { index: number; height: number; width: number; bitrate: number; level: HlsLevel };
|
|
848
|
+
const byHeight = new Map<string | number, LevelEntry>();
|
|
849
|
+
|
|
850
|
+
this.hls.levels.forEach((level: HlsLevel, index: number) => {
|
|
851
|
+
const height = Number(level.height) || 0;
|
|
852
|
+
const bitrate = Number(level.bitrate) || 0;
|
|
853
|
+
const key: string | number = height > 0 ? height : `br_${bitrate}`;
|
|
854
|
+
const existing = byHeight.get(key);
|
|
855
|
+
|
|
856
|
+
if (!existing || bitrate > (existing.bitrate || 0)) {
|
|
857
|
+
byHeight.set(key, { index, height: level.height, width: level.width, bitrate, level });
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
return Array.from(byHeight.values()).map((entry) => {
|
|
862
|
+
const height = Number(entry.height) || 0;
|
|
863
|
+
const kb = entry.bitrate > 0 ? Math.round(entry.bitrate / 1000) : 0;
|
|
864
|
+
const name = height > 0 ? `${height}p` : (kb > 0 ? `${kb} kb` : 'Auto');
|
|
865
|
+
return { index: entry.index, height: entry.height, width: entry.width, bitrate: entry.bitrate, name };
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
getCurrentQuality() {
|
|
872
|
+
if (this.hls) {
|
|
873
|
+
return this.hls.currentLevel;
|
|
874
|
+
}
|
|
875
|
+
return -1;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
activateTextTrackForLanguage(lang: string): boolean {
|
|
879
|
+
if (!this.hls || !lang) return false;
|
|
880
|
+
|
|
881
|
+
const tracks = this.hls.subtitleTracks;
|
|
882
|
+
if (!tracks || tracks.length === 0) return false;
|
|
883
|
+
|
|
884
|
+
const idx = tracks.findIndex((t: HlsSubtitleTrack) => {
|
|
885
|
+
const tLang = t.lang || t.language || '';
|
|
886
|
+
return tLang === lang || tLang.startsWith(lang) || lang.startsWith(tLang);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
if (idx < 0) return false;
|
|
890
|
+
|
|
891
|
+
this.player.log(`Activating HLS subtitle track index ${idx} for language "${lang}"`);
|
|
892
|
+
this.hls.subtitleTrack = idx;
|
|
893
|
+
|
|
894
|
+
this._lastKnownCueCount = 0;
|
|
895
|
+
this._startCueUpdatePolling();
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
getTextTrackURLs(): { lang: string; url: string }[] {
|
|
900
|
+
if (!this.hls || !this._manifestUrl) return [];
|
|
901
|
+
try {
|
|
902
|
+
const tracks = this.hls.subtitleTracks;
|
|
903
|
+
if (!tracks || tracks.length === 0) return [];
|
|
904
|
+
|
|
905
|
+
const results: { lang: string; url: string }[] = [];
|
|
906
|
+
for (const track of tracks) {
|
|
907
|
+
const lang = track.lang || track.language || '';
|
|
908
|
+
const playlistUrl = track.url;
|
|
909
|
+
if (!lang || !playlistUrl) continue;
|
|
910
|
+
results.push({ lang, url: playlistUrl });
|
|
911
|
+
}
|
|
912
|
+
return results;
|
|
913
|
+
} catch {
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
supportsAutoQuality() {
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
isAutoQuality() {
|
|
923
|
+
return this.hls?.currentLevel === -1;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
destroy() {
|
|
927
|
+
this._stopCueUpdatePolling();
|
|
928
|
+
this._clearTimers();
|
|
929
|
+
// Detach the media listeners registered in attachMediaEvents().
|
|
930
|
+
this._listenerController.abort();
|
|
931
|
+
// Remove the deferred 'ready' listener if it never fired.
|
|
932
|
+
if (this._pendingReadyHandler) {
|
|
933
|
+
this.player.off('ready', this._pendingReadyHandler);
|
|
934
|
+
this._pendingReadyHandler = null;
|
|
935
|
+
}
|
|
936
|
+
this._lastKnownCueCount = 0;
|
|
937
|
+
this._manifestUrl = null;
|
|
938
|
+
if (this.hls) {
|
|
939
|
+
this.hls.destroy();
|
|
940
|
+
this.hls = null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|