vidply 1.2.0 → 1.2.1

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.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.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
+