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,1077 +1,1077 @@
1
- import type { Renderer } from '../types/renderer.js';
2
- import type { Player } from '../core/Player.js';
3
- import { loadScriptOnce } from '../utils/ScriptLoader.js';
4
-
5
- interface DashTextTrack {
6
- lang?: string;
7
- language?: string;
8
- srclang?: string;
9
- kind?: string;
10
- label?: string;
11
- labels?: string;
12
- isTTML?: boolean;
13
- codec?: string;
14
- mimeType?: string;
15
- [key: string]: unknown;
16
- }
17
-
18
- /**
19
- * Shape of payloads emitted by VidPly's `captionsenabled` event when
20
- * routed into a DASH renderer. These come from CaptionManager and carry
21
- * both the language and the underlying TextTrack reference.
22
- */
23
- interface CaptionTrackSelection {
24
- language?: string;
25
- label?: string;
26
- track?: TextTrack;
27
- }
28
-
29
- type CaptionEnabledHandler = (track: CaptionTrackSelection) => void;
30
- type CaptionDisabledHandler = () => void;
31
-
32
- export class DASHRenderer implements Renderer {
33
- player: Player;
34
- media: HTMLMediaElement;
35
- dash: DashMediaPlayerInstance | null;
36
- readonly isStreaming = true;
37
- _dashSourceLoaded: boolean;
38
- _pendingSrc: string | null;
39
- _dashSubtitleTracksCount: number | undefined;
40
- _dashTextTracks: DashTextTrack[];
41
- _cueUpdateTimer: ReturnType<typeof setInterval> | null;
42
- _captionEnabledHandler: CaptionEnabledHandler | null;
43
- _captionDisabledHandler: CaptionDisabledHandler | null;
44
- _lastKnownCueCount: number;
45
- _dashTextIsTtml: boolean;
46
- _pendingTimeouts: ReturnType<typeof setTimeout>[];
47
- _ttmlDiv: HTMLElement | null;
48
- _manifestUrl: string | null;
49
- // Detaches all media listeners (attachMediaEvents) in destroy().
50
- private _listenerController: AbortController;
51
- // Deferred 'ready' handler from updateCaptionButtonsForStreaming, removed on
52
- // destroy if it never fired.
53
- private _pendingReadyHandler: (() => void) | null;
54
-
55
- constructor(player: Player) {
56
- this.player = player;
57
- this.media = player.element;
58
- this.dash = null;
59
- this._dashSourceLoaded = false;
60
- this._pendingSrc = null;
61
- this._dashSubtitleTracksCount = undefined;
62
- this._dashTextTracks = [];
63
- this._cueUpdateTimer = null;
64
- this._captionEnabledHandler = null;
65
- this._captionDisabledHandler = null;
66
- this._lastKnownCueCount = 0;
67
- this._dashTextIsTtml = false;
68
- this._pendingTimeouts = [];
69
- this._ttmlDiv = null;
70
- this._manifestUrl = null;
71
- this._listenerController = new AbortController();
72
- this._pendingReadyHandler = null;
73
- }
74
-
75
- async init() {
76
- this.player.log('Using dash.js for DASH support');
77
- await this.initDashJs();
78
- }
79
-
80
- async initDashJs() {
81
- this.media.controls = false;
82
- this.media.removeAttribute('controls');
83
-
84
- if (!window.dashjs) {
85
- await this.loadDashJs();
86
- }
87
-
88
- // Remove <source> children — dash.js manages the src via MSE
89
- const sourceElements = Array.from(this.media.querySelectorAll('source'));
90
- let originalSrc = null;
91
- if (sourceElements.length > 0) {
92
- originalSrc = sourceElements[0]?.getAttribute('src') ?? null;
93
- sourceElements.forEach(source => source.remove());
94
- this.player.log('Removed <source> elements for HTML5 validity (dash.js uses MSE)');
95
- }
96
-
97
- const dashjs = window.dashjs;
98
- if (!dashjs) {
99
- throw new Error('dash.js not available');
100
- }
101
- this.dash = dashjs.MediaPlayer().create();
102
-
103
- this.dash.updateSettings({
104
- debug: {
105
- logLevel: this.player.options.debug ? 4 : 0
106
- },
107
- streaming: {
108
- // Override dash.js default of 'lowestStartupDelay'. For audio
109
- // AdaptationSets that tie on selectionPriority and role=main (e.g.
110
- // Axinom's three en/en-low/en-high tracks), 'lowestStartupDelay'
111
- // falls through to 'highestEfficiency' which, for audio, has no
112
- // meaningful pixels-per-bit metric and collapses to "highest
113
- // bitrate". 'firstTrack' respects manifest order instead, which is
114
- // both predictable and closer to the MPD author's intent.
115
- selectionModeForInitialTrack: 'firstTrack',
116
- // NOTE on pre-play preload: we deliberately do NOT set
117
- // streaming.scheduling.scheduleWhilePaused = false here. While that
118
- // is the documented dash.js way to suppress segment downloads while
119
- // paused / before the first play, in our setup (dash.js 5.2.0 +
120
- // dash.initialize(media, null, false) + attachSource at init) it
121
- // tears down the SourceBuffers mid-init with
122
- // "SourceBuffer has been removed from the parent media source"
123
- // exceptions, which leaves the player unable to seek or play. The
124
- // PR #3785 fix that was supposed to handle the initial-playback /
125
- // autoPlay=false case is fragile against our usage pattern.
126
- // Instead we keep dash.js's default scheduling (scheduleWhilePaused
127
- // stays at its default `true`) and let the buffer caps below limit
128
- // how much is fetched before play. With a single ~6s segment size,
129
- // the visible network preload is one init segment per track plus
130
- // 1–2 media segments — the same "first two chunks" behavior the
131
- // user previously confirmed as acceptable for DASH.
132
- buffer: {
133
- bufferTimeAtTopQuality: 30,
134
- bufferTimeAtTopQualityLongForm: 60,
135
- // dash.js 5.x: use bufferTimeDefault (replaces removed stableBufferTime).
136
- // Keep at 12s — going lower (0 / 1) was tested but dash.js still
137
- // loads the first segment regardless because it's needed to make
138
- // the MediaSource playable, so the savings are negligible while
139
- // hurting mid-playback resilience on slow networks.
140
- bufferTimeDefault: 12,
141
- bufferToKeep: 20,
142
- bufferPruningInterval: 10
143
- },
144
- retryAttempts: {
145
- MPD: 4,
146
- MediaSegment: 6,
147
- InitializationSegment: 4,
148
- BitstreamSwitchingSegment: 4
149
- },
150
- retryIntervals: {
151
- MPD: 1000,
152
- MediaSegment: 1000,
153
- InitializationSegment: 1000,
154
- BitstreamSwitchingSegment: 1000
155
- },
156
- abr: {
157
- autoSwitchBitrate: { video: true, audio: true }
158
- },
159
- text: {
160
- defaultEnabled: true
161
- }
162
- }
163
- });
164
-
165
- // Create the TTML rendering div before initialize() so the DOM element
166
- // exists, but attach it to dash.js after initialize() because
167
- // attachTTMLRenderingDiv() requires attachView() which initialize() calls.
168
- this._ttmlDiv = document.createElement('div');
169
- this._ttmlDiv.className = 'vidply-dash-ttml';
170
- this._ttmlDiv.style.visibility = 'hidden';
171
- const wrapper = this.player.videoWrapper || this.media.parentElement;
172
- if (wrapper) {
173
- wrapper.appendChild(this._ttmlDiv);
174
- }
175
-
176
- this.dash.initialize(this.media, null, false);
177
- this.dash.attachTTMLRenderingDiv(this._ttmlDiv);
178
-
179
- // Resolve source URL
180
- let src = this.player.currentSource;
181
-
182
- if (!src && originalSrc) {
183
- src = originalSrc;
184
- }
185
-
186
- if (!src) {
187
- src = this.player.element.getAttribute('data-vidply-src');
188
- }
189
-
190
- if (!src) {
191
- const elementSrc = this.player.element.getAttribute('src') || this.player.element.src;
192
- if (elementSrc && !elementSrc.startsWith('blob:')) {
193
- src = elementSrc;
194
- }
195
- }
196
-
197
- this.player.log(`Loading DASH source: ${src}`, 'log');
198
-
199
- if (!src) {
200
- throw new Error('No DASH source found');
201
- }
202
-
203
- // Always attach the source at init so the manifest is fetched and the
204
- // seekbar / duration / quality list are available before the first play.
205
- // We deliberately don't honor `deferLoad` here: deferring attachSource
206
- // would leave duration unknown and seek requests unserviceable. The
207
- // small preload (init segments + ~1 segment of media data) that dash.js
208
- // does up to bufferTimeDefault is acceptable; in exchange the seekbar
209
- // becomes functional immediately.
210
- this._pendingSrc = src;
211
- this._manifestUrl = src;
212
- this.dash.attachSource(src);
213
- this._dashSourceLoaded = true;
214
-
215
- // Force the vidply poster overlay even though dash.js will paint the
216
- // first decoded frame into the <video> element. With MSE attached and
217
- // init/startup segments appended, the browser ignores the native
218
- // `poster` attribute and shows the first frame instead. We instead use
219
- // the `vidply-forced-poster` CSS class, which sets `<video>` to
220
- // opacity:0 and renders the poster image as a wrapper background — the
221
- // user sees the artwork until they press play, just like on a fully
222
- // native HTML5 video. The 'play' handler in Player.ts already calls
223
- // hidePosterOverlay() on first playback, so we don't need to remove it
224
- // manually here.
225
- this.player.showPosterOverlay();
226
-
227
- this.attachDashEvents();
228
- this.attachMediaEvents();
229
- this._setupCaptionSync();
230
- }
231
-
232
- /**
233
- * Load dash.js. Pinned to an exact version (the previous default
234
- * `5.2.0` is preserved) and overridable via `options.dashScriptUrl`
235
- * (URL) / `options.dashScriptIntegrity` (SRI hash). See
236
- * HLSRenderer.loadHlsJs() for the SRI computation command.
237
- */
238
- async loadDashJs(): Promise<void> {
239
- const defaultUrl = 'https://cdn.jsdelivr.net/npm/dashjs@5.2.0/dist/modern/umd/dash.all.min.js';
240
- const url: string = (this.player.options.dashScriptUrl as string | undefined) || defaultUrl;
241
- const integrity = this.player.options.dashScriptIntegrity as string | undefined;
242
-
243
- return loadScriptOnce(url, { integrity });
244
- }
245
-
246
- _setTimeout(fn: () => void, delay: number) {
247
- const id = setTimeout(() => {
248
- this._pendingTimeouts = this._pendingTimeouts.filter(t => t !== id);
249
- fn();
250
- }, delay);
251
- this._pendingTimeouts.push(id);
252
- return id;
253
- }
254
-
255
- attachDashEvents() {
256
- const dashjs = window.dashjs;
257
- const dash = this.dash;
258
- if (!dashjs || !dash) return;
259
- const dashEvents = dashjs.MediaPlayer.events;
260
-
261
- dash.on(dashEvents.MANIFEST_LOADED, (...args: unknown[]) => {
262
- const e = args[0] as { data?: unknown } | undefined;
263
- const data = e?.data ?? e;
264
- this.player.log('DASH manifest loaded');
265
- this.player.emit('dashmanifestloaded', data);
266
-
267
- if (this.player.container) {
268
- this.player.container.classList.remove('vidply-external-controls');
269
- }
270
-
271
- this._setTimeout(() => {
272
- this._checkSubtitleTracks();
273
- }, 500);
274
- });
275
-
276
- dash.on(dashEvents.QUALITY_CHANGE_RENDERED, (...args: unknown[]) => {
277
- const e = args[0] as { mediaType?: string; newQuality?: number };
278
- if (e.mediaType === 'video') {
279
- this.player.log('DASH quality changed to index ' + e.newQuality);
280
- this.player.emit('dashqualitychanged', e);
281
- }
282
- });
283
-
284
- dash.on(dashEvents.TEXT_TRACKS_ADDED, (...args: unknown[]) => {
285
- const e = args[0] as { tracks?: DashTextTrack[] } | undefined;
286
- const tracks = e?.tracks ?? [];
287
- this._dashTextTracks = tracks;
288
- this._dashTextIsTtml = tracks.some((t) =>
289
- t.isTTML || /stpp|ttml/i.test(t.codec || '') || /ttml/i.test(t.mimeType || '')
290
- );
291
- this.player.log(`DASH text tracks added: ${tracks.length} tracks, format: ${this._dashTextIsTtml ? 'TTML' : 'WebVTT'}`);
292
- this._dashSubtitleTracksCount = tracks.length;
293
- this.player.emit('dashsubtitletracksupdated', { tracks });
294
- this.updateCaptionButtonsForDash();
295
-
296
- if (tracks.length > 0) {
297
- try {
298
- dash.setTextTrack(0);
299
- } catch {
300
- // ignore if not ready yet
301
- }
302
- if (!this._dashTextIsTtml) {
303
- this._startCueUpdatePolling();
304
- }
305
- }
306
- });
307
-
308
- dash.on(dashEvents.STREAM_INITIALIZED, () => {
309
- this.player.log('DASH stream initialized');
310
- this.player.emit('dashstreaminitialized');
311
-
312
- this._setTimeout(() => {
313
- const qualities = this.getQualities();
314
- if (qualities.length > 0) {
315
- this.player.emit('dashmanifestparsed', { qualities });
316
- }
317
- }, 300);
318
- });
319
-
320
- dash.on(dashEvents.ERROR, (...args: unknown[]) => {
321
- this.handleDashError(args[0]);
322
- });
323
-
324
- dash.on(dashEvents.FRAGMENT_LOADING_COMPLETED, (...args: unknown[]) => {
325
- const e = args[0] as { request?: { mediaType?: string } } | undefined;
326
- this.player.state.buffering = false;
327
- if (e?.request?.mediaType === 'text' && !this._dashTextIsTtml) {
328
- this._setTimeout(() => {
329
- const count = this._getTotalCueCount();
330
- if (count > this._lastKnownCueCount) {
331
- this._lastKnownCueCount = count;
332
- this.player.emit('textcuesupdate');
333
- }
334
- }, 100);
335
- }
336
- });
337
- }
338
-
339
- /**
340
- * Count total cues across all subtitle/caption tracks (for WebVTT DASH).
341
- */
342
- _getTotalCueCount() {
343
- const textTracks = this.media.textTracks;
344
- let total = 0;
345
- if (!textTracks) return total;
346
- for (let i = 0; i < textTracks.length; i++) {
347
- const track = textTracks[i];
348
- if (track && (track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale && track.cues) {
349
- total += track.cues.length;
350
- }
351
- }
352
- return total;
353
- }
354
-
355
- /**
356
- * Return true if `time` falls inside any TimeRange the SourceBuffer already
357
- * holds, with a small tolerance to absorb GOP boundaries. Used by the
358
- * seeking handler to decide whether to surface a 'waiting' event for the
359
- * spinner UI when the user scrubs while paused.
360
- */
361
- _isTimeBuffered(time: number): boolean {
362
- const buffered = this.media.buffered;
363
- if (!buffered || buffered.length === 0) return false;
364
- const tolerance = 0.25;
365
- for (let i = 0; i < buffered.length; i++) {
366
- if (time >= buffered.start(i) - tolerance && time <= buffered.end(i) + tolerance) {
367
- return true;
368
- }
369
- }
370
- return false;
371
- }
372
-
373
- /**
374
- * Sync VidPly caption track switches with dash.js so it loads
375
- * subtitle segments for the selected language.
376
- */
377
- _setupCaptionSync() {
378
- this._captionEnabledHandler = (selectedTrack) => {
379
- if (this._dashTextIsTtml) {
380
- // dash.js only renders TTML content to the rendering div when the
381
- // TextTrack mode is 'showing'. CaptionManager sets it to 'hidden',
382
- // so we override it here. VidPly's own caption overlay is skipped
383
- // via handlesOwnCaptions().
384
- if (selectedTrack.track) {
385
- selectedTrack.track.mode = 'showing';
386
- }
387
- if (this._ttmlDiv) {
388
- this._ttmlDiv.style.visibility = 'visible';
389
- }
390
- }
391
- this._syncDashTextTrack(selectedTrack);
392
- };
393
- this._captionDisabledHandler = () => {
394
- if (this._dashTextIsTtml && this._ttmlDiv) {
395
- this._ttmlDiv.style.visibility = 'hidden';
396
- }
397
- if (this.dash) {
398
- try { this.dash.setTextTrack(-1); } catch { /* ignore */ }
399
- }
400
- };
401
- this.player.on('captionsenabled', this._captionEnabledHandler);
402
- this.player.on('captionsdisabled', this._captionDisabledHandler);
403
- }
404
-
405
- /**
406
- * Map a VidPly caption track to the corresponding dash.js track index
407
- * and switch dash.js to load segments for that language.
408
- */
409
- _syncDashTextTrack(selectedTrack: CaptionTrackSelection) {
410
- if (!this.dash || !this._dashTextTracks.length) return;
411
-
412
- const lang = selectedTrack.language;
413
- if (!lang) return;
414
-
415
- const dashIndex = this._dashTextTracks.findIndex(dt => {
416
- const dtLang = dt.lang || dt.language || dt.srclang || '';
417
- if (!dtLang) return false;
418
- return dtLang === lang || dtLang.startsWith(lang) || lang.startsWith(dtLang);
419
- });
420
-
421
- if (dashIndex >= 0) {
422
- this.player.log(`Syncing DASH text track to index ${dashIndex} (${lang})`);
423
- try {
424
- this.dash.setTextTrack(dashIndex);
425
- } catch { /* ignore */ }
426
- if (!this._dashTextIsTtml) {
427
- this._lastKnownCueCount = 0;
428
- this._startCueUpdatePolling();
429
- }
430
- }
431
- }
432
-
433
- /**
434
- * Poll for new WebVTT cues being added by dash.js as subtitle segments load.
435
- * Emits events for transcript refresh when new cues arrive.
436
- */
437
- _startCueUpdatePolling() {
438
- this._stopCueUpdatePolling();
439
- let prevCueCount = 0;
440
- let stableRounds = 0;
441
-
442
- this._cueUpdateTimer = setInterval(() => {
443
- const count = this._getTotalCueCount();
444
-
445
- if (count > prevCueCount) {
446
- prevCueCount = count;
447
- stableRounds = 0;
448
- this.player.emit('textcuesupdate');
449
- } else {
450
- stableRounds++;
451
- if (stableRounds >= 8) {
452
- this._stopCueUpdatePolling();
453
- if (count > 0) {
454
- this.player.emit('textcuesupdate');
455
- }
456
- }
457
- }
458
- }, 500);
459
- }
460
-
461
- _stopCueUpdatePolling() {
462
- if (this._cueUpdateTimer) {
463
- clearInterval(this._cueUpdateTimer);
464
- this._cueUpdateTimer = null;
465
- }
466
- }
467
-
468
- _checkSubtitleTracks() {
469
- if (this._dashSubtitleTracksCount !== undefined && this._dashSubtitleTracksCount > 0) {
470
- return;
471
- }
472
- const tracks = this.media.textTracks;
473
- let count = 0;
474
- for (let i = 0; i < tracks.length; i++) {
475
- const track = tracks[i];
476
- if (track && (track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
477
- count++;
478
- }
479
- }
480
- this._dashSubtitleTracksCount = count;
481
- this.updateCaptionButtonsForDash();
482
- }
483
-
484
- updateCaptionButtonsForDash(retryCount = 0) {
485
- const tracksCount = this._dashSubtitleTracksCount || 0;
486
-
487
- const doUpdate = () => {
488
- this.player.invalidateTrackCache();
489
-
490
- if (tracksCount > 0) {
491
- if (this.player.captionManager) {
492
- const found = this.player.captionManager.refreshTracks();
493
-
494
- // dash.js fires TEXT_TRACKS_ADDED when it knows about the
495
- // tracks, but the browser TextTrackList may not yet enumerate
496
- // the new TextTrack objects synchronously. If refreshTracks
497
- // found nothing, retry a few times with increasing delays.
498
- if (found === 0 && retryCount < 5) {
499
- const delay = (retryCount + 1) * 200;
500
- this.player.log(`DASH caption tracks not yet on video element, retrying in ${delay}ms (attempt ${retryCount + 1})`, 'info');
501
- this._setTimeout(() => {
502
- this.updateCaptionButtonsForDash(retryCount + 1);
503
- }, delay);
504
- return;
505
- }
506
- }
507
-
508
- if (!this._dashTextIsTtml && this.player.transcriptManager?.isVisible) {
509
- this.player.transcriptManager.loadTranscriptData();
510
- this.player.transcriptManager.updateLanguageSelector();
511
- }
512
-
513
- if (this.player.controlBar) {
514
- this.player.controlBar.ensureCaptionsButton();
515
- if (!this._dashTextIsTtml) {
516
- this.player.controlBar.ensureCaptionStyleButton();
517
- this.player.controlBar.ensureTranscriptButton();
518
- }
519
- }
520
- } else {
521
- if (this.player.captionManager) {
522
- this.player.captionManager.refreshTracks();
523
- }
524
-
525
- if (this.player.transcriptManager?.isVisible) {
526
- this.player.transcriptManager.hideTranscript();
527
- }
528
-
529
- if (this.player.controlBar) {
530
- this.player.controlBar.removeHlsCaptionButtons(true);
531
- }
532
- }
533
- };
534
-
535
- if (this.player.controlBar) {
536
- doUpdate();
537
- return;
538
- }
539
-
540
- const onReady = () => {
541
- this.player.off('ready', onReady);
542
- this._pendingReadyHandler = null;
543
- doUpdate();
544
- };
545
- // Track so destroy() can detach it if 'ready' never fires.
546
- this._pendingReadyHandler = onReady;
547
- this.player.on('ready', onReady);
548
- }
549
-
550
- attachMediaEvents() {
551
- const { signal } = this._listenerController;
552
-
553
- this.media.addEventListener('loadedmetadata', () => {
554
- this.player.state.duration = this.media.duration;
555
- this.player.emit('loadedmetadata');
556
- }, { signal });
557
-
558
- this.media.addEventListener('durationchange', () => {
559
- const duration = this.media.duration;
560
- if (duration && isFinite(duration) && duration > 0) {
561
- this.player.state.duration = duration;
562
- this.player.emit('durationchange', duration);
563
- }
564
- }, { signal });
565
-
566
- this.media.addEventListener('play', () => {
567
- this.player.state.playing = true;
568
- this.player.state.paused = false;
569
- this.player.state.ended = false;
570
- this.player.emit('play');
571
-
572
- if (this.player.options.onPlay) {
573
- this.player.options.onPlay.call(this.player);
574
- }
575
- }, { signal });
576
-
577
- this.media.addEventListener('pause', () => {
578
- this.player.state.playing = false;
579
- this.player.state.paused = true;
580
- this.player.emit('pause');
581
-
582
- if (this.player.options.onPause) {
583
- this.player.options.onPause.call(this.player);
584
- }
585
- }, { signal });
586
-
587
- this.media.addEventListener('ended', () => {
588
- this.player.state.playing = false;
589
- this.player.state.paused = true;
590
- this.player.state.ended = true;
591
- this.player.emit('ended');
592
-
593
- if (this.player.options.onEnded) {
594
- this.player.options.onEnded.call(this.player);
595
- }
596
-
597
- if (this.player.options.loop) {
598
- this.player.seek(0);
599
- this.player.play();
600
- }
601
- }, { signal });
602
-
603
- this.media.addEventListener('timeupdate', () => {
604
- this.player.state.currentTime = this.media.currentTime;
605
- this.player.emit('timeupdate', this.media.currentTime);
606
-
607
- if (this.player.options.onTimeUpdate) {
608
- this.player.options.onTimeUpdate.call(this.player, this.media.currentTime);
609
- }
610
- }, { signal });
611
-
612
- this.media.addEventListener('volumechange', () => {
613
- this.player.state.volume = this.media.volume;
614
- this.player.state.muted = this.media.muted;
615
- this.player.emit('volumechange', this.media.volume);
616
- }, { signal });
617
-
618
- this.media.addEventListener('seeking', () => {
619
- this.player.state.seeking = true;
620
- this.player.emit('seeking');
621
-
622
- // Browsers do not fire `waiting` when seeking on a paused media element
623
- // — there is nothing to "wait" for since playback isn't requested. With
624
- // dash.js + MSE, scrubbing while paused (or before the first play)
625
- // still triggers a real fragment download, but the spinner stays hidden
626
- // because no `waiting` event reaches the buffering UI. Detect a seek
627
- // into an unbuffered range here and surface it as 'waiting' so the
628
- // spinner appears. Cleared again by the existing `canplay` /
629
- // `seeked` paths.
630
- if (!this._isTimeBuffered(this.media.currentTime)) {
631
- this.player.state.buffering = true;
632
- this.player.emit('waiting');
633
- }
634
- }, { signal });
635
-
636
- this.media.addEventListener('seeked', () => {
637
- this.player.state.seeking = false;
638
- this.player.emit('seeked');
639
- }, { signal });
640
-
641
- this.media.addEventListener('waiting', () => {
642
- this.player.state.buffering = true;
643
- this.player.emit('waiting');
644
- }, { signal });
645
-
646
- this.media.addEventListener('canplay', () => {
647
- this.player.state.buffering = false;
648
- this.player.emit('canplay');
649
- }, { signal });
650
-
651
- this.media.addEventListener('error', () => {
652
- this.player.handleError(this.media.error);
653
- }, { signal });
654
- }
655
-
656
- handleDashError(e: unknown) {
657
- const wrapped = e as { error?: { code?: number; message?: string }; code?: number; message?: string } | undefined;
658
- const error = wrapped?.error ?? wrapped;
659
- if (!error) return;
660
- const code = error.code ?? '';
661
- const message = error.message || '';
662
- this.player.log(`DASH Error - Code: ${code}, Message: ${message}`, 'warn');
663
-
664
- if (typeof code === 'number' && code >= 100) {
665
- this.player.log('Fatal DASH error', 'error');
666
- this.player.handleError(new Error(`DASH Error: ${code} - ${message}`));
667
- } else {
668
- this.player.log('Non-fatal DASH error: ' + (message || String(error)), 'warn');
669
- }
670
- }
671
-
672
- ensureLoaded() {
673
- if (!this.player.options.deferLoad) {
674
- return;
675
- }
676
-
677
- if (!this.dash) {
678
- return;
679
- }
680
-
681
- if (this._dashSourceLoaded) {
682
- return;
683
- }
684
-
685
- const src = this._pendingSrc || this.player._pendingSource || this.player.currentSource;
686
- if (!src) {
687
- return;
688
- }
689
-
690
- try {
691
- this.dash.attachSource(src);
692
- this._dashSourceLoaded = true;
693
- } catch {
694
- // ignore
695
- }
696
- }
697
-
698
- play() {
699
- const scrollX = window.scrollX;
700
- const scrollY = window.scrollY;
701
-
702
- // Defer-load (legacy code path, kept for completeness): the source is
703
- // normally attached at init and dash.js is configured with
704
- // scheduling.scheduleWhilePaused: false to keep prefetch at zero until
705
- // play. If for any reason the source is still pending here, attach it
706
- // now. Note that media.play() is called immediately afterwards; on
707
- // modern dash.js this races cleanly because attachSource() is sync
708
- // enough for the MediaSource to be ready by the time the play promise
709
- // resolves, and the configured zero-prefetch settings make the race
710
- // window very short. The general "no preload" UX no longer relies on
711
- // this branch.
712
- if (this.player.options.deferLoad && this.dash && !this._dashSourceLoaded) {
713
- const src = this._pendingSrc || this.player.currentSource;
714
- if (src) {
715
- try {
716
- this.dash.attachSource(src);
717
- this._dashSourceLoaded = true;
718
- } catch {
719
- // ignore and let media.play() surface errors if any
720
- }
721
- }
722
- }
723
-
724
- const promise = this.media.play();
725
-
726
- window.scrollTo(scrollX, scrollY);
727
-
728
- if (promise !== undefined) {
729
- promise.catch(error => {
730
- this.player.log('Play failed:', error, 'warn');
731
- });
732
- }
733
- }
734
-
735
- pause() {
736
- // dash.js stops scheduling new fragment downloads once `paused` is true,
737
- // because we configured `streaming.scheduling.scheduleWhilePaused: false`
738
- // at init. Already-buffered data stays in the SourceBuffer, so a
739
- // subsequent play() resumes near-instantly while pause saves bandwidth.
740
- this.media.pause();
741
- }
742
-
743
- seek(time: number) {
744
- this.media.currentTime = time;
745
- }
746
-
747
- setVolume(volume: number) {
748
- this.media.volume = volume;
749
- }
750
-
751
- setMuted(muted: boolean) {
752
- this.media.muted = muted;
753
- }
754
-
755
- setPlaybackSpeed(speed: number) {
756
- this.media.playbackRate = speed;
757
- }
758
-
759
- switchQuality(qualityIndex: number) {
760
- if (!this.dash) return;
761
-
762
- if (qualityIndex === -1) {
763
- // Re-enable ABR auto-switching
764
- if (typeof this.dash.setAutoSwitchQualityFor === 'function') {
765
- this.dash.setAutoSwitchQualityFor('video', true);
766
- } else {
767
- this.dash.updateSettings({
768
- streaming: { abr: { autoSwitchBitrate: { video: true } } }
769
- });
770
- }
771
- } else {
772
- // Disable ABR and lock to the chosen quality
773
- if (typeof this.dash.setAutoSwitchQualityFor === 'function') {
774
- this.dash.setAutoSwitchQualityFor('video', false);
775
- } else {
776
- this.dash.updateSettings({
777
- streaming: { abr: { autoSwitchBitrate: { video: false } } }
778
- });
779
- }
780
-
781
- if (typeof this.dash.setRepresentationForTypeByIndex === 'function') {
782
- this.dash.setRepresentationForTypeByIndex('video', qualityIndex);
783
- } else if (typeof this.dash.setQualityFor === 'function') {
784
- this.dash.setQualityFor('video', qualityIndex, true);
785
- }
786
- }
787
- }
788
-
789
- getQualities() {
790
- if (!this.dash) return [];
791
-
792
- try {
793
- // dash.js v5+: getRepresentationsByType
794
- let reps = null;
795
- if (typeof this.dash.getRepresentationsByType === 'function') {
796
- reps = this.dash.getRepresentationsByType('video');
797
- }
798
-
799
- if (reps && reps.length > 0) {
800
- const heightCounts: Record<number, number> = {};
801
- reps.forEach((r: DashRepresentation) => {
802
- const h = Number(r.height) || 0;
803
- heightCounts[h] = (heightCounts[h] || 0) + 1;
804
- });
805
-
806
- return reps.map((rep: DashRepresentation, index: number) => {
807
- const height = Number(rep.height) || 0;
808
- const bitrate = Number(rep.bandwidth || rep.bitrate) || 0;
809
- const kb = bitrate > 0 ? Math.round(bitrate / 1000) : 0;
810
- let name;
811
- if (height > 0 && (heightCounts[height] ?? 0) > 1 && kb > 0) {
812
- name = `${height}p (${kb} kbps)`;
813
- } else if (height > 0) {
814
- name = `${height}p`;
815
- } else {
816
- name = kb > 0 ? `${kb} kbps` : 'Auto';
817
- }
818
- return {
819
- index,
820
- id: rep.id,
821
- height: rep.height,
822
- width: rep.width,
823
- bitrate,
824
- name
825
- };
826
- });
827
- }
828
-
829
- // Fallback: dash.js v4 and earlier
830
- const bitrateList = this.dash.getBitrateInfoListFor('video');
831
- if (!bitrateList || bitrateList.length === 0) return [];
832
-
833
- const heightCounts: Record<number, number> = {};
834
- bitrateList.forEach((info: DashBitrateInfo) => {
835
- const h = Number(info.height) || 0;
836
- heightCounts[h] = (heightCounts[h] || 0) + 1;
837
- });
838
-
839
- return bitrateList.map((info: DashBitrateInfo, index: number) => {
840
- const height = Number(info.height) || 0;
841
- const bitrate = Number(info.bitrate) || 0;
842
- const kb = bitrate > 0 ? Math.round(bitrate / 1000) : 0;
843
- let name;
844
- if (height > 0 && (heightCounts[height] ?? 0) > 1 && kb > 0) {
845
- name = `${height}p (${kb} kbps)`;
846
- } else if (height > 0) {
847
- name = `${height}p`;
848
- } else {
849
- name = kb > 0 ? `${kb} kbps` : 'Auto';
850
- }
851
- return {
852
- index,
853
- height: info.height,
854
- width: info.width,
855
- bitrate: info.bitrate,
856
- name
857
- };
858
- });
859
- } catch {
860
- return [];
861
- }
862
- }
863
-
864
- getCurrentQuality() {
865
- if (!this.dash) return -1;
866
- try {
867
- if (typeof this.dash.getRepresentationsByType === 'function') {
868
- const reps = this.dash.getRepresentationsByType('video');
869
- const current = this.dash.getCurrentRepresentationForType?.('video');
870
- if (current && reps) {
871
- const idx = reps.findIndex((r: DashRepresentation) => r.id === current.id);
872
- if (idx >= 0) return idx;
873
- }
874
- }
875
- return this.dash.getQualityFor('video');
876
- } catch {
877
- return -1;
878
- }
879
- }
880
-
881
- handlesOwnCaptions() {
882
- return this._dashTextIsTtml;
883
- }
884
-
885
- /**
886
- * Tell dash.js to activate the text track for `lang` so it begins
887
- * downloading subtitle segments and populating cues for that language.
888
- */
889
- activateTextTrackForLanguage(lang: string): boolean {
890
- if (!this.dash || !this._dashTextTracks.length || !lang) return false;
891
-
892
- let dashIndex = this._dashTextTracks.findIndex(dt => {
893
- const dtLang = dt.lang || dt.language || dt.srclang || '';
894
- if (!dtLang) return false;
895
- return dtLang === lang || dtLang.startsWith(lang) || lang.startsWith(dtLang);
896
- });
897
-
898
- if (dashIndex < 0) {
899
- dashIndex = this._dashTextTracks.findIndex(dt => {
900
- const dtLabel = (dt.label || dt.labels || '').toString().toLowerCase();
901
- return dtLabel.includes(lang.toLowerCase());
902
- });
903
- }
904
-
905
- if (dashIndex < 0) return false;
906
-
907
- this.player.log(`Activating DASH text track index ${dashIndex} for transcript language "${lang}"`);
908
- try {
909
- this.dash.setTextTrack(dashIndex);
910
- } catch { /* ignore */ }
911
-
912
- // dash.js's text scheduler only fetches segments while the video is
913
- // playing. When paused, a brief play–pause cycle forces the scheduler
914
- // to buffer text data for the newly selected track.
915
- if (this.media.paused) {
916
- const pos = this.media.currentTime;
917
- const wasMuted = this.media.muted;
918
- this.media.muted = true;
919
- const playPromise = this.media.play();
920
- const doPause = () => {
921
- if (this.media && !this.media.paused) {
922
- this.media.pause();
923
- this.media.muted = wasMuted;
924
- // Restore position in case it drifted
925
- if (Math.abs(this.media.currentTime - pos) > 0.5) {
926
- this.media.currentTime = pos;
927
- }
928
- }
929
- };
930
- if (playPromise && typeof playPromise.then === 'function') {
931
- playPromise.then(() => {
932
- this._setTimeout(doPause, 250);
933
- }).catch(() => {
934
- this.media.muted = wasMuted;
935
- });
936
- } else {
937
- this._setTimeout(doPause, 250);
938
- }
939
- }
940
-
941
- if (!this._dashTextIsTtml) {
942
- this._lastKnownCueCount = 0;
943
- this._startCueUpdatePolling();
944
- }
945
- return true;
946
- }
947
-
948
- getTextTrackURLs(): { lang: string; url: string }[] {
949
- if (!this.dash || !this._manifestUrl) return [];
950
- try {
951
- // dash.js manifest objects are loosely shaped (XML-derived), so we
952
- // walk them through index-keyed records and narrow as we go.
953
- type ManifestNode = Record<string, unknown>;
954
- const manifest = this.dash.getManifest?.() as ManifestNode | undefined;
955
- if (!manifest) return [];
956
-
957
- const baseUrl = this._manifestUrl.substring(0, this._manifestUrl.lastIndexOf('/') + 1);
958
- const results: { lang: string; url: string }[] = [];
959
-
960
- const rawPeriods = manifest.Period || manifest.period || manifest.periods || manifest;
961
- const periods = (Array.isArray(rawPeriods) ? rawPeriods : [rawPeriods]) as ManifestNode[];
962
- for (const period of periods) {
963
- const rawAdaptSets = period.AdaptationSet || period.adaptationSet || period.AdaptationSet_asArray || [];
964
- const adaptSets = (Array.isArray(rawAdaptSets) ? rawAdaptSets : [rawAdaptSets]) as ManifestNode[];
965
- for (const as of adaptSets) {
966
- const ct = String(as.contentType || as.ContentType || '');
967
- const mime = String(as.mimeType || as.MimeType || '');
968
- if (ct !== 'text' && !/text\/vtt|application\/ttml/i.test(mime)) continue;
969
-
970
- const lang = String(as.lang || as.language || '');
971
- const rawReps = as.Representation || as.representation || as.Representation_asArray || [];
972
- const reps = (Array.isArray(rawReps) ? rawReps : [rawReps]) as ManifestNode[];
973
- for (const rep of reps) {
974
- const bu = (rep.BaseURL || rep.baseURL || rep.BaseURL_asArray) as
975
- | string
976
- | { __text?: string }
977
- | Array<string | { __text?: string }>
978
- | undefined;
979
- let rawUrl: string | undefined;
980
- if (Array.isArray(bu)) {
981
- const first = bu[0];
982
- rawUrl = typeof first === 'string' ? first : first?.__text;
983
- } else if (typeof bu === 'string') {
984
- rawUrl = bu;
985
- } else {
986
- rawUrl = bu?.__text;
987
- }
988
- if (!rawUrl) continue;
989
- const url = rawUrl.startsWith('http') ? rawUrl : new URL(rawUrl, baseUrl).href;
990
- results.push({ lang, url });
991
- break;
992
- }
993
- }
994
- }
995
- return results;
996
- } catch {
997
- return [];
998
- }
999
- }
1000
-
1001
- supportsAutoQuality() {
1002
- return true;
1003
- }
1004
-
1005
- isAutoQuality() {
1006
- if (!this.dash) return true;
1007
- try {
1008
- if (typeof this.dash.getAutoSwitchQualityFor === 'function') {
1009
- return this.dash.getAutoSwitchQualityFor('video');
1010
- }
1011
- const settings = this.dash.getSettings?.();
1012
- return settings?.streaming?.abr?.autoSwitchBitrate?.video !== false;
1013
- } catch {
1014
- return true;
1015
- }
1016
- }
1017
-
1018
- destroy() {
1019
- this._pendingTimeouts.forEach(id => clearTimeout(id));
1020
- this._pendingTimeouts = [];
1021
- this._stopCueUpdatePolling();
1022
- this._lastKnownCueCount = 0;
1023
- // Detach media listeners registered in attachMediaEvents().
1024
- this._listenerController.abort();
1025
- // Remove the deferred 'ready' listener if it never fired.
1026
- if (this._pendingReadyHandler) {
1027
- this.player.off('ready', this._pendingReadyHandler);
1028
- this._pendingReadyHandler = null;
1029
- }
1030
-
1031
- if (this._captionEnabledHandler) {
1032
- this.player.off('captionsenabled', this._captionEnabledHandler);
1033
- this._captionEnabledHandler = null;
1034
- }
1035
- if (this._captionDisabledHandler) {
1036
- this.player.off('captionsdisabled', this._captionDisabledHandler);
1037
- this._captionDisabledHandler = null;
1038
- }
1039
-
1040
- if (this._ttmlDiv && this._ttmlDiv.parentNode) {
1041
- this._ttmlDiv.parentNode.removeChild(this._ttmlDiv);
1042
- this._ttmlDiv = null;
1043
- }
1044
-
1045
- // Mark all subtitle/caption text tracks as stale. dash.js creates
1046
- // programmatic TextTrack objects that persist on the <video> element
1047
- // after destroy() and cannot be removed via standard APIs. The stale
1048
- // flag prevents _checkSubtitleTracks() in a new renderer instance from
1049
- // treating them as tracks belonging to the current stream.
1050
- const textTracks = this.media.textTracks;
1051
- for (let i = 0; i < textTracks.length; i++) {
1052
- const track = textTracks[i];
1053
- if (track && (track.kind === 'subtitles' || track.kind === 'captions')) {
1054
- track._vidplyStale = true;
1055
- track.mode = 'disabled';
1056
- }
1057
- }
1058
-
1059
- if (this.dash) {
1060
- try {
1061
- // Silence dash.js logging during teardown to suppress harmless
1062
- // SourceBuffer errors from in-flight async operations.
1063
- this.dash.updateSettings({ debug: { logLevel: 0 } });
1064
- // reset() cleanly detaches from MediaSource and cancels pending
1065
- // buffer operations before destroy() releases all resources.
1066
- this.dash.reset();
1067
- } catch { /* ignore teardown errors */ }
1068
- try {
1069
- this.dash.destroy();
1070
- } catch { /* ignore teardown errors */ }
1071
- this.dash = null;
1072
- }
1073
- this._dashTextTracks = [];
1074
- this._dashTextIsTtml = false;
1075
- this._manifestUrl = null;
1076
- }
1077
- }
1
+ import type { Renderer } from '../types/renderer.js';
2
+ import type { Player } from '../core/Player.js';
3
+ import { loadScriptOnce } from '../utils/ScriptLoader.js';
4
+
5
+ interface DashTextTrack {
6
+ lang?: string;
7
+ language?: string;
8
+ srclang?: string;
9
+ kind?: string;
10
+ label?: string;
11
+ labels?: string;
12
+ isTTML?: boolean;
13
+ codec?: string;
14
+ mimeType?: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ /**
19
+ * Shape of payloads emitted by VidPly's `captionsenabled` event when
20
+ * routed into a DASH renderer. These come from CaptionManager and carry
21
+ * both the language and the underlying TextTrack reference.
22
+ */
23
+ interface CaptionTrackSelection {
24
+ language?: string;
25
+ label?: string;
26
+ track?: TextTrack;
27
+ }
28
+
29
+ type CaptionEnabledHandler = (track: CaptionTrackSelection) => void;
30
+ type CaptionDisabledHandler = () => void;
31
+
32
+ export class DASHRenderer implements Renderer {
33
+ player: Player;
34
+ media: HTMLMediaElement;
35
+ dash: DashMediaPlayerInstance | null;
36
+ readonly isStreaming = true;
37
+ _dashSourceLoaded: boolean;
38
+ _pendingSrc: string | null;
39
+ _dashSubtitleTracksCount: number | undefined;
40
+ _dashTextTracks: DashTextTrack[];
41
+ _cueUpdateTimer: ReturnType<typeof setInterval> | null;
42
+ _captionEnabledHandler: CaptionEnabledHandler | null;
43
+ _captionDisabledHandler: CaptionDisabledHandler | null;
44
+ _lastKnownCueCount: number;
45
+ _dashTextIsTtml: boolean;
46
+ _pendingTimeouts: ReturnType<typeof setTimeout>[];
47
+ _ttmlDiv: HTMLElement | null;
48
+ _manifestUrl: string | null;
49
+ // Detaches all media listeners (attachMediaEvents) in destroy().
50
+ private _listenerController: AbortController;
51
+ // Deferred 'ready' handler from updateCaptionButtonsForStreaming, removed on
52
+ // destroy if it never fired.
53
+ private _pendingReadyHandler: (() => void) | null;
54
+
55
+ constructor(player: Player) {
56
+ this.player = player;
57
+ this.media = player.element;
58
+ this.dash = null;
59
+ this._dashSourceLoaded = false;
60
+ this._pendingSrc = null;
61
+ this._dashSubtitleTracksCount = undefined;
62
+ this._dashTextTracks = [];
63
+ this._cueUpdateTimer = null;
64
+ this._captionEnabledHandler = null;
65
+ this._captionDisabledHandler = null;
66
+ this._lastKnownCueCount = 0;
67
+ this._dashTextIsTtml = false;
68
+ this._pendingTimeouts = [];
69
+ this._ttmlDiv = null;
70
+ this._manifestUrl = null;
71
+ this._listenerController = new AbortController();
72
+ this._pendingReadyHandler = null;
73
+ }
74
+
75
+ async init() {
76
+ this.player.log('Using dash.js for DASH support');
77
+ await this.initDashJs();
78
+ }
79
+
80
+ async initDashJs() {
81
+ this.media.controls = false;
82
+ this.media.removeAttribute('controls');
83
+
84
+ if (!window.dashjs) {
85
+ await this.loadDashJs();
86
+ }
87
+
88
+ // Remove <source> children — dash.js manages the src via MSE
89
+ const sourceElements = Array.from(this.media.querySelectorAll('source'));
90
+ let originalSrc = null;
91
+ if (sourceElements.length > 0) {
92
+ originalSrc = sourceElements[0]?.getAttribute('src') ?? null;
93
+ sourceElements.forEach(source => source.remove());
94
+ this.player.log('Removed <source> elements for HTML5 validity (dash.js uses MSE)');
95
+ }
96
+
97
+ const dashjs = window.dashjs;
98
+ if (!dashjs) {
99
+ throw new Error('dash.js not available');
100
+ }
101
+ this.dash = dashjs.MediaPlayer().create();
102
+
103
+ this.dash.updateSettings({
104
+ debug: {
105
+ logLevel: this.player.options.debug ? 4 : 0
106
+ },
107
+ streaming: {
108
+ // Override dash.js default of 'lowestStartupDelay'. For audio
109
+ // AdaptationSets that tie on selectionPriority and role=main (e.g.
110
+ // Axinom's three en/en-low/en-high tracks), 'lowestStartupDelay'
111
+ // falls through to 'highestEfficiency' which, for audio, has no
112
+ // meaningful pixels-per-bit metric and collapses to "highest
113
+ // bitrate". 'firstTrack' respects manifest order instead, which is
114
+ // both predictable and closer to the MPD author's intent.
115
+ selectionModeForInitialTrack: 'firstTrack',
116
+ // NOTE on pre-play preload: we deliberately do NOT set
117
+ // streaming.scheduling.scheduleWhilePaused = false here. While that
118
+ // is the documented dash.js way to suppress segment downloads while
119
+ // paused / before the first play, in our setup (dash.js 5.2.0 +
120
+ // dash.initialize(media, null, false) + attachSource at init) it
121
+ // tears down the SourceBuffers mid-init with
122
+ // "SourceBuffer has been removed from the parent media source"
123
+ // exceptions, which leaves the player unable to seek or play. The
124
+ // PR #3785 fix that was supposed to handle the initial-playback /
125
+ // autoPlay=false case is fragile against our usage pattern.
126
+ // Instead we keep dash.js's default scheduling (scheduleWhilePaused
127
+ // stays at its default `true`) and let the buffer caps below limit
128
+ // how much is fetched before play. With a single ~6s segment size,
129
+ // the visible network preload is one init segment per track plus
130
+ // 1–2 media segments — the same "first two chunks" behavior the
131
+ // user previously confirmed as acceptable for DASH.
132
+ buffer: {
133
+ bufferTimeAtTopQuality: 30,
134
+ bufferTimeAtTopQualityLongForm: 60,
135
+ // dash.js 5.x: use bufferTimeDefault (replaces removed stableBufferTime).
136
+ // Keep at 12s — going lower (0 / 1) was tested but dash.js still
137
+ // loads the first segment regardless because it's needed to make
138
+ // the MediaSource playable, so the savings are negligible while
139
+ // hurting mid-playback resilience on slow networks.
140
+ bufferTimeDefault: 12,
141
+ bufferToKeep: 20,
142
+ bufferPruningInterval: 10
143
+ },
144
+ retryAttempts: {
145
+ MPD: 4,
146
+ MediaSegment: 6,
147
+ InitializationSegment: 4,
148
+ BitstreamSwitchingSegment: 4
149
+ },
150
+ retryIntervals: {
151
+ MPD: 1000,
152
+ MediaSegment: 1000,
153
+ InitializationSegment: 1000,
154
+ BitstreamSwitchingSegment: 1000
155
+ },
156
+ abr: {
157
+ autoSwitchBitrate: { video: true, audio: true }
158
+ },
159
+ text: {
160
+ defaultEnabled: true
161
+ }
162
+ }
163
+ });
164
+
165
+ // Create the TTML rendering div before initialize() so the DOM element
166
+ // exists, but attach it to dash.js after initialize() because
167
+ // attachTTMLRenderingDiv() requires attachView() which initialize() calls.
168
+ this._ttmlDiv = document.createElement('div');
169
+ this._ttmlDiv.className = 'vidply-dash-ttml';
170
+ this._ttmlDiv.style.visibility = 'hidden';
171
+ const wrapper = this.player.videoWrapper || this.media.parentElement;
172
+ if (wrapper) {
173
+ wrapper.appendChild(this._ttmlDiv);
174
+ }
175
+
176
+ this.dash.initialize(this.media, null, false);
177
+ this.dash.attachTTMLRenderingDiv(this._ttmlDiv);
178
+
179
+ // Resolve source URL
180
+ let src = this.player.currentSource;
181
+
182
+ if (!src && originalSrc) {
183
+ src = originalSrc;
184
+ }
185
+
186
+ if (!src) {
187
+ src = this.player.element.getAttribute('data-vidply-src');
188
+ }
189
+
190
+ if (!src) {
191
+ const elementSrc = this.player.element.getAttribute('src') || this.player.element.src;
192
+ if (elementSrc && !elementSrc.startsWith('blob:')) {
193
+ src = elementSrc;
194
+ }
195
+ }
196
+
197
+ this.player.log(`Loading DASH source: ${src}`, 'log');
198
+
199
+ if (!src) {
200
+ throw new Error('No DASH source found');
201
+ }
202
+
203
+ // Always attach the source at init so the manifest is fetched and the
204
+ // seekbar / duration / quality list are available before the first play.
205
+ // We deliberately don't honor `deferLoad` here: deferring attachSource
206
+ // would leave duration unknown and seek requests unserviceable. The
207
+ // small preload (init segments + ~1 segment of media data) that dash.js
208
+ // does up to bufferTimeDefault is acceptable; in exchange the seekbar
209
+ // becomes functional immediately.
210
+ this._pendingSrc = src;
211
+ this._manifestUrl = src;
212
+ this.dash.attachSource(src);
213
+ this._dashSourceLoaded = true;
214
+
215
+ // Force the vidply poster overlay even though dash.js will paint the
216
+ // first decoded frame into the <video> element. With MSE attached and
217
+ // init/startup segments appended, the browser ignores the native
218
+ // `poster` attribute and shows the first frame instead. We instead use
219
+ // the `vidply-forced-poster` CSS class, which sets `<video>` to
220
+ // opacity:0 and renders the poster image as a wrapper background — the
221
+ // user sees the artwork until they press play, just like on a fully
222
+ // native HTML5 video. The 'play' handler in Player.ts already calls
223
+ // hidePosterOverlay() on first playback, so we don't need to remove it
224
+ // manually here.
225
+ this.player.showPosterOverlay();
226
+
227
+ this.attachDashEvents();
228
+ this.attachMediaEvents();
229
+ this._setupCaptionSync();
230
+ }
231
+
232
+ /**
233
+ * Load dash.js. Pinned to an exact version (the previous default
234
+ * `5.2.0` is preserved) and overridable via `options.dashScriptUrl`
235
+ * (URL) / `options.dashScriptIntegrity` (SRI hash). See
236
+ * HLSRenderer.loadHlsJs() for the SRI computation command.
237
+ */
238
+ async loadDashJs(): Promise<void> {
239
+ const defaultUrl = 'https://cdn.jsdelivr.net/npm/dashjs@5.2.0/dist/modern/umd/dash.all.min.js';
240
+ const url: string = (this.player.options.dashScriptUrl as string | undefined) || defaultUrl;
241
+ const integrity = this.player.options.dashScriptIntegrity as string | undefined;
242
+
243
+ return loadScriptOnce(url, { integrity });
244
+ }
245
+
246
+ _setTimeout(fn: () => void, delay: number) {
247
+ const id = setTimeout(() => {
248
+ this._pendingTimeouts = this._pendingTimeouts.filter(t => t !== id);
249
+ fn();
250
+ }, delay);
251
+ this._pendingTimeouts.push(id);
252
+ return id;
253
+ }
254
+
255
+ attachDashEvents() {
256
+ const dashjs = window.dashjs;
257
+ const dash = this.dash;
258
+ if (!dashjs || !dash) return;
259
+ const dashEvents = dashjs.MediaPlayer.events;
260
+
261
+ dash.on(dashEvents.MANIFEST_LOADED, (...args: unknown[]) => {
262
+ const e = args[0] as { data?: unknown } | undefined;
263
+ const data = e?.data ?? e;
264
+ this.player.log('DASH manifest loaded');
265
+ this.player.emit('dashmanifestloaded', data);
266
+
267
+ if (this.player.container) {
268
+ this.player.container.classList.remove('vidply-external-controls');
269
+ }
270
+
271
+ this._setTimeout(() => {
272
+ this._checkSubtitleTracks();
273
+ }, 500);
274
+ });
275
+
276
+ dash.on(dashEvents.QUALITY_CHANGE_RENDERED, (...args: unknown[]) => {
277
+ const e = args[0] as { mediaType?: string; newQuality?: number };
278
+ if (e.mediaType === 'video') {
279
+ this.player.log('DASH quality changed to index ' + e.newQuality);
280
+ this.player.emit('dashqualitychanged', e);
281
+ }
282
+ });
283
+
284
+ dash.on(dashEvents.TEXT_TRACKS_ADDED, (...args: unknown[]) => {
285
+ const e = args[0] as { tracks?: DashTextTrack[] } | undefined;
286
+ const tracks = e?.tracks ?? [];
287
+ this._dashTextTracks = tracks;
288
+ this._dashTextIsTtml = tracks.some((t) =>
289
+ t.isTTML || /stpp|ttml/i.test(t.codec || '') || /ttml/i.test(t.mimeType || '')
290
+ );
291
+ this.player.log(`DASH text tracks added: ${tracks.length} tracks, format: ${this._dashTextIsTtml ? 'TTML' : 'WebVTT'}`);
292
+ this._dashSubtitleTracksCount = tracks.length;
293
+ this.player.emit('dashsubtitletracksupdated', { tracks });
294
+ this.updateCaptionButtonsForDash();
295
+
296
+ if (tracks.length > 0) {
297
+ try {
298
+ dash.setTextTrack(0);
299
+ } catch {
300
+ // ignore if not ready yet
301
+ }
302
+ if (!this._dashTextIsTtml) {
303
+ this._startCueUpdatePolling();
304
+ }
305
+ }
306
+ });
307
+
308
+ dash.on(dashEvents.STREAM_INITIALIZED, () => {
309
+ this.player.log('DASH stream initialized');
310
+ this.player.emit('dashstreaminitialized');
311
+
312
+ this._setTimeout(() => {
313
+ const qualities = this.getQualities();
314
+ if (qualities.length > 0) {
315
+ this.player.emit('dashmanifestparsed', { qualities });
316
+ }
317
+ }, 300);
318
+ });
319
+
320
+ dash.on(dashEvents.ERROR, (...args: unknown[]) => {
321
+ this.handleDashError(args[0]);
322
+ });
323
+
324
+ dash.on(dashEvents.FRAGMENT_LOADING_COMPLETED, (...args: unknown[]) => {
325
+ const e = args[0] as { request?: { mediaType?: string } } | undefined;
326
+ this.player.state.buffering = false;
327
+ if (e?.request?.mediaType === 'text' && !this._dashTextIsTtml) {
328
+ this._setTimeout(() => {
329
+ const count = this._getTotalCueCount();
330
+ if (count > this._lastKnownCueCount) {
331
+ this._lastKnownCueCount = count;
332
+ this.player.emit('textcuesupdate');
333
+ }
334
+ }, 100);
335
+ }
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Count total cues across all subtitle/caption tracks (for WebVTT DASH).
341
+ */
342
+ _getTotalCueCount() {
343
+ const textTracks = this.media.textTracks;
344
+ let total = 0;
345
+ if (!textTracks) return total;
346
+ for (let i = 0; i < textTracks.length; i++) {
347
+ const track = textTracks[i];
348
+ if (track && (track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale && track.cues) {
349
+ total += track.cues.length;
350
+ }
351
+ }
352
+ return total;
353
+ }
354
+
355
+ /**
356
+ * Return true if `time` falls inside any TimeRange the SourceBuffer already
357
+ * holds, with a small tolerance to absorb GOP boundaries. Used by the
358
+ * seeking handler to decide whether to surface a 'waiting' event for the
359
+ * spinner UI when the user scrubs while paused.
360
+ */
361
+ _isTimeBuffered(time: number): boolean {
362
+ const buffered = this.media.buffered;
363
+ if (!buffered || buffered.length === 0) return false;
364
+ const tolerance = 0.25;
365
+ for (let i = 0; i < buffered.length; i++) {
366
+ if (time >= buffered.start(i) - tolerance && time <= buffered.end(i) + tolerance) {
367
+ return true;
368
+ }
369
+ }
370
+ return false;
371
+ }
372
+
373
+ /**
374
+ * Sync VidPly caption track switches with dash.js so it loads
375
+ * subtitle segments for the selected language.
376
+ */
377
+ _setupCaptionSync() {
378
+ this._captionEnabledHandler = (selectedTrack) => {
379
+ if (this._dashTextIsTtml) {
380
+ // dash.js only renders TTML content to the rendering div when the
381
+ // TextTrack mode is 'showing'. CaptionManager sets it to 'hidden',
382
+ // so we override it here. VidPly's own caption overlay is skipped
383
+ // via handlesOwnCaptions().
384
+ if (selectedTrack.track) {
385
+ selectedTrack.track.mode = 'showing';
386
+ }
387
+ if (this._ttmlDiv) {
388
+ this._ttmlDiv.style.visibility = 'visible';
389
+ }
390
+ }
391
+ this._syncDashTextTrack(selectedTrack);
392
+ };
393
+ this._captionDisabledHandler = () => {
394
+ if (this._dashTextIsTtml && this._ttmlDiv) {
395
+ this._ttmlDiv.style.visibility = 'hidden';
396
+ }
397
+ if (this.dash) {
398
+ try { this.dash.setTextTrack(-1); } catch { /* ignore */ }
399
+ }
400
+ };
401
+ this.player.on('captionsenabled', this._captionEnabledHandler);
402
+ this.player.on('captionsdisabled', this._captionDisabledHandler);
403
+ }
404
+
405
+ /**
406
+ * Map a VidPly caption track to the corresponding dash.js track index
407
+ * and switch dash.js to load segments for that language.
408
+ */
409
+ _syncDashTextTrack(selectedTrack: CaptionTrackSelection) {
410
+ if (!this.dash || !this._dashTextTracks.length) return;
411
+
412
+ const lang = selectedTrack.language;
413
+ if (!lang) return;
414
+
415
+ const dashIndex = this._dashTextTracks.findIndex(dt => {
416
+ const dtLang = dt.lang || dt.language || dt.srclang || '';
417
+ if (!dtLang) return false;
418
+ return dtLang === lang || dtLang.startsWith(lang) || lang.startsWith(dtLang);
419
+ });
420
+
421
+ if (dashIndex >= 0) {
422
+ this.player.log(`Syncing DASH text track to index ${dashIndex} (${lang})`);
423
+ try {
424
+ this.dash.setTextTrack(dashIndex);
425
+ } catch { /* ignore */ }
426
+ if (!this._dashTextIsTtml) {
427
+ this._lastKnownCueCount = 0;
428
+ this._startCueUpdatePolling();
429
+ }
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Poll for new WebVTT cues being added by dash.js as subtitle segments load.
435
+ * Emits events for transcript refresh when new cues arrive.
436
+ */
437
+ _startCueUpdatePolling() {
438
+ this._stopCueUpdatePolling();
439
+ let prevCueCount = 0;
440
+ let stableRounds = 0;
441
+
442
+ this._cueUpdateTimer = setInterval(() => {
443
+ const count = this._getTotalCueCount();
444
+
445
+ if (count > prevCueCount) {
446
+ prevCueCount = count;
447
+ stableRounds = 0;
448
+ this.player.emit('textcuesupdate');
449
+ } else {
450
+ stableRounds++;
451
+ if (stableRounds >= 8) {
452
+ this._stopCueUpdatePolling();
453
+ if (count > 0) {
454
+ this.player.emit('textcuesupdate');
455
+ }
456
+ }
457
+ }
458
+ }, 500);
459
+ }
460
+
461
+ _stopCueUpdatePolling() {
462
+ if (this._cueUpdateTimer) {
463
+ clearInterval(this._cueUpdateTimer);
464
+ this._cueUpdateTimer = null;
465
+ }
466
+ }
467
+
468
+ _checkSubtitleTracks() {
469
+ if (this._dashSubtitleTracksCount !== undefined && this._dashSubtitleTracksCount > 0) {
470
+ return;
471
+ }
472
+ const tracks = this.media.textTracks;
473
+ let count = 0;
474
+ for (let i = 0; i < tracks.length; i++) {
475
+ const track = tracks[i];
476
+ if (track && (track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
477
+ count++;
478
+ }
479
+ }
480
+ this._dashSubtitleTracksCount = count;
481
+ this.updateCaptionButtonsForDash();
482
+ }
483
+
484
+ updateCaptionButtonsForDash(retryCount = 0) {
485
+ const tracksCount = this._dashSubtitleTracksCount || 0;
486
+
487
+ const doUpdate = () => {
488
+ this.player.invalidateTrackCache();
489
+
490
+ if (tracksCount > 0) {
491
+ if (this.player.captionManager) {
492
+ const found = this.player.captionManager.refreshTracks();
493
+
494
+ // dash.js fires TEXT_TRACKS_ADDED when it knows about the
495
+ // tracks, but the browser TextTrackList may not yet enumerate
496
+ // the new TextTrack objects synchronously. If refreshTracks
497
+ // found nothing, retry a few times with increasing delays.
498
+ if (found === 0 && retryCount < 5) {
499
+ const delay = (retryCount + 1) * 200;
500
+ this.player.log(`DASH caption tracks not yet on video element, retrying in ${delay}ms (attempt ${retryCount + 1})`, 'info');
501
+ this._setTimeout(() => {
502
+ this.updateCaptionButtonsForDash(retryCount + 1);
503
+ }, delay);
504
+ return;
505
+ }
506
+ }
507
+
508
+ if (!this._dashTextIsTtml && this.player.transcriptManager?.isVisible) {
509
+ this.player.transcriptManager.loadTranscriptData();
510
+ this.player.transcriptManager.updateLanguageSelector();
511
+ }
512
+
513
+ if (this.player.controlBar) {
514
+ this.player.controlBar.ensureCaptionsButton();
515
+ if (!this._dashTextIsTtml) {
516
+ this.player.controlBar.ensureCaptionStyleButton();
517
+ this.player.controlBar.ensureTranscriptButton();
518
+ }
519
+ }
520
+ } else {
521
+ if (this.player.captionManager) {
522
+ this.player.captionManager.refreshTracks();
523
+ }
524
+
525
+ if (this.player.transcriptManager?.isVisible) {
526
+ this.player.transcriptManager.hideTranscript();
527
+ }
528
+
529
+ if (this.player.controlBar) {
530
+ this.player.controlBar.removeHlsCaptionButtons(true);
531
+ }
532
+ }
533
+ };
534
+
535
+ if (this.player.controlBar) {
536
+ doUpdate();
537
+ return;
538
+ }
539
+
540
+ const onReady = () => {
541
+ this.player.off('ready', onReady);
542
+ this._pendingReadyHandler = null;
543
+ doUpdate();
544
+ };
545
+ // Track so destroy() can detach it if 'ready' never fires.
546
+ this._pendingReadyHandler = onReady;
547
+ this.player.on('ready', onReady);
548
+ }
549
+
550
+ attachMediaEvents() {
551
+ const { signal } = this._listenerController;
552
+
553
+ this.media.addEventListener('loadedmetadata', () => {
554
+ this.player.state.duration = this.media.duration;
555
+ this.player.emit('loadedmetadata');
556
+ }, { signal });
557
+
558
+ this.media.addEventListener('durationchange', () => {
559
+ const duration = this.media.duration;
560
+ if (duration && isFinite(duration) && duration > 0) {
561
+ this.player.state.duration = duration;
562
+ this.player.emit('durationchange', duration);
563
+ }
564
+ }, { signal });
565
+
566
+ this.media.addEventListener('play', () => {
567
+ this.player.state.playing = true;
568
+ this.player.state.paused = false;
569
+ this.player.state.ended = false;
570
+ this.player.emit('play');
571
+
572
+ if (this.player.options.onPlay) {
573
+ this.player.options.onPlay.call(this.player);
574
+ }
575
+ }, { signal });
576
+
577
+ this.media.addEventListener('pause', () => {
578
+ this.player.state.playing = false;
579
+ this.player.state.paused = true;
580
+ this.player.emit('pause');
581
+
582
+ if (this.player.options.onPause) {
583
+ this.player.options.onPause.call(this.player);
584
+ }
585
+ }, { signal });
586
+
587
+ this.media.addEventListener('ended', () => {
588
+ this.player.state.playing = false;
589
+ this.player.state.paused = true;
590
+ this.player.state.ended = true;
591
+ this.player.emit('ended');
592
+
593
+ if (this.player.options.onEnded) {
594
+ this.player.options.onEnded.call(this.player);
595
+ }
596
+
597
+ if (this.player.options.loop) {
598
+ this.player.seek(0);
599
+ this.player.play();
600
+ }
601
+ }, { signal });
602
+
603
+ this.media.addEventListener('timeupdate', () => {
604
+ this.player.state.currentTime = this.media.currentTime;
605
+ this.player.emit('timeupdate', this.media.currentTime);
606
+
607
+ if (this.player.options.onTimeUpdate) {
608
+ this.player.options.onTimeUpdate.call(this.player, this.media.currentTime);
609
+ }
610
+ }, { signal });
611
+
612
+ this.media.addEventListener('volumechange', () => {
613
+ this.player.state.volume = this.media.volume;
614
+ this.player.state.muted = this.media.muted;
615
+ this.player.emit('volumechange', this.media.volume);
616
+ }, { signal });
617
+
618
+ this.media.addEventListener('seeking', () => {
619
+ this.player.state.seeking = true;
620
+ this.player.emit('seeking');
621
+
622
+ // Browsers do not fire `waiting` when seeking on a paused media element
623
+ // — there is nothing to "wait" for since playback isn't requested. With
624
+ // dash.js + MSE, scrubbing while paused (or before the first play)
625
+ // still triggers a real fragment download, but the spinner stays hidden
626
+ // because no `waiting` event reaches the buffering UI. Detect a seek
627
+ // into an unbuffered range here and surface it as 'waiting' so the
628
+ // spinner appears. Cleared again by the existing `canplay` /
629
+ // `seeked` paths.
630
+ if (!this._isTimeBuffered(this.media.currentTime)) {
631
+ this.player.state.buffering = true;
632
+ this.player.emit('waiting');
633
+ }
634
+ }, { signal });
635
+
636
+ this.media.addEventListener('seeked', () => {
637
+ this.player.state.seeking = false;
638
+ this.player.emit('seeked');
639
+ }, { signal });
640
+
641
+ this.media.addEventListener('waiting', () => {
642
+ this.player.state.buffering = true;
643
+ this.player.emit('waiting');
644
+ }, { signal });
645
+
646
+ this.media.addEventListener('canplay', () => {
647
+ this.player.state.buffering = false;
648
+ this.player.emit('canplay');
649
+ }, { signal });
650
+
651
+ this.media.addEventListener('error', () => {
652
+ this.player.handleError(this.media.error);
653
+ }, { signal });
654
+ }
655
+
656
+ handleDashError(e: unknown) {
657
+ const wrapped = e as { error?: { code?: number; message?: string }; code?: number; message?: string } | undefined;
658
+ const error = wrapped?.error ?? wrapped;
659
+ if (!error) return;
660
+ const code = error.code ?? '';
661
+ const message = error.message || '';
662
+ this.player.log(`DASH Error - Code: ${code}, Message: ${message}`, 'warn');
663
+
664
+ if (typeof code === 'number' && code >= 100) {
665
+ this.player.log('Fatal DASH error', 'error');
666
+ this.player.handleError(new Error(`DASH Error: ${code} - ${message}`));
667
+ } else {
668
+ this.player.log('Non-fatal DASH error: ' + (message || String(error)), 'warn');
669
+ }
670
+ }
671
+
672
+ ensureLoaded() {
673
+ if (!this.player.options.deferLoad) {
674
+ return;
675
+ }
676
+
677
+ if (!this.dash) {
678
+ return;
679
+ }
680
+
681
+ if (this._dashSourceLoaded) {
682
+ return;
683
+ }
684
+
685
+ const src = this._pendingSrc || this.player._pendingSource || this.player.currentSource;
686
+ if (!src) {
687
+ return;
688
+ }
689
+
690
+ try {
691
+ this.dash.attachSource(src);
692
+ this._dashSourceLoaded = true;
693
+ } catch {
694
+ // ignore
695
+ }
696
+ }
697
+
698
+ play() {
699
+ const scrollX = window.scrollX;
700
+ const scrollY = window.scrollY;
701
+
702
+ // Defer-load (legacy code path, kept for completeness): the source is
703
+ // normally attached at init and dash.js is configured with
704
+ // scheduling.scheduleWhilePaused: false to keep prefetch at zero until
705
+ // play. If for any reason the source is still pending here, attach it
706
+ // now. Note that media.play() is called immediately afterwards; on
707
+ // modern dash.js this races cleanly because attachSource() is sync
708
+ // enough for the MediaSource to be ready by the time the play promise
709
+ // resolves, and the configured zero-prefetch settings make the race
710
+ // window very short. The general "no preload" UX no longer relies on
711
+ // this branch.
712
+ if (this.player.options.deferLoad && this.dash && !this._dashSourceLoaded) {
713
+ const src = this._pendingSrc || this.player.currentSource;
714
+ if (src) {
715
+ try {
716
+ this.dash.attachSource(src);
717
+ this._dashSourceLoaded = true;
718
+ } catch {
719
+ // ignore and let media.play() surface errors if any
720
+ }
721
+ }
722
+ }
723
+
724
+ const promise = this.media.play();
725
+
726
+ window.scrollTo(scrollX, scrollY);
727
+
728
+ if (promise !== undefined) {
729
+ promise.catch(error => {
730
+ this.player.log('Play failed:', error, 'warn');
731
+ });
732
+ }
733
+ }
734
+
735
+ pause() {
736
+ // dash.js stops scheduling new fragment downloads once `paused` is true,
737
+ // because we configured `streaming.scheduling.scheduleWhilePaused: false`
738
+ // at init. Already-buffered data stays in the SourceBuffer, so a
739
+ // subsequent play() resumes near-instantly while pause saves bandwidth.
740
+ this.media.pause();
741
+ }
742
+
743
+ seek(time: number) {
744
+ this.media.currentTime = time;
745
+ }
746
+
747
+ setVolume(volume: number) {
748
+ this.media.volume = volume;
749
+ }
750
+
751
+ setMuted(muted: boolean) {
752
+ this.media.muted = muted;
753
+ }
754
+
755
+ setPlaybackSpeed(speed: number) {
756
+ this.media.playbackRate = speed;
757
+ }
758
+
759
+ switchQuality(qualityIndex: number) {
760
+ if (!this.dash) return;
761
+
762
+ if (qualityIndex === -1) {
763
+ // Re-enable ABR auto-switching
764
+ if (typeof this.dash.setAutoSwitchQualityFor === 'function') {
765
+ this.dash.setAutoSwitchQualityFor('video', true);
766
+ } else {
767
+ this.dash.updateSettings({
768
+ streaming: { abr: { autoSwitchBitrate: { video: true } } }
769
+ });
770
+ }
771
+ } else {
772
+ // Disable ABR and lock to the chosen quality
773
+ if (typeof this.dash.setAutoSwitchQualityFor === 'function') {
774
+ this.dash.setAutoSwitchQualityFor('video', false);
775
+ } else {
776
+ this.dash.updateSettings({
777
+ streaming: { abr: { autoSwitchBitrate: { video: false } } }
778
+ });
779
+ }
780
+
781
+ if (typeof this.dash.setRepresentationForTypeByIndex === 'function') {
782
+ this.dash.setRepresentationForTypeByIndex('video', qualityIndex);
783
+ } else if (typeof this.dash.setQualityFor === 'function') {
784
+ this.dash.setQualityFor('video', qualityIndex, true);
785
+ }
786
+ }
787
+ }
788
+
789
+ getQualities() {
790
+ if (!this.dash) return [];
791
+
792
+ try {
793
+ // dash.js v5+: getRepresentationsByType
794
+ let reps = null;
795
+ if (typeof this.dash.getRepresentationsByType === 'function') {
796
+ reps = this.dash.getRepresentationsByType('video');
797
+ }
798
+
799
+ if (reps && reps.length > 0) {
800
+ const heightCounts: Record<number, number> = {};
801
+ reps.forEach((r: DashRepresentation) => {
802
+ const h = Number(r.height) || 0;
803
+ heightCounts[h] = (heightCounts[h] || 0) + 1;
804
+ });
805
+
806
+ return reps.map((rep: DashRepresentation, index: number) => {
807
+ const height = Number(rep.height) || 0;
808
+ const bitrate = Number(rep.bandwidth || rep.bitrate) || 0;
809
+ const kb = bitrate > 0 ? Math.round(bitrate / 1000) : 0;
810
+ let name;
811
+ if (height > 0 && (heightCounts[height] ?? 0) > 1 && kb > 0) {
812
+ name = `${height}p (${kb} kbps)`;
813
+ } else if (height > 0) {
814
+ name = `${height}p`;
815
+ } else {
816
+ name = kb > 0 ? `${kb} kbps` : 'Auto';
817
+ }
818
+ return {
819
+ index,
820
+ id: rep.id,
821
+ height: rep.height,
822
+ width: rep.width,
823
+ bitrate,
824
+ name
825
+ };
826
+ });
827
+ }
828
+
829
+ // Fallback: dash.js v4 and earlier
830
+ const bitrateList = this.dash.getBitrateInfoListFor('video');
831
+ if (!bitrateList || bitrateList.length === 0) return [];
832
+
833
+ const heightCounts: Record<number, number> = {};
834
+ bitrateList.forEach((info: DashBitrateInfo) => {
835
+ const h = Number(info.height) || 0;
836
+ heightCounts[h] = (heightCounts[h] || 0) + 1;
837
+ });
838
+
839
+ return bitrateList.map((info: DashBitrateInfo, index: number) => {
840
+ const height = Number(info.height) || 0;
841
+ const bitrate = Number(info.bitrate) || 0;
842
+ const kb = bitrate > 0 ? Math.round(bitrate / 1000) : 0;
843
+ let name;
844
+ if (height > 0 && (heightCounts[height] ?? 0) > 1 && kb > 0) {
845
+ name = `${height}p (${kb} kbps)`;
846
+ } else if (height > 0) {
847
+ name = `${height}p`;
848
+ } else {
849
+ name = kb > 0 ? `${kb} kbps` : 'Auto';
850
+ }
851
+ return {
852
+ index,
853
+ height: info.height,
854
+ width: info.width,
855
+ bitrate: info.bitrate,
856
+ name
857
+ };
858
+ });
859
+ } catch {
860
+ return [];
861
+ }
862
+ }
863
+
864
+ getCurrentQuality() {
865
+ if (!this.dash) return -1;
866
+ try {
867
+ if (typeof this.dash.getRepresentationsByType === 'function') {
868
+ const reps = this.dash.getRepresentationsByType('video');
869
+ const current = this.dash.getCurrentRepresentationForType?.('video');
870
+ if (current && reps) {
871
+ const idx = reps.findIndex((r: DashRepresentation) => r.id === current.id);
872
+ if (idx >= 0) return idx;
873
+ }
874
+ }
875
+ return this.dash.getQualityFor('video');
876
+ } catch {
877
+ return -1;
878
+ }
879
+ }
880
+
881
+ handlesOwnCaptions() {
882
+ return this._dashTextIsTtml;
883
+ }
884
+
885
+ /**
886
+ * Tell dash.js to activate the text track for `lang` so it begins
887
+ * downloading subtitle segments and populating cues for that language.
888
+ */
889
+ activateTextTrackForLanguage(lang: string): boolean {
890
+ if (!this.dash || !this._dashTextTracks.length || !lang) return false;
891
+
892
+ let dashIndex = this._dashTextTracks.findIndex(dt => {
893
+ const dtLang = dt.lang || dt.language || dt.srclang || '';
894
+ if (!dtLang) return false;
895
+ return dtLang === lang || dtLang.startsWith(lang) || lang.startsWith(dtLang);
896
+ });
897
+
898
+ if (dashIndex < 0) {
899
+ dashIndex = this._dashTextTracks.findIndex(dt => {
900
+ const dtLabel = (dt.label || dt.labels || '').toString().toLowerCase();
901
+ return dtLabel.includes(lang.toLowerCase());
902
+ });
903
+ }
904
+
905
+ if (dashIndex < 0) return false;
906
+
907
+ this.player.log(`Activating DASH text track index ${dashIndex} for transcript language "${lang}"`);
908
+ try {
909
+ this.dash.setTextTrack(dashIndex);
910
+ } catch { /* ignore */ }
911
+
912
+ // dash.js's text scheduler only fetches segments while the video is
913
+ // playing. When paused, a brief play–pause cycle forces the scheduler
914
+ // to buffer text data for the newly selected track.
915
+ if (this.media.paused) {
916
+ const pos = this.media.currentTime;
917
+ const wasMuted = this.media.muted;
918
+ this.media.muted = true;
919
+ const playPromise = this.media.play();
920
+ const doPause = () => {
921
+ if (this.media && !this.media.paused) {
922
+ this.media.pause();
923
+ this.media.muted = wasMuted;
924
+ // Restore position in case it drifted
925
+ if (Math.abs(this.media.currentTime - pos) > 0.5) {
926
+ this.media.currentTime = pos;
927
+ }
928
+ }
929
+ };
930
+ if (playPromise && typeof playPromise.then === 'function') {
931
+ playPromise.then(() => {
932
+ this._setTimeout(doPause, 250);
933
+ }).catch(() => {
934
+ this.media.muted = wasMuted;
935
+ });
936
+ } else {
937
+ this._setTimeout(doPause, 250);
938
+ }
939
+ }
940
+
941
+ if (!this._dashTextIsTtml) {
942
+ this._lastKnownCueCount = 0;
943
+ this._startCueUpdatePolling();
944
+ }
945
+ return true;
946
+ }
947
+
948
+ getTextTrackURLs(): { lang: string; url: string }[] {
949
+ if (!this.dash || !this._manifestUrl) return [];
950
+ try {
951
+ // dash.js manifest objects are loosely shaped (XML-derived), so we
952
+ // walk them through index-keyed records and narrow as we go.
953
+ type ManifestNode = Record<string, unknown>;
954
+ const manifest = this.dash.getManifest?.() as ManifestNode | undefined;
955
+ if (!manifest) return [];
956
+
957
+ const baseUrl = this._manifestUrl.substring(0, this._manifestUrl.lastIndexOf('/') + 1);
958
+ const results: { lang: string; url: string }[] = [];
959
+
960
+ const rawPeriods = manifest.Period || manifest.period || manifest.periods || manifest;
961
+ const periods = (Array.isArray(rawPeriods) ? rawPeriods : [rawPeriods]) as ManifestNode[];
962
+ for (const period of periods) {
963
+ const rawAdaptSets = period.AdaptationSet || period.adaptationSet || period.AdaptationSet_asArray || [];
964
+ const adaptSets = (Array.isArray(rawAdaptSets) ? rawAdaptSets : [rawAdaptSets]) as ManifestNode[];
965
+ for (const as of adaptSets) {
966
+ const ct = String(as.contentType || as.ContentType || '');
967
+ const mime = String(as.mimeType || as.MimeType || '');
968
+ if (ct !== 'text' && !/text\/vtt|application\/ttml/i.test(mime)) continue;
969
+
970
+ const lang = String(as.lang || as.language || '');
971
+ const rawReps = as.Representation || as.representation || as.Representation_asArray || [];
972
+ const reps = (Array.isArray(rawReps) ? rawReps : [rawReps]) as ManifestNode[];
973
+ for (const rep of reps) {
974
+ const bu = (rep.BaseURL || rep.baseURL || rep.BaseURL_asArray) as
975
+ | string
976
+ | { __text?: string }
977
+ | Array<string | { __text?: string }>
978
+ | undefined;
979
+ let rawUrl: string | undefined;
980
+ if (Array.isArray(bu)) {
981
+ const first = bu[0];
982
+ rawUrl = typeof first === 'string' ? first : first?.__text;
983
+ } else if (typeof bu === 'string') {
984
+ rawUrl = bu;
985
+ } else {
986
+ rawUrl = bu?.__text;
987
+ }
988
+ if (!rawUrl) continue;
989
+ const url = rawUrl.startsWith('http') ? rawUrl : new URL(rawUrl, baseUrl).href;
990
+ results.push({ lang, url });
991
+ break;
992
+ }
993
+ }
994
+ }
995
+ return results;
996
+ } catch {
997
+ return [];
998
+ }
999
+ }
1000
+
1001
+ supportsAutoQuality() {
1002
+ return true;
1003
+ }
1004
+
1005
+ isAutoQuality() {
1006
+ if (!this.dash) return true;
1007
+ try {
1008
+ if (typeof this.dash.getAutoSwitchQualityFor === 'function') {
1009
+ return this.dash.getAutoSwitchQualityFor('video');
1010
+ }
1011
+ const settings = this.dash.getSettings?.();
1012
+ return settings?.streaming?.abr?.autoSwitchBitrate?.video !== false;
1013
+ } catch {
1014
+ return true;
1015
+ }
1016
+ }
1017
+
1018
+ destroy() {
1019
+ this._pendingTimeouts.forEach(id => clearTimeout(id));
1020
+ this._pendingTimeouts = [];
1021
+ this._stopCueUpdatePolling();
1022
+ this._lastKnownCueCount = 0;
1023
+ // Detach media listeners registered in attachMediaEvents().
1024
+ this._listenerController.abort();
1025
+ // Remove the deferred 'ready' listener if it never fired.
1026
+ if (this._pendingReadyHandler) {
1027
+ this.player.off('ready', this._pendingReadyHandler);
1028
+ this._pendingReadyHandler = null;
1029
+ }
1030
+
1031
+ if (this._captionEnabledHandler) {
1032
+ this.player.off('captionsenabled', this._captionEnabledHandler);
1033
+ this._captionEnabledHandler = null;
1034
+ }
1035
+ if (this._captionDisabledHandler) {
1036
+ this.player.off('captionsdisabled', this._captionDisabledHandler);
1037
+ this._captionDisabledHandler = null;
1038
+ }
1039
+
1040
+ if (this._ttmlDiv && this._ttmlDiv.parentNode) {
1041
+ this._ttmlDiv.parentNode.removeChild(this._ttmlDiv);
1042
+ this._ttmlDiv = null;
1043
+ }
1044
+
1045
+ // Mark all subtitle/caption text tracks as stale. dash.js creates
1046
+ // programmatic TextTrack objects that persist on the <video> element
1047
+ // after destroy() and cannot be removed via standard APIs. The stale
1048
+ // flag prevents _checkSubtitleTracks() in a new renderer instance from
1049
+ // treating them as tracks belonging to the current stream.
1050
+ const textTracks = this.media.textTracks;
1051
+ for (let i = 0; i < textTracks.length; i++) {
1052
+ const track = textTracks[i];
1053
+ if (track && (track.kind === 'subtitles' || track.kind === 'captions')) {
1054
+ track._vidplyStale = true;
1055
+ track.mode = 'disabled';
1056
+ }
1057
+ }
1058
+
1059
+ if (this.dash) {
1060
+ try {
1061
+ // Silence dash.js logging during teardown to suppress harmless
1062
+ // SourceBuffer errors from in-flight async operations.
1063
+ this.dash.updateSettings({ debug: { logLevel: 0 } });
1064
+ // reset() cleanly detaches from MediaSource and cancels pending
1065
+ // buffer operations before destroy() releases all resources.
1066
+ this.dash.reset();
1067
+ } catch { /* ignore teardown errors */ }
1068
+ try {
1069
+ this.dash.destroy();
1070
+ } catch { /* ignore teardown errors */ }
1071
+ this.dash = null;
1072
+ }
1073
+ this._dashTextTracks = [];
1074
+ this._dashTextIsTtml = false;
1075
+ this._manifestUrl = null;
1076
+ }
1077
+ }