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,2426 +1,2426 @@
1
- /**
2
- * Transcript Manager Component
3
- * Manages transcript display and interaction
4
- */
5
-
6
- import { DOMUtils } from '../utils/DOMUtils.js';
7
- import { TimeUtils } from '../utils/TimeUtils.js';
8
- import { createIconElement } from '../icons/Icons.js';
9
- import { i18n } from '../i18n/i18n.js';
10
- import { StorageManager } from '../utils/StorageManager.js';
11
- import { focusElement } from '../utils/FocusUtils.js';
12
- import { createMenuItem } from '../utils/MenuUtils.js';
13
- import { DraggableResizable } from '../utils/DraggableResizable.js';
14
- import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
15
- import { DraggablePanel } from '../utils/DraggablePanel.js';
16
- import { deriveTrackLabel } from '../utils/TrackLabelUtils.js';
17
- import type { Player } from '../core/Player.js';
18
- import type { Renderer } from '../types/renderer.js';
19
-
20
- type TranscriptCue = TextTrackCue;
21
- type TranscriptTrack = TextTrack & { _vidplyStale?: boolean };
22
- type TranscriptLanguageInfo = {
23
- language: string;
24
- label: string;
25
- track: TranscriptTrack;
26
- };
27
- type TranscriptCueItem = {
28
- cue: TranscriptCue;
29
- type: 'caption' | 'description';
30
- };
31
- type TranscriptEntry = {
32
- element: HTMLElement;
33
- cue: TranscriptCue;
34
- type: 'caption' | 'description';
35
- startTime: number;
36
- endTime: number;
37
- };
38
-
39
- interface TranscriptStyleOptions {
40
- fontSize: string;
41
- fontFamily: string;
42
- color: string;
43
- backgroundColor: string;
44
- opacity: number;
45
- [key: string]: unknown;
46
- }
47
-
48
- interface TranscriptHandlers {
49
- timeupdate: () => void;
50
- seeked: () => void;
51
- audiodescriptionenabled: () => void;
52
- audiodescriptiondisabled: () => void;
53
- textcuesupdate: (() => void) | null;
54
- resize: (() => void) | null;
55
- settingsClick: ((e: MouseEvent) => void) | null;
56
- settingsKeydown: ((e: KeyboardEvent) => void) | null;
57
- documentClick: ((e: MouseEvent) => void) | null;
58
- styleDialogKeydown: ((e: KeyboardEvent) => void) | null;
59
- floatingchange: ((state: 'pinned' | 'auto' | null) => void) | null;
60
- }
61
-
62
- type TimerHandle = ReturnType<typeof setTimeout>;
63
-
64
- export class TranscriptManager {
65
- player: Player;
66
- _cueUpdateTimeout: TimerHandle | null;
67
- autoscrollCheckbox: HTMLInputElement | null = null;
68
- autoscrollEnabled: boolean;
69
- availableTranscriptLanguages: TranscriptLanguageInfo[];
70
- currentActiveEntry: TranscriptEntry | null;
71
- currentTranscriptLanguage: string | null;
72
- customKeyHandler: ((e: KeyboardEvent) => void) | null = null;
73
- /**
74
- * True once the style-dialog's outside-click listener has been
75
- * attached. The settings-menu's outside-click listener is now
76
- * owned by {@link DraggablePanel} and tracked there; this flag
77
- * covers the dialog half of the shared `handlers.documentClick`.
78
- */
79
- documentClickHandlerAdded: boolean = false;
80
- draggableResizable: DraggableResizable | null;
81
- handlers: TranscriptHandlers = {} as TranscriptHandlers;
82
- headerLeft: HTMLElement | null = null;
83
- isVisible: boolean;
84
- languageLabel: HTMLElement | null;
85
- languageSelector: HTMLSelectElement | null;
86
- languageSelectorHandler: ((e: Event) => void) | null;
87
- languageSelectorWrapper: HTMLElement | null = null;
88
- liveRegion: HTMLElement | null;
89
- metadataCueChangeHandler: (() => void) | null = null;
90
- metadataCues: TextTrackCue[];
91
- settingsButton: HTMLButtonElement | null;
92
- showTimestamps: boolean;
93
- showTimestampsButton: HTMLElement | null = null;
94
- showTimestampsText: Element | null = null;
95
- storage: StorageManager;
96
- styleDialog: HTMLElement | null;
97
- styleDialogJustOpened: boolean;
98
- styleDialogVisible: boolean;
99
- timeouts: Set<TimerHandle>;
100
- transcriptContent: HTMLElement | null = null;
101
- transcriptEntries: TranscriptEntry[];
102
- transcriptHeader: HTMLElement | null = null;
103
- transcriptResizeHandles: HTMLElement[];
104
- transcriptStyle: TranscriptStyleOptions;
105
- transcriptWindow: HTMLElement | null;
106
- _dashActiveLang: string | null;
107
- _vttCache: Map<string, TranscriptCueItem[]>;
108
-
109
- /**
110
- * Owns the settings-menu DOM scaffold, its outside-click
111
- * dismissal, keyboard navigation, viewport-aware positioning,
112
- * and the drag-mode / resize-mode toggle items. Instantiated
113
- * lazily once the header is built in {@link createTranscriptHeader}.
114
- */
115
- private _panel: DraggablePanel | null = null;
116
-
117
- // Back-compat getters for panel-owned state. External callers and
118
- // internal reads (e.g. `this.settingsMenuVisible` inside other
119
- // transcript methods) keep reading the same properties; the
120
- // setters are no-ops because the panel is now the authoritative
121
- // owner of those values.
122
- get settingsMenu(): HTMLElement | null {
123
- return this._panel?.settingsMenu ?? null;
124
- }
125
- set settingsMenu(_v: HTMLElement | null) {
126
- // panel-owned.
127
- }
128
-
129
- get settingsMenuVisible(): boolean {
130
- return this._panel?.settingsMenuVisible ?? false;
131
- }
132
- set settingsMenuVisible(_v: boolean) {
133
- // panel-owned.
134
- }
135
-
136
- get settingsMenuJustOpened(): boolean {
137
- return this._panel?.justOpened ?? false;
138
- }
139
- set settingsMenuJustOpened(_v: boolean) {
140
- // panel-owned.
141
- }
142
-
143
- get dragOptionButton(): HTMLElement | null {
144
- return this._panel?.dragOptionButton ?? null;
145
- }
146
- get dragOptionText(): Element | null {
147
- return this._panel?.dragOptionText ?? null;
148
- }
149
- get resizeOptionButton(): HTMLElement | null {
150
- return this._panel?.resizeOptionButton ?? null;
151
- }
152
- get resizeOptionText(): Element | null {
153
- return this._panel?.resizeOptionText ?? null;
154
- }
155
-
156
- constructor(player: Player) {
157
- this.player = player;
158
- this.transcriptWindow = null;
159
- this.transcriptEntries = [];
160
- this.metadataCues = [];
161
- this.currentActiveEntry = null;
162
- this.isVisible = false;
163
-
164
- // Storage manager
165
- this.storage = new StorageManager('vidply');
166
-
167
- // Draggable/Resizable utility
168
- this.draggableResizable = null;
169
- this.settingsButton = null;
170
-
171
- // Resize mode state
172
- this.transcriptResizeHandles = [];
173
- this.liveRegion = null;
174
-
175
- // Style dialog state
176
- this.styleDialog = null;
177
- this.styleDialogVisible = false;
178
- this.styleDialogJustOpened = false;
179
-
180
- // Language selector state
181
- this.languageSelector = null;
182
- this.languageLabel = null;
183
- this.currentTranscriptLanguage = null;
184
- this.availableTranscriptLanguages = [];
185
- this.languageSelectorHandler = null;
186
-
187
- const savedPreferences = this.storage.getTranscriptPreferences();
188
-
189
- this.autoscrollEnabled = typeof savedPreferences?.autoscroll === 'boolean' ? savedPreferences.autoscroll : true;
190
-
191
- this.showTimestamps = typeof savedPreferences?.showTimestamps === 'boolean' ? savedPreferences.showTimestamps : false;
192
-
193
- const savedFontSize = typeof savedPreferences?.fontSize === 'string' ? savedPreferences.fontSize : undefined;
194
- const savedFontFamily = typeof savedPreferences?.fontFamily === 'string' ? savedPreferences.fontFamily : undefined;
195
- const savedColor = typeof savedPreferences?.color === 'string' ? savedPreferences.color : undefined;
196
- const savedBackgroundColor = typeof savedPreferences?.backgroundColor === 'string' ? savedPreferences.backgroundColor : undefined;
197
- const savedOpacity = typeof savedPreferences?.opacity === 'number' ? savedPreferences.opacity : undefined;
198
- const optFontSize = typeof this.player.options.transcriptFontSize === 'string' ? this.player.options.transcriptFontSize : undefined;
199
- const optFontFamily = typeof this.player.options.transcriptFontFamily === 'string' ? this.player.options.transcriptFontFamily : undefined;
200
- const optColor = typeof this.player.options.transcriptColor === 'string' ? this.player.options.transcriptColor : undefined;
201
- const optBackgroundColor = typeof this.player.options.transcriptBackgroundColor === 'string' ? this.player.options.transcriptBackgroundColor : undefined;
202
- const optOpacity = typeof this.player.options.transcriptOpacity === 'number' ? this.player.options.transcriptOpacity : undefined;
203
- this.transcriptStyle = {
204
- fontSize: savedFontSize || optFontSize || '100%',
205
- fontFamily: savedFontFamily || optFontFamily || 'sans-serif',
206
- color: savedColor || optColor || '#ffffff',
207
- backgroundColor: savedBackgroundColor || optBackgroundColor || '#1e1e1e',
208
- opacity: savedOpacity ?? optOpacity ?? 0.98
209
- };
210
-
211
- // Store event handlers for cleanup
212
- this.handlers = {
213
- timeupdate: () => this.updateActiveEntry(),
214
- seeked: () => this.updateActiveEntry(),
215
- audiodescriptionenabled: () => {
216
- if (this.isVisible) {
217
- this.loadTranscriptData();
218
- }
219
- },
220
- audiodescriptiondisabled: () => {
221
- if (this.isVisible) {
222
- this.loadTranscriptData();
223
- }
224
- },
225
- textcuesupdate: null,
226
- resize: null,
227
- settingsClick: null,
228
- settingsKeydown: null,
229
- documentClick: null,
230
- styleDialogKeydown: null,
231
- floatingchange: null
232
- };
233
-
234
- this._cueUpdateTimeout = null;
235
- this._dashActiveLang = null;
236
- this._vttCache = new Map<string, TranscriptCueItem[]>();
237
-
238
- // Timeout management (for cleanup)
239
- this.timeouts = new Set();
240
-
241
- this.init();
242
- }
243
-
244
- init() {
245
- // Set up metadata handling immediately (independent of transcript display)
246
- this.setupMetadataHandlingOnLoad();
247
-
248
- // Listen for time updates to highlight active transcript entry
249
- this.player.on('timeupdate', this.handlers.timeupdate);
250
- this.player.on('seeked', this.handlers.seeked);
251
-
252
- // Listen for audio description changes to reload transcript
253
- this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
254
- this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
255
-
256
- // Listen for text cue updates (cues arrive incrementally as segments load in HLS/DASH).
257
- // Skip re-render when we already fetched the full VTT for the active language.
258
- this.handlers.textcuesupdate = () => {
259
- if (!this.isVisible) return;
260
- if (this.currentTranscriptLanguage && this._vttCache.has(this.currentTranscriptLanguage)) return;
261
- if (this._cueUpdateTimeout) {
262
- this.clearManagedTimeout(this._cueUpdateTimeout);
263
- }
264
- this._cueUpdateTimeout = this.setManagedTimeout(() => {
265
- this._cueUpdateTimeout = null;
266
- this.loadTranscriptData();
267
- }, 400);
268
- };
269
- this.player.on('textcuesupdate', this.handlers.textcuesupdate);
270
-
271
- // Reposition transcript when entering/exiting fullscreen
272
- this.player.on('fullscreenchange', () => {
273
- if (this.isVisible) {
274
- // Re-setup drag/drop when entering/exiting fullscreen on mobile devices
275
- // This enables drag/resize when entering fullscreen on mobile
276
- const isMobile = window.innerWidth < 768;
277
- if (isMobile) {
278
- this.setupDragAndDrop();
279
- }
280
-
281
- // Only auto-position if user hasn't manually positioned it
282
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
283
- // Add a small delay to ensure DOM has updated after fullscreen transition
284
- this.setManagedTimeout(() => this.positionTranscript(), 100);
285
- }
286
- }
287
- });
288
-
289
- // Collapse the transcript whenever the player enters the floating
290
- // shell. The floating miniplayer is intentionally narrow and only
291
- // shows a whitelisted subset of controls, so a full-size transcript
292
- // popup would either overflow the shell or travel with it and break
293
- // the miniplayer UX. We redock the transcript on exit by simply
294
- // letting the user re-open it if desired.
295
- this.handlers.floatingchange = (state) => {
296
- if (state && this.isVisible) {
297
- this.hideTranscript();
298
- }
299
- };
300
- this.player.on('floatingchange', this.handlers.floatingchange);
301
- }
302
-
303
- /**
304
- * For streaming renderers (DASH), tell the renderer to activate the text
305
- * track for `lang` so dash.js starts downloading subtitle segments and
306
- * populating cues. Skips the call if the language is already active.
307
- */
308
- private _requestStreamingTrack(lang: string | null) {
309
- if (!lang) return;
310
- const renderer = this.player.renderer as Renderer | null | undefined;
311
- if (renderer?.isStreaming && typeof renderer.activateTextTrackForLanguage === 'function') {
312
- if (this._dashActiveLang !== lang) {
313
- this._dashActiveLang = lang;
314
- renderer.activateTextTrackForLanguage(lang);
315
- }
316
- }
317
- }
318
-
319
- /**
320
- * Toggle transcript window visibility
321
- */
322
- toggleTranscript() {
323
- if (this.isVisible) {
324
- this.hideTranscript();
325
- } else {
326
- this.showTranscript();
327
- }
328
- }
329
-
330
- /**
331
- * Show transcript window
332
- */
333
- showTranscript() {
334
- // The floating miniplayer hides the transcript control button and is
335
- // too narrow to host the transcript window, so refuse to open while
336
- // floating. Covers both the control-bar click (already gated by CSS)
337
- // and the keyboard shortcut / public API paths.
338
- if (this.player.state?.floating) {
339
- return;
340
- }
341
-
342
- // Always invalidate track cache to get fresh HLS subtitle tracks
343
- this.player.invalidateTrackCache();
344
-
345
- if (this.transcriptWindow) {
346
- this.transcriptWindow.style.display = 'flex';
347
- this.isVisible = true;
348
-
349
- // Reload transcript data to get fresh HLS tracks
350
- this.loadTranscriptData();
351
- this._requestStreamingTrack(this.currentTranscriptLanguage);
352
- this.updateLanguageSelector();
353
-
354
- if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
355
- this.player.controlBar.updateTranscriptButton();
356
- }
357
-
358
- // Focus the settings button for keyboard accessibility
359
- focusElement(this.settingsButton, { delay: 150 });
360
- return;
361
- }
362
-
363
- this.createTranscriptWindow();
364
- this.loadTranscriptData();
365
- this._requestStreamingTrack(this.currentTranscriptLanguage);
366
-
367
- const transcriptWindow = this.transcriptWindow as HTMLElement | null;
368
- if (transcriptWindow) {
369
- transcriptWindow.style.display = 'flex';
370
-
371
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
372
- this.setManagedTimeout(() => this.positionTranscript(), 0);
373
- }
374
-
375
- focusElement(this.settingsButton, { delay: 150 });
376
- }
377
- this.isVisible = true;
378
- }
379
-
380
- /**
381
- * Hide transcript window
382
- */
383
- hideTranscript({ focusButton = false } = {}) {
384
- if (this.transcriptWindow) {
385
- this.transcriptWindow.style.display = 'none';
386
- this.isVisible = false;
387
- }
388
- if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
389
- this.draggableResizable.disablePointerResizeMode();
390
- this.updateResizeOptionState();
391
- }
392
- this.hideResizeModeIndicator();
393
- this.announceLive('');
394
-
395
- // Update transcript button state in control bar
396
- if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
397
- this.player.controlBar.updateTranscriptButton();
398
- }
399
-
400
- if (focusButton) {
401
- const transcriptButton = this.player.controlBar?.controls?.transcript;
402
- if (transcriptButton && typeof transcriptButton.focus === 'function') {
403
- transcriptButton.focus({ preventScroll: true });
404
- }
405
- }
406
- }
407
-
408
- /**
409
- * Create the transcript window UI
410
- */
411
- createTranscriptWindow() {
412
- this.transcriptWindow = DOMUtils.createElement('div', {
413
- className: `${this.player.options.classPrefix}-transcript-window`,
414
- attributes: {
415
- 'role': 'dialog',
416
- 'aria-label': i18n.t('transcript.ariaLabel'),
417
- 'tabindex': '-1'
418
- }
419
- });
420
-
421
- // Header (draggable)
422
- this.transcriptHeader = DOMUtils.createElement('div', {
423
- className: `${this.player.options.classPrefix}-transcript-header`,
424
- attributes: {
425
- 'tabindex': '0'
426
- }
427
- });
428
-
429
- // Header left side (settings button + title)
430
- this.headerLeft = DOMUtils.createElement('div', {
431
- className: `${this.player.options.classPrefix}-transcript-header-left`
432
- });
433
-
434
- // Settings button
435
- const settingsAriaLabel = i18n.t('transcript.settingsMenu');
436
- this.settingsButton = DOMUtils.createElement('button', {
437
- className: `${this.player.options.classPrefix}-transcript-settings`,
438
- attributes: {
439
- 'type': 'button',
440
- 'aria-label': settingsAriaLabel,
441
- 'aria-expanded': 'false'
442
- }
443
- });
444
- this.settingsButton.appendChild(createIconElement('settings'));
445
- DOMUtils.attachTooltip(this.settingsButton, settingsAriaLabel, this.player.options.classPrefix);
446
-
447
- // The panel owns the settings-menu DOM, lifecycle, and the drag
448
- // + resize toggle items. Transcript-specific extras (style dialog
449
- // opener and timestamps toggle) are appended via the extras
450
- // builder below. The manager keeps ownership of the toggle
451
- // semantics (announcements, focus target), which the panel
452
- // invokes via the click callbacks.
453
- this._panel = new DraggablePanel({
454
- player: this.player,
455
- namespace: 'transcript',
456
- settingsButton: this.settingsButton,
457
- getDraggable: () => this.draggableResizable,
458
- i18nKeys: {
459
- enableDrag: 'transcript.enableDragMode',
460
- disableDrag: 'transcript.disableDragMode',
461
- enableDragAria: 'transcript.enableDragModeAria',
462
- disableDragAria: 'transcript.disableDragModeAria',
463
- enableResize: 'transcript.enableResizeMode',
464
- disableResize: 'transcript.disableResizeMode',
465
- enableResizeAria: 'transcript.enableResizeModeAria',
466
- disableResizeAria: 'transcript.disableResizeModeAria',
467
- closeMenu: 'transcript.closeMenu',
468
- },
469
- menuAlign: 'left',
470
- getMenuParent: () =>
471
- this.headerLeft ?? this.transcriptHeader ?? this.transcriptWindow,
472
- // The transcript window itself is the anchor for the mode badge
473
- // so it sits above the panel regardless of where the settings
474
- // menu was opened from. The default class name is derived from
475
- // the namespace → `{classPrefix}-transcript-mode-badge`.
476
- getBadgeHost: () => this.transcriptWindow,
477
- onDragItemClick: (panel) => {
478
- this.toggleKeyboardDragMode();
479
- panel.hide();
480
- },
481
- onResizeItemClick: (panel) => {
482
- const enabled = this.toggleResizeMode({ focus: false });
483
- if (enabled) {
484
- panel.hide({ focusButton: false });
485
- setTimeout(() => {
486
- this.transcriptWindow?.focus({ preventScroll: true });
487
- }, 20);
488
- } else {
489
- panel.hide({ focusButton: true });
490
- }
491
- },
492
- buildExtraItems: ({ menu, itemClass, classPrefix, stripInlineTooltip }) => {
493
- const styleOption = createMenuItem({
494
- classPrefix,
495
- itemClass,
496
- icon: 'settings',
497
- label: 'transcript.styleTranscript',
498
- onClick: (e: Event) => {
499
- e.preventDefault();
500
- e.stopPropagation();
501
- this._panel?.hide();
502
- setTimeout(() => {
503
- this.showStyleDialog();
504
- }, 50);
505
- },
506
- });
507
- stripInlineTooltip(styleOption);
508
- menu.appendChild(styleOption);
509
-
510
- const timestampsOption = createMenuItem({
511
- classPrefix,
512
- itemClass,
513
- icon: 'clock',
514
- label: 'transcript.showTimestamps',
515
- hasTextClass: true,
516
- onClick: () => {
517
- this.toggleShowTimestamps();
518
- },
519
- });
520
- timestampsOption.setAttribute('role', 'switch');
521
- timestampsOption.setAttribute(
522
- 'aria-checked',
523
- this.showTimestamps ? 'true' : 'false'
524
- );
525
- stripInlineTooltip(timestampsOption);
526
- this.showTimestampsButton = timestampsOption;
527
- this.showTimestampsText = timestampsOption.querySelector(
528
- `.${classPrefix}-settings-text`
529
- );
530
- this.updateShowTimestampsState();
531
- menu.appendChild(timestampsOption);
532
- },
533
- });
534
-
535
- this.handlers.settingsClick = (e: MouseEvent) => {
536
- e.preventDefault();
537
- e.stopPropagation();
538
- this._panel?.markJustOpenedForClick();
539
- this._panel?.toggle();
540
- };
541
- this.settingsButton.addEventListener('click', this.handlers.settingsClick);
542
-
543
- // Keyboard handler for settings button
544
- this.handlers.settingsKeydown = (e: KeyboardEvent) => {
545
- // D key to toggle keyboard drag mode
546
- if (e.key === 'd' || e.key === 'D') {
547
- e.preventDefault();
548
- e.stopPropagation();
549
- this.toggleKeyboardDragMode();
550
- }
551
- // R key to toggle resize mode
552
- else if (e.key === 'r' || e.key === 'R') {
553
- e.preventDefault();
554
- e.stopPropagation();
555
- this.toggleResizeMode();
556
- }
557
- // Escape to close menu if open
558
- else if (e.key === 'Escape' && this.settingsMenuVisible) {
559
- e.preventDefault();
560
- e.stopPropagation();
561
- this.hideSettingsMenu();
562
- }
563
- };
564
- this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
565
-
566
- const title = DOMUtils.createElement('h3', {
567
- textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
568
- });
569
-
570
- // Autoscroll checkbox
571
- const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
572
-
573
- const autoscrollLabel = DOMUtils.createElement('label', {
574
- className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
575
- attributes: {
576
- 'for': autoscrollId
577
- }
578
- });
579
-
580
- this.autoscrollCheckbox = DOMUtils.createElement('input', {
581
- attributes: {
582
- 'id': autoscrollId,
583
- 'type': 'checkbox'
584
- }
585
- });
586
- // Set checked property directly (boolean attribute, not "true" string)
587
- if (this.autoscrollEnabled) {
588
- this.autoscrollCheckbox.checked = true;
589
- }
590
-
591
- const autoscrollText = DOMUtils.createElement('span', {
592
- textContent: i18n.t('transcript.autoscroll'),
593
- className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
594
- });
595
-
596
- autoscrollLabel.appendChild(this.autoscrollCheckbox);
597
- autoscrollLabel.appendChild(autoscrollText);
598
-
599
- // Handle autoscroll checkbox change
600
- this.autoscrollCheckbox.addEventListener('change', (e: Event) => {
601
- this.autoscrollEnabled = (e.target as HTMLInputElement).checked;
602
- this.saveAutoscrollPreference();
603
- });
604
-
605
- preventDragOnElement(autoscrollLabel);
606
-
607
- this.transcriptHeader.appendChild(title);
608
- this.headerLeft.appendChild(this.settingsButton);
609
- this.headerLeft.appendChild(autoscrollLabel);
610
-
611
- // Language selector (will be populated after tracks are loaded)
612
- const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
613
- const { label: languageLabel, select: languageSelector } = createLabeledSelect({
614
- classPrefix: this.player.options.classPrefix,
615
- labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
616
- selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
617
- labelText: 'settings.language',
618
- selectId: selectId,
619
- hidden: false // Don't hide individual elements, we'll hide the wrapper instead
620
- });
621
-
622
- this.languageLabel = languageLabel;
623
- this.languageSelector = languageSelector;
624
-
625
- const languageSelectorWrapper = DOMUtils.createElement('div', {
626
- className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
627
- attributes: {
628
- 'style': 'display: none;'
629
- }
630
- });
631
- languageSelectorWrapper.appendChild(languageLabel);
632
- languageSelectorWrapper.appendChild(languageSelector);
633
- this.languageSelectorWrapper = languageSelectorWrapper;
634
-
635
- preventDragOnElement(languageSelectorWrapper);
636
-
637
- if (this.headerLeft) {
638
- this.headerLeft.appendChild(languageSelectorWrapper);
639
- }
640
-
641
- const closeAriaLabel = i18n.t('transcript.close');
642
- const closeButton = DOMUtils.createElement('button', {
643
- className: `${this.player.options.classPrefix}-transcript-close`,
644
- attributes: {
645
- 'type': 'button',
646
- 'aria-label': closeAriaLabel
647
- }
648
- });
649
- closeButton.appendChild(createIconElement('close'));
650
- DOMUtils.attachTooltip(closeButton, closeAriaLabel, this.player.options.classPrefix);
651
- closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
652
-
653
- this.transcriptHeader.appendChild(this.headerLeft);
654
- this.transcriptHeader.appendChild(closeButton);
655
-
656
- // Content container
657
- this.transcriptContent = DOMUtils.createElement('div', {
658
- className: `${this.player.options.classPrefix}-transcript-content`
659
- });
660
-
661
- this.transcriptWindow.appendChild(this.transcriptHeader);
662
- this.transcriptWindow.appendChild(this.transcriptContent);
663
-
664
- this.createResizeHandles();
665
-
666
- // Live region for announcements (screen reader feedback)
667
- this.liveRegion = DOMUtils.createElement('div', {
668
- className: 'vidply-sr-only',
669
- attributes: {
670
- 'aria-live': 'polite',
671
- 'aria-atomic': 'true'
672
- }
673
- });
674
- this.transcriptWindow.appendChild(this.liveRegion);
675
-
676
- // Append to player container
677
- this.player.container.appendChild(this.transcriptWindow);
678
-
679
- // Setup drag functionality FIRST (this will restore saved position if it exists)
680
- this.setupDragAndDrop();
681
-
682
- // Then position it next to the video wrapper ONLY if user hasn't manually positioned it
683
- // This ensures we don't overwrite saved positions from localStorage
684
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
685
- this.positionTranscript();
686
- }
687
-
688
- // Outside-click dismissal for the *style dialog* only. The
689
- // settings-menu equivalent is owned by the DraggablePanel, which
690
- // attaches its own listener (tied to the player's lifecycle
691
- // signal) when the menu is first opened.
692
- this.handlers.documentClick = (e: MouseEvent) => {
693
- if (this.styleDialogJustOpened) {
694
- return;
695
- }
696
- const target = e.target as Node | null;
697
- if (this.styleDialogVisible && this.styleDialog &&
698
- target && !this.styleDialog.contains(target)) {
699
- this.hideStyleDialog();
700
- }
701
- };
702
- this.documentClickHandlerAdded = false;
703
-
704
- // Re-position on window resize (debounced) - but only if not manually positioned
705
- let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
706
- this.handlers.resize = () => {
707
- if (resizeTimeout) {
708
- this.clearManagedTimeout(resizeTimeout);
709
- }
710
- resizeTimeout = this.setManagedTimeout(() => {
711
- // Only auto-position if user hasn't manually moved it
712
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
713
- this.positionTranscript();
714
- }
715
- }, 100);
716
- };
717
- window.addEventListener('resize', this.handlers.resize, {
718
- signal: this.player.lifecycleSignal
719
- });
720
- }
721
-
722
- createResizeHandles() {
723
- const transcriptWindow = this.transcriptWindow;
724
- if (!transcriptWindow) return;
725
-
726
- const directions: Array<'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'> = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
727
- this.transcriptResizeHandles = directions.map(direction => {
728
- const handle = DOMUtils.createElement('div', {
729
- className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
730
- attributes: {
731
- 'data-direction': direction,
732
- 'data-vidply-managed-resize': 'true',
733
- 'aria-hidden': 'true'
734
- }
735
- });
736
-
737
- handle.style.display = 'none';
738
- transcriptWindow.appendChild(handle);
739
- return handle;
740
- });
741
- }
742
-
743
- /**
744
- * Position transcript window next to video
745
- */
746
- positionTranscript() {
747
- const playerWithVideoWrapper = this.player as Player & { videoWrapper?: HTMLElement | null };
748
- if (!this.transcriptWindow || !playerWithVideoWrapper.videoWrapper || !this.isVisible) return;
749
-
750
- // Don't auto-position if user has manually positioned it
751
- if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
752
- return;
753
- }
754
-
755
- const isMobile = window.innerWidth < 768;
756
- const videoRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
757
-
758
- // Check if player is in fullscreen mode
759
- const isFullscreen = this.player.state.fullscreen;
760
-
761
- if (isMobile && !isFullscreen) {
762
- // Mobile: Position directly underneath the video controls as part of the layout
763
- this.transcriptWindow.style.position = 'relative';
764
- this.transcriptWindow.style.left = '0';
765
- this.transcriptWindow.style.right = '0';
766
- this.transcriptWindow.style.bottom = 'auto';
767
- this.transcriptWindow.style.top = 'auto';
768
- this.transcriptWindow.style.width = '100%';
769
- this.transcriptWindow.style.maxWidth = '100%';
770
- this.transcriptWindow.style.maxHeight = '300px';
771
- this.transcriptWindow.style.height = 'auto';
772
- this.transcriptWindow.style.borderRadius = '0';
773
- this.transcriptWindow.style.transform = 'none';
774
- this.transcriptWindow.style.border = 'none';
775
- this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
776
- // Remove any empty border properties that might have been set
777
- this.transcriptWindow.style.removeProperty('border-right');
778
- this.transcriptWindow.style.removeProperty('border-bottom');
779
- this.transcriptWindow.style.removeProperty('border-left');
780
- // Remove border-image properties that can cause parse errors
781
- this.transcriptWindow.style.removeProperty('border-image');
782
- this.transcriptWindow.style.removeProperty('border-image-source');
783
- this.transcriptWindow.style.removeProperty('border-image-slice');
784
- this.transcriptWindow.style.removeProperty('border-image-width');
785
- this.transcriptWindow.style.removeProperty('border-image-outset');
786
- this.transcriptWindow.style.removeProperty('border-image-repeat');
787
- this.transcriptWindow.style.boxShadow = 'none';
788
- // Disable dragging on mobile
789
- if (this.transcriptHeader) {
790
- this.transcriptHeader.style.cursor = 'default';
791
- }
792
-
793
- // Insert directly after video wrapper to appear right under controls
794
- const videoWrapper = playerWithVideoWrapper.videoWrapper;
795
- if (videoWrapper && videoWrapper.parentNode && videoWrapper.nextSibling !== this.transcriptWindow) {
796
- videoWrapper.parentNode.insertBefore(this.transcriptWindow, videoWrapper.nextSibling);
797
- }
798
- } else if (isFullscreen) {
799
- // In fullscreen: position in bottom right corner inside the video
800
- this.transcriptWindow.style.position = 'fixed';
801
- this.transcriptWindow.style.left = 'auto';
802
- this.transcriptWindow.style.right = '20px';
803
- this.transcriptWindow.style.bottom = '80px'; // Above controls
804
- this.transcriptWindow.style.top = 'auto';
805
- this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
806
- this.transcriptWindow.style.height = 'auto';
807
- const fullscreenMinWidth = 260;
808
- const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
809
- const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
810
- const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
811
- this.transcriptWindow.style.width = `${fullscreenWidth}px`;
812
- this.transcriptWindow.style.maxWidth = 'none';
813
- this.transcriptWindow.style.borderRadius = '8px';
814
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
815
- // Remove borderTop and any other individual border properties to avoid empty values
816
- this.transcriptWindow.style.removeProperty('border-top');
817
- this.transcriptWindow.style.removeProperty('border-right');
818
- this.transcriptWindow.style.removeProperty('border-bottom');
819
- this.transcriptWindow.style.removeProperty('border-left');
820
- // Remove border-image properties that can cause parse errors
821
- this.transcriptWindow.style.removeProperty('border-image');
822
- this.transcriptWindow.style.removeProperty('border-image-source');
823
- this.transcriptWindow.style.removeProperty('border-image-slice');
824
- this.transcriptWindow.style.removeProperty('border-image-width');
825
- this.transcriptWindow.style.removeProperty('border-image-outset');
826
- this.transcriptWindow.style.removeProperty('border-image-repeat');
827
- // Enable dragging in fullscreen (including touch devices)
828
- if (this.transcriptHeader) {
829
- this.transcriptHeader.style.cursor = 'move';
830
- }
831
-
832
- // Move back to container for fullscreen
833
- if (this.transcriptWindow.parentNode !== this.player.container) {
834
- this.player.container.appendChild(this.transcriptWindow);
835
- }
836
- } else {
837
- // Desktop mode: position to the right of video, or overlay if insufficient space
838
- const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
839
- const padding = 20;
840
- const minWidth = 260;
841
- const containerRect = this.player.container.getBoundingClientRect();
842
-
843
- const ensureContainerPositioned = () => {
844
- const computed = window.getComputedStyle(this.player.container);
845
- if (computed.position === 'static') {
846
- this.player.container.style.position = 'relative';
847
- }
848
- };
849
-
850
- ensureContainerPositioned();
851
-
852
- // Calculate available space to the right of the video
853
- // WCAG 1.4.10 Reflow: Avoid horizontal overflow and content being cut off
854
- const availableWidth = window.innerWidth - videoRect.right - padding;
855
-
856
- // Switch to overlay mode when:
857
- // 1. Available width is less than the desired transcript width (would cause squeezing/cutoff)
858
- // 2. OR available width is below minimum usable width
859
- // This triggers earlier - as soon as the transcript would be squeezed
860
- const wouldBeCutOff = availableWidth < transcriptWidth;
861
- const hasMinimumSpace = availableWidth >= minWidth;
862
- const useOverlay = wouldBeCutOff || !hasMinimumSpace;
863
-
864
- if (!useOverlay) {
865
- // Position to the right of the video (original behavior)
866
- const left = (videoRect.right - containerRect.left) + padding;
867
- const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
868
- const appliedHeight = videoRect.height;
869
-
870
- this.transcriptWindow.style.position = 'absolute';
871
- this.transcriptWindow.style.left = `${left}px`;
872
- this.transcriptWindow.style.right = 'auto';
873
- this.transcriptWindow.style.bottom = 'auto';
874
- this.transcriptWindow.style.top = '0';
875
- this.transcriptWindow.style.height = `${appliedHeight}px`;
876
- this.transcriptWindow.style.maxHeight = 'none';
877
- this.transcriptWindow.style.width = `${appliedWidth}px`;
878
- this.transcriptWindow.style.maxWidth = 'none';
879
- this.transcriptWindow.style.boxShadow = '';
880
- } else {
881
- // Insufficient space: fall back to compact overlay mode inside video area
882
- // Positioned above controls to prevent horizontal overflow
883
- const overlayMaxWidth = 320;
884
- const overlayMaxHeight = 280;
885
- const overlayWidth = Math.min(overlayMaxWidth, videoRect.width - 40);
886
- const overlayHeight = Math.min(overlayMaxHeight, videoRect.height - 120);
887
-
888
- // Calculate position relative to container, placing overlay inside video area
889
- const videoWrapperRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
890
- const controlsHeight = 70; // Approximate height of controls
891
-
892
- // Position from top of container to bottom of video minus controls and overlay height
893
- const overlayActualHeight = Math.max(180, overlayHeight);
894
- const topPosition = (videoWrapperRect.bottom - containerRect.top) - controlsHeight - overlayActualHeight;
895
- const rightPosition = (containerRect.right - videoWrapperRect.right) + 12;
896
-
897
- this.transcriptWindow.style.position = 'absolute';
898
- this.transcriptWindow.style.left = 'auto';
899
- this.transcriptWindow.style.right = `${rightPosition}px`;
900
- this.transcriptWindow.style.top = `${topPosition}px`;
901
- this.transcriptWindow.style.bottom = 'auto';
902
- this.transcriptWindow.style.width = `${Math.max(minWidth, overlayWidth)}px`;
903
- this.transcriptWindow.style.maxWidth = `${overlayMaxWidth}px`;
904
- this.transcriptWindow.style.height = `${overlayActualHeight}px`;
905
- this.transcriptWindow.style.maxHeight = `${overlayMaxHeight}px`;
906
- this.transcriptWindow.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.6)'; // Enhanced shadow for overlay visibility
907
- }
908
-
909
- this.transcriptWindow.style.borderRadius = '8px';
910
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
911
- // Remove borderTop and any other individual border properties to avoid empty values
912
- this.transcriptWindow.style.removeProperty('border-top');
913
- this.transcriptWindow.style.removeProperty('border-right');
914
- this.transcriptWindow.style.removeProperty('border-bottom');
915
- this.transcriptWindow.style.removeProperty('border-left');
916
- // Remove border-image properties that can cause parse errors
917
- this.transcriptWindow.style.removeProperty('border-image');
918
- this.transcriptWindow.style.removeProperty('border-image-source');
919
- this.transcriptWindow.style.removeProperty('border-image-slice');
920
- this.transcriptWindow.style.removeProperty('border-image-width');
921
- this.transcriptWindow.style.removeProperty('border-image-outset');
922
- this.transcriptWindow.style.removeProperty('border-image-repeat');
923
- // Enable dragging on desktop
924
- if (this.transcriptHeader) {
925
- this.transcriptHeader.style.cursor = 'move';
926
- }
927
-
928
- // Move back to container for desktop
929
- if (this.transcriptWindow.parentNode !== this.player.container) {
930
- this.player.container.appendChild(this.transcriptWindow);
931
- }
932
- }
933
- }
934
-
935
- /**
936
- * Get available transcript languages from tracks
937
- */
938
- getAvailableTranscriptLanguages() {
939
- const textTracks = this.player.textTracks as TranscriptTrack[];
940
- const languages = new Map<string, TranscriptLanguageInfo>();
941
-
942
- // Collect all caption/subtitle tracks with their languages.
943
- // For DASH/HLS, tracks may have placeholder labels (AdaptationSet IDs,
944
- // "null", etc.); deriveTrackLabel normalises them to human-readable names.
945
- textTracks.forEach((track: TranscriptTrack) => {
946
- if ((track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale) {
947
- const lang = (track.language ?? '').trim();
948
- const label = deriveTrackLabel(track.label, track.language, 'player.captions');
949
- const key = lang || label;
950
- if (key && !languages.has(key)) {
951
- languages.set(key, {
952
- language: lang,
953
- label,
954
- track: track
955
- });
956
- }
957
- }
958
- });
959
-
960
- return Array.from(languages.values());
961
- }
962
-
963
- /**
964
- * Update language selector dropdown
965
- */
966
- updateLanguageSelector() {
967
- const languageSelector = this.languageSelector;
968
- if (!languageSelector) return;
969
-
970
- this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
971
-
972
- languageSelector.innerHTML = '';
973
-
974
- if (this.availableTranscriptLanguages.length < 2) {
975
- if (this.languageSelectorWrapper) {
976
- this.languageSelectorWrapper.style.display = 'none';
977
- }
978
- return;
979
- }
980
-
981
- if (this.languageSelectorWrapper) {
982
- this.languageSelectorWrapper.style.display = 'flex';
983
- }
984
-
985
- this.availableTranscriptLanguages.forEach((langInfo: TranscriptLanguageInfo) => {
986
- const attrs: Record<string, string> = {
987
- 'value': langInfo.language || langInfo.label
988
- };
989
- if (langInfo.language) {
990
- attrs['lang'] = langInfo.language;
991
- }
992
- const option = DOMUtils.createElement('option', {
993
- textContent: langInfo.label,
994
- attributes: attrs
995
- });
996
- languageSelector.appendChild(option);
997
- });
998
-
999
- if (this.currentTranscriptLanguage) {
1000
- languageSelector.value = this.currentTranscriptLanguage;
1001
- } else if (this.availableTranscriptLanguages.length > 0) {
1002
- const activeTrack = this.player.textTracks.find(
1003
- (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
1004
- );
1005
- const firstLang = this.availableTranscriptLanguages[0];
1006
- const fallbackLang = firstLang ? firstLang.language : null;
1007
- this.currentTranscriptLanguage = activeTrack ? activeTrack.language : fallbackLang;
1008
- if (this.currentTranscriptLanguage) {
1009
- languageSelector.value = this.currentTranscriptLanguage;
1010
- }
1011
- }
1012
-
1013
- if (this.languageSelectorHandler) {
1014
- languageSelector.removeEventListener('change', this.languageSelectorHandler);
1015
- }
1016
-
1017
- // Handle language change — tell the streaming renderer to switch tracks
1018
- // first so dash.js starts downloading cues for the new language, then
1019
- // render whatever is available (textcuesupdate will re-render later).
1020
- const handler = (e: Event) => {
1021
- this.currentTranscriptLanguage = (e.target as HTMLSelectElement).value;
1022
- this._requestStreamingTrack(this.currentTranscriptLanguage);
1023
- this.loadTranscriptData();
1024
-
1025
- if (this.transcriptContent && this.currentTranscriptLanguage) {
1026
- this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
1027
- }
1028
- };
1029
- this.languageSelectorHandler = handler;
1030
- languageSelector.addEventListener('change', handler);
1031
- }
1032
-
1033
- private _parseVTT(vttText: string): TranscriptCueItem[] {
1034
- const cues: TranscriptCueItem[] = [];
1035
- const blocks = vttText.replace(/\r\n/g, '\n').split(/\n\n+/);
1036
- for (const block of blocks) {
1037
- const lines = block.trim().split('\n');
1038
- let tsLine = -1;
1039
- for (let i = 0; i < lines.length; i++) {
1040
- if (lines[i]?.includes('-->')) { tsLine = i; break; }
1041
- }
1042
- if (tsLine < 0) continue;
1043
- const match = lines[tsLine]?.match(
1044
- /(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})/
1045
- );
1046
- if (!match) continue;
1047
- const startTime =
1048
- (match[1] ? parseInt(match[1]) * 3600 : 0) +
1049
- parseInt(match[2] ?? '0') * 60 +
1050
- parseInt(match[3] ?? '0') +
1051
- parseInt(match[4] ?? '0') / 1000;
1052
- const endTime =
1053
- (match[5] ? parseInt(match[5]) * 3600 : 0) +
1054
- parseInt(match[6] ?? '0') * 60 +
1055
- parseInt(match[7] ?? '0') +
1056
- parseInt(match[8] ?? '0') / 1000;
1057
- const text = lines.slice(tsLine + 1).join('\n').trim();
1058
- if (!text) continue;
1059
- cues.push({
1060
- cue: { startTime, endTime, text, id: '' } as unknown as TranscriptCue,
1061
- type: 'caption'
1062
- });
1063
- }
1064
- return cues;
1065
- }
1066
-
1067
- private async _loadVttTranscript(lang: string): Promise<TranscriptCueItem[] | null> {
1068
- const cached = this._vttCache.get(lang);
1069
- if (cached) return cached;
1070
-
1071
- const renderer = this.player.renderer as Renderer | null | undefined;
1072
- if (!renderer?.isStreaming || typeof renderer.getTextTrackURLs !== 'function') return null;
1073
-
1074
- const urls: { lang: string; url: string }[] = renderer.getTextTrackURLs();
1075
- const entry = urls.find(u =>
1076
- u.lang === lang || u.lang.startsWith(lang) || lang.startsWith(u.lang)
1077
- );
1078
- if (!entry) return null;
1079
-
1080
- // Build a combined AbortSignal: the player's lifecycle controller plus
1081
- // a 10s timeout per individual fetch.
1082
- const signal = this._buildFetchSignal(10_000);
1083
-
1084
- try {
1085
- const res = await fetch(entry.url, { signal });
1086
- if (!res.ok) return null;
1087
- let text = await res.text();
1088
-
1089
- // HLS subtitle playlists are m3u8 files that reference the actual VTT.
1090
- // Resolve the VTT URI and fetch it instead.
1091
- if (text.trimStart().startsWith('#EXTM3U')) {
1092
- const vttUri = text.split('\n')
1093
- .map(l => l.trim())
1094
- .find(l => l && !l.startsWith('#'));
1095
- if (!vttUri) return null;
1096
- const baseUrl = entry.url.substring(0, entry.url.lastIndexOf('/') + 1);
1097
- const vttUrl = vttUri.startsWith('http') ? vttUri : new URL(vttUri, baseUrl).href;
1098
- const vttRes = await fetch(vttUrl, { signal: this._buildFetchSignal(10_000) });
1099
- if (!vttRes.ok) return null;
1100
- text = await vttRes.text();
1101
- }
1102
-
1103
- const cues = this._parseVTT(text);
1104
- if (cues.length > 0) this._vttCache.set(lang, cues);
1105
- return cues;
1106
- } catch {
1107
- return null;
1108
- }
1109
- }
1110
-
1111
- /**
1112
- * Build an AbortSignal that fires when either the player is destroyed
1113
- * or `timeoutMs` elapses, whichever happens first.
1114
- */
1115
- private _buildFetchSignal(timeoutMs: number): AbortSignal | undefined {
1116
- const signals: AbortSignal[] = [];
1117
- const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
1118
- if (lifecycle) signals.push(lifecycle);
1119
- if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
1120
- signals.push(AbortSignal.timeout(timeoutMs));
1121
- }
1122
- if (signals.length === 0) return undefined;
1123
- if (signals.length === 1) return signals[0];
1124
- const anyFn = (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any;
1125
- return anyFn ? anyFn(signals) : signals[0];
1126
- }
1127
-
1128
- /**
1129
- * Load transcript data from caption/subtitle tracks
1130
- */
1131
- loadTranscriptData() {
1132
- this.transcriptEntries = [];
1133
- this.currentActiveEntry = null;
1134
- if (this.transcriptContent) {
1135
- this.transcriptContent.innerHTML = '';
1136
- }
1137
-
1138
- // Get all text tracks
1139
- const textTracks = this.player.textTracks as TranscriptTrack[];
1140
-
1141
- // Find track for selected language, or default to first available.
1142
- // Safari native HLS can expose duplicate TextTrack objects for the same
1143
- // language (SUBTITLES vs CLOSED-CAPTIONS); prefer the one with cues loaded.
1144
- let captionTrack: TranscriptTrack | null = null;
1145
- if (this.currentTranscriptLanguage) {
1146
- const candidates = textTracks.filter(
1147
- (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') &&
1148
- track.language === this.currentTranscriptLanguage &&
1149
- !track._vidplyStale
1150
- );
1151
- captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
1152
- }
1153
-
1154
- if (!captionTrack) {
1155
- const candidates = textTracks.filter(
1156
- (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale
1157
- );
1158
- captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
1159
- if (captionTrack) {
1160
- this.currentTranscriptLanguage = captionTrack.language;
1161
- }
1162
- }
1163
-
1164
- // Find description track matching the selected language
1165
- let descriptionTrack: TranscriptTrack | null = null;
1166
- if (this.currentTranscriptLanguage) {
1167
- descriptionTrack = textTracks.find(
1168
- (track: TranscriptTrack) => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
1169
- ) || null;
1170
- }
1171
- // Fallback to first available description track if no match found
1172
- if (!descriptionTrack) {
1173
- descriptionTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'descriptions') || null;
1174
- }
1175
-
1176
- const metadataTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'metadata');
1177
-
1178
- // We need at least one track type available for display
1179
- // (captions, descriptions, or metadata - though metadata is not displayed)
1180
- if (!captionTrack && !descriptionTrack && !metadataTrack) {
1181
- this.showNoTranscriptMessage();
1182
- return;
1183
- }
1184
-
1185
- // Enable all tracks to load cues so they're available for the transcript
1186
- const tracksToLoad: TranscriptTrack[] = [captionTrack, descriptionTrack, metadataTrack].filter(
1187
- (track): track is TranscriptTrack => Boolean(track)
1188
- );
1189
- tracksToLoad.forEach((track: TranscriptTrack) => {
1190
- if (track.mode === 'disabled') {
1191
- track.mode = 'hidden';
1192
- }
1193
- });
1194
-
1195
- // For streaming renderers, fetch the complete VTT file directly rather
1196
- // than relying on dash.js's partial segment-based cues.
1197
- const renderer = this.player.renderer as Renderer | null | undefined;
1198
- const isStreaming = renderer?.isStreaming && typeof renderer.getTextTrackURLs === 'function';
1199
- const lang = this.currentTranscriptLanguage || (captionTrack?.language ?? '');
1200
-
1201
- if (isStreaming && lang) {
1202
- const loadingMessage = DOMUtils.createElement('div', {
1203
- className: `${this.player.options.classPrefix}-transcript-loading`,
1204
- textContent: i18n.t('transcript.loading')
1205
- });
1206
- this.transcriptContent?.appendChild(loadingMessage);
1207
-
1208
- this._loadVttTranscript(lang).then(vttCues => {
1209
- if (!this.isVisible) return;
1210
- this._renderTranscriptCues(
1211
- vttCues && vttCues.length > 0 ? vttCues : null,
1212
- captionTrack,
1213
- descriptionTrack,
1214
- metadataTrack
1215
- );
1216
- });
1217
- return;
1218
- }
1219
-
1220
- // Non-streaming path: wait for sidecar <track> elements to load cues.
1221
- const primaryTrack = captionTrack;
1222
- const primaryNeedsCues = primaryTrack && (!primaryTrack.cues || primaryTrack.cues.length === 0);
1223
-
1224
- if (primaryNeedsCues) {
1225
- const loadingMessage = DOMUtils.createElement('div', {
1226
- className: `${this.player.options.classPrefix}-transcript-loading`,
1227
- textContent: i18n.t('transcript.loading')
1228
- });
1229
- this.transcriptContent?.appendChild(loadingMessage);
1230
-
1231
- const hasSidecarElement = this.player.findTrackElement?.(primaryTrack);
1232
- if (hasSidecarElement) {
1233
- primaryTrack.addEventListener('load', () => {
1234
- this.loadTranscriptData();
1235
- }, { once: true });
1236
- this.setManagedTimeout(() => { this.loadTranscriptData(); }, 1000);
1237
- } else {
1238
- let attempts = 0;
1239
- const poll = () => {
1240
- attempts++;
1241
- if (!this.isVisible) return;
1242
- if (primaryTrack.cues && primaryTrack.cues.length > 0) {
1243
- this.loadTranscriptData();
1244
- return;
1245
- }
1246
- if (attempts < 40) {
1247
- this.setManagedTimeout(poll, 500);
1248
- }
1249
- };
1250
- this.setManagedTimeout(poll, 300);
1251
- }
1252
- return;
1253
- }
1254
-
1255
- this._renderTranscriptCues(null, captionTrack, descriptionTrack, metadataTrack);
1256
- }
1257
-
1258
- private _renderTranscriptCues(
1259
- vttCues: TranscriptCueItem[] | null,
1260
- captionTrack: TranscriptTrack | null,
1261
- descriptionTrack: TranscriptTrack | null,
1262
- metadataTrack: TranscriptTrack | undefined | null
1263
- ) {
1264
- this.transcriptEntries = [];
1265
- this.currentActiveEntry = null;
1266
- const transcriptContent = this.transcriptContent;
1267
- if (transcriptContent) {
1268
- transcriptContent.innerHTML = '';
1269
- }
1270
-
1271
- const allCues: TranscriptCueItem[] = [];
1272
-
1273
- if (vttCues && vttCues.length > 0) {
1274
- allCues.push(...vttCues);
1275
- } else if (captionTrack && captionTrack.cues) {
1276
- Array.from(captionTrack.cues).forEach((cue: TranscriptCue) => {
1277
- allCues.push({ cue, type: 'caption' });
1278
- });
1279
- }
1280
-
1281
- if (descriptionTrack && descriptionTrack.cues) {
1282
- Array.from(descriptionTrack.cues).forEach((cue: TranscriptCue) => {
1283
- allCues.push({ cue, type: 'description' });
1284
- });
1285
- }
1286
-
1287
- if (metadataTrack && metadataTrack.cues) {
1288
- this.metadataCues = Array.from(metadataTrack.cues) as TranscriptCue[];
1289
- this.setupMetadataHandling();
1290
- }
1291
-
1292
- allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
1293
-
1294
- allCues.forEach((item, index) => {
1295
- const entry = this.createTranscriptEntry(item.cue, index, item.type);
1296
- this.transcriptEntries.push({
1297
- element: entry,
1298
- cue: item.cue,
1299
- type: item.type,
1300
- startTime: item.cue.startTime,
1301
- endTime: item.cue.endTime
1302
- });
1303
- transcriptContent?.appendChild(entry);
1304
- });
1305
-
1306
- this.applyTranscriptStyles();
1307
- this.updateTimestampVisibility();
1308
-
1309
- if (this.transcriptContent && this.currentTranscriptLanguage) {
1310
- this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
1311
- }
1312
-
1313
- this.updateLanguageSelector();
1314
- this.updateActiveEntry();
1315
- }
1316
-
1317
- /**
1318
- * Setup metadata handling on player load
1319
- * This runs independently of transcript loading
1320
- */
1321
- setupMetadataHandlingOnLoad() {
1322
- // Wait for metadata to be loaded
1323
- const setupMetadata = () => {
1324
- const textTracks = this.player.textTracks;
1325
- const metadataTrack = (textTracks as TranscriptTrack[]).find((track: TranscriptTrack) => track.kind === 'metadata');
1326
-
1327
- if (metadataTrack) {
1328
- // Enable the metadata track so cuechange events fire
1329
- // Use 'hidden' mode so it doesn't display anything, but events still work
1330
- if (metadataTrack.mode === 'disabled') {
1331
- metadataTrack.mode = 'hidden';
1332
- }
1333
-
1334
- // Check if we already added the listener
1335
- if (this.metadataCueChangeHandler) {
1336
- metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
1337
- }
1338
-
1339
- // Add event listener for cue changes
1340
- this.metadataCueChangeHandler = () => {
1341
- const activeCues = Array.from(metadataTrack.activeCues || []) as TranscriptCue[];
1342
- if (activeCues.length > 0) {
1343
- // Debug logging (can be removed in production)
1344
- if (this.player.options.debug) {
1345
- console.log('[VidPly Metadata] Active cues:', activeCues.map((c) => ({
1346
- start: c.startTime,
1347
- end: c.endTime,
1348
- text: (c as VTTCue).text
1349
- })));
1350
- }
1351
- }
1352
- activeCues.forEach((cue: TranscriptCue) => {
1353
- this.handleMetadataCue(cue);
1354
- });
1355
- };
1356
-
1357
- metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
1358
-
1359
- // Debug: Log metadata track setup
1360
- if (this.player.options.debug) {
1361
- const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
1362
- console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
1363
- }
1364
- } else if (this.player.options.debug) {
1365
- console.warn('[VidPly Metadata] No metadata track found');
1366
- }
1367
- };
1368
-
1369
- // Try immediately
1370
- setupMetadata();
1371
-
1372
- // Also try after loadedmetadata event
1373
- this.player.on('loadedmetadata', setupMetadata);
1374
- }
1375
-
1376
- /**
1377
- * Setup metadata handling
1378
- * Metadata cues are not displayed but can be used programmatically
1379
- * This is called when transcript data is loaded (for storing cues)
1380
- */
1381
- setupMetadataHandling() {
1382
- if (!this.metadataCues || this.metadataCues.length === 0) {
1383
- return;
1384
- }
1385
-
1386
- // The actual event handling is set up in setupMetadataHandlingOnLoad()
1387
- // This method just stores the cues for reference
1388
- if (this.player.options.debug) {
1389
- console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
1390
- }
1391
- }
1392
-
1393
- /**
1394
- * Handle individual metadata cues
1395
- * Parses metadata text and emits events or triggers actions
1396
- */
1397
- handleMetadataCue(cue: TranscriptCue) {
1398
- const cueText = (cue as TextTrackCue & { text?: string }).text || '';
1399
- const text = cueText.trim();
1400
-
1401
- // Debug logging
1402
- if (this.player.options.debug) {
1403
- console.log('[VidPly Metadata] Processing cue:', {
1404
- time: cue.startTime,
1405
- text: text
1406
- });
1407
- }
1408
-
1409
- // Emit a generic metadata event that developers can listen to
1410
- this.player.emit('metadata', {
1411
- time: cue.startTime,
1412
- endTime: cue.endTime,
1413
- text: text,
1414
- cue: cue
1415
- });
1416
-
1417
- // Parse for specific commands (examples based on wwa_meta.vtt format)
1418
- if (text.includes('PAUSE')) {
1419
- // Automatically pause the video
1420
- if (!this.player.state.paused) {
1421
- if (this.player.options.debug) {
1422
- console.log('[VidPly Metadata] Pausing video at', cue.startTime);
1423
- }
1424
- this.player.pause();
1425
- }
1426
- // Also emit event for developers who want to listen
1427
- this.player.emit('metadata:pause', { time: cue.startTime, text: text });
1428
- }
1429
-
1430
- // Parse for focus directives
1431
- const focusMatch = text.match(/FOCUS:([\w#-]+)/);
1432
- if (focusMatch && focusMatch[1]) {
1433
- const targetSelector = focusMatch[1];
1434
- // Automatically focus the target element
1435
- const targetElement = document.querySelector(targetSelector) as HTMLElement | null;
1436
- if (targetElement) {
1437
- if (this.player.options.debug) {
1438
- console.log('[VidPly Metadata] Focusing element:', targetSelector);
1439
- }
1440
- // Use setTimeout to ensure DOM is ready
1441
- this.setManagedTimeout(() => {
1442
- targetElement.focus({ preventScroll: true });
1443
- }, 10);
1444
- } else if (this.player.options.debug) {
1445
- console.warn('[VidPly Metadata] Element not found:', targetSelector);
1446
- }
1447
- // Also emit event for developers who want to listen
1448
- this.player.emit('metadata:focus', {
1449
- time: cue.startTime,
1450
- target: targetSelector,
1451
- element: targetElement,
1452
- text: text
1453
- });
1454
- }
1455
-
1456
- // Parse for hashtag references
1457
- const hashtags = text.match(/#[\w-]+/g);
1458
- if (hashtags) {
1459
- if (this.player.options.debug) {
1460
- console.log('[VidPly Metadata] Hashtags found:', hashtags);
1461
- }
1462
- this.player.emit('metadata:hashtags', {
1463
- time: cue.startTime,
1464
- hashtags: hashtags,
1465
- text: text
1466
- });
1467
- }
1468
- }
1469
-
1470
- /**
1471
- * Create a single transcript entry element
1472
- */
1473
- createTranscriptEntry(cue: TranscriptCue, index: number, type: 'caption' | 'description' = 'caption') {
1474
- const entryText = this.stripVTTFormatting(((cue as TextTrackCue & { text?: string }).text) || '');
1475
-
1476
- // Use a real <button> so screen readers/AT report "button", Enter and
1477
- // Space activate it natively, and forms-mode users can find it.
1478
- const seekLabelTemplate = i18n.t('transcript.seekTo') || 'Seek to {time}';
1479
- const ariaLabel = seekLabelTemplate
1480
- .replace('{time}', TimeUtils.formatTime(cue.startTime))
1481
- .replace('{text}', entryText);
1482
-
1483
- const entry = DOMUtils.createElement('button', {
1484
- className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
1485
- attributes: {
1486
- 'type': 'button',
1487
- 'data-start': String(cue.startTime),
1488
- 'data-end': String(cue.endTime),
1489
- 'data-type': type,
1490
- 'aria-label': `${ariaLabel} — ${entryText}`
1491
- }
1492
- });
1493
-
1494
- const timestamp = DOMUtils.createElement('span', {
1495
- className: `${this.player.options.classPrefix}-transcript-time`,
1496
- textContent: TimeUtils.formatTime(cue.startTime),
1497
- attributes: {
1498
- 'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
1499
- }
1500
- });
1501
-
1502
- const text = DOMUtils.createElement('span', {
1503
- className: `${this.player.options.classPrefix}-transcript-text`,
1504
- textContent: entryText
1505
- });
1506
-
1507
- entry.appendChild(timestamp);
1508
- entry.appendChild(text);
1509
-
1510
- // Click to seek. <button> elements activate on Enter/Space natively,
1511
- // so a custom keydown handler is no longer needed.
1512
- const seekToTime = () => {
1513
- this.player.seek(cue.startTime);
1514
- if (this.player.state.paused) {
1515
- this.player.play();
1516
- }
1517
- };
1518
-
1519
- entry.addEventListener('click', seekToTime);
1520
-
1521
- return entry;
1522
- }
1523
-
1524
- /**
1525
- * Strip VTT formatting tags from text
1526
- */
1527
- stripVTTFormatting(text: string) {
1528
- // Remove VTT tags like <v Speaker>, <c>, etc.
1529
- return text
1530
- .replace(/<[^>]+>/g, '')
1531
- .replace(/\n/g, ' ')
1532
- .trim();
1533
- }
1534
-
1535
- /**
1536
- * Show message when no transcript is available
1537
- */
1538
- showNoTranscriptMessage() {
1539
- const message = DOMUtils.createElement('div', {
1540
- className: `${this.player.options.classPrefix}-transcript-empty`,
1541
- textContent: i18n.t('transcript.noTranscript')
1542
- });
1543
- this.transcriptContent?.appendChild(message);
1544
- }
1545
-
1546
- /**
1547
- * Update active transcript entry based on current time
1548
- */
1549
- updateActiveEntry() {
1550
- if (!this.isVisible || this.transcriptEntries.length === 0) return;
1551
-
1552
- const currentTime = this.player.state.currentTime;
1553
-
1554
- // Find the entry that matches current time
1555
- const activeEntry = this.transcriptEntries.find(
1556
- (entry: TranscriptEntry) => currentTime >= entry.startTime && currentTime < entry.endTime
1557
- );
1558
-
1559
- if (activeEntry && activeEntry !== this.currentActiveEntry) {
1560
- // Remove previous active class
1561
- if (this.currentActiveEntry) {
1562
- this.currentActiveEntry.element.classList.remove(
1563
- `${this.player.options.classPrefix}-transcript-entry-active`
1564
- );
1565
- }
1566
-
1567
- // Add active class to current entry
1568
- activeEntry.element.classList.add(
1569
- `${this.player.options.classPrefix}-transcript-entry-active`
1570
- );
1571
-
1572
- // Scroll to active entry
1573
- this.scrollToEntry(activeEntry.element);
1574
-
1575
- this.currentActiveEntry = activeEntry;
1576
- } else if (!activeEntry && this.currentActiveEntry) {
1577
- // No active entry, remove active class
1578
- this.currentActiveEntry.element.classList.remove(
1579
- `${this.player.options.classPrefix}-transcript-entry-active`
1580
- );
1581
- this.currentActiveEntry = null;
1582
- }
1583
- }
1584
-
1585
- /**
1586
- * Scroll transcript window to show active entry
1587
- */
1588
- scrollToEntry(entryElement: HTMLElement) {
1589
- if (!this.transcriptContent || !this.autoscrollEnabled) return;
1590
-
1591
- const contentRect = this.transcriptContent.getBoundingClientRect();
1592
- const entryRect = entryElement.getBoundingClientRect();
1593
-
1594
- // Check if entry is out of view
1595
- if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
1596
- // Scroll to center the entry
1597
- const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
1598
- this.transcriptContent.scrollTo({
1599
- top: scrollTop,
1600
- behavior: 'smooth'
1601
- });
1602
- }
1603
- }
1604
-
1605
- /**
1606
- * Save autoscroll preference to localStorage
1607
- */
1608
- saveAutoscrollPreference() {
1609
- const savedPreferences = this.storage.getTranscriptPreferences() || {};
1610
- savedPreferences.autoscroll = this.autoscrollEnabled;
1611
- this.storage.saveTranscriptPreferences(savedPreferences);
1612
- }
1613
-
1614
- /**
1615
- * Setup drag and drop functionality
1616
- */
1617
- setupDragAndDrop() {
1618
- if (!this.transcriptHeader || !this.transcriptWindow) return;
1619
-
1620
- // Check if we're on mobile and not in fullscreen
1621
- const isMobile = window.innerWidth < 768;
1622
- const isFullscreen = this.player.state.fullscreen;
1623
-
1624
- // On mobile devices (< 768px), only enable drag/resize in fullscreen
1625
- // On desktop/tablets (>= 768px), always enable drag/resize
1626
- if (isMobile && !isFullscreen) {
1627
- // Destroy existing instance if exiting fullscreen on mobile
1628
- if (this.draggableResizable) {
1629
- this.draggableResizable.destroy();
1630
- this.draggableResizable = null;
1631
- }
1632
- return; // No drag/resize on mobile when not in fullscreen
1633
- }
1634
-
1635
- // If already initialized, don't re-initialize
1636
- if (this.draggableResizable) {
1637
- return;
1638
- }
1639
-
1640
- // Create DraggableResizable utility with touch support
1641
- this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
1642
- dragHandle: this.transcriptHeader,
1643
- resizeHandles: this.transcriptResizeHandles,
1644
- constrainToViewport: true,
1645
- classPrefix: `${this.player.options.classPrefix}-transcript`,
1646
- keyboardDragKey: 'd',
1647
- keyboardResizeKey: 'r',
1648
- keyboardStep: 10,
1649
- keyboardStepLarge: 50,
1650
- minWidth: 300,
1651
- minHeight: 200,
1652
- maxWidth: () => Math.max(320, window.innerWidth - 40),
1653
- maxHeight: () => Math.max(200, window.innerHeight - 120),
1654
- pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
1655
- onPointerResizeToggle: (enabled) => {
1656
- // Update resize handles visibility
1657
- this.transcriptResizeHandles.forEach((handle: HTMLElement) => {
1658
- handle.style.display = enabled ? 'block' : 'none';
1659
- });
1660
- // Call the state change handler
1661
- this.onPointerResizeModeChange(enabled);
1662
- },
1663
- onDragStart: (e: Event) => {
1664
- // Don't drag if clicking on certain elements
1665
- const ignoreSelectors = [
1666
- `.${this.player.options.classPrefix}-transcript-close`,
1667
- `.${this.player.options.classPrefix}-transcript-settings`,
1668
- `.${this.player.options.classPrefix}-transcript-language-select`,
1669
- `.${this.player.options.classPrefix}-transcript-language-label`,
1670
- `.${this.player.options.classPrefix}-transcript-settings-menu`,
1671
- `.${this.player.options.classPrefix}-transcript-style-dialog`
1672
- ];
1673
-
1674
- for (const selector of ignoreSelectors) {
1675
- if ((e.target as HTMLElement).closest(selector)) {
1676
- return false; // Prevent drag
1677
- }
1678
- }
1679
-
1680
- return true; // Allow drag
1681
- }
1682
- });
1683
-
1684
- // Add custom keyboard handler for special keys (Escape, Home)
1685
- this.customKeyHandler = (e: KeyboardEvent) => {
1686
- const key = e.key.toLowerCase();
1687
- const alreadyPrevented = e.defaultPrevented;
1688
-
1689
- // Don't handle keys if settings menu or style dialog is open (let them handle keys)
1690
- if (this.settingsMenuVisible || this.styleDialogVisible) {
1691
- return;
1692
- }
1693
-
1694
- if (key === 'home') {
1695
- e.preventDefault();
1696
- e.stopPropagation();
1697
- if (this.draggableResizable) {
1698
- if (this.draggableResizable.pointerResizeMode) {
1699
- this.draggableResizable.disablePointerResizeMode();
1700
- }
1701
- this.draggableResizable.manuallyPositioned = false;
1702
- this.positionTranscript();
1703
- this.updateResizeOptionState();
1704
- this.announceLive(i18n.t('transcript.positionReset'));
1705
- }
1706
- return;
1707
- }
1708
-
1709
- if (key === 'r') {
1710
- if (alreadyPrevented) {
1711
- return;
1712
- }
1713
- e.preventDefault();
1714
- e.stopPropagation();
1715
- const enabled = this.toggleResizeMode();
1716
- if (enabled) {
1717
- this.transcriptWindow?.focus({ preventScroll: true });
1718
- }
1719
- return;
1720
- }
1721
-
1722
- if (key === 'escape') {
1723
- // Check priority: resize mode > drag mode > close transcript
1724
- // (settings menu and style dialog already handled by early return above)
1725
- if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
1726
- e.preventDefault();
1727
- e.stopPropagation();
1728
- this.draggableResizable.disablePointerResizeMode();
1729
- return;
1730
- }
1731
- if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
1732
- e.preventDefault();
1733
- e.stopPropagation();
1734
- this.draggableResizable.disableKeyboardDragMode();
1735
- this.announceLive(i18n.t('transcript.dragModeDisabled'));
1736
- return;
1737
- }
1738
- // Only close transcript if nothing else is open
1739
- e.preventDefault();
1740
- e.stopPropagation();
1741
- this.hideTranscript({ focusButton: true });
1742
- return;
1743
- }
1744
- };
1745
-
1746
- const customKeyHandler = this.customKeyHandler;
1747
- if (this.transcriptWindow && customKeyHandler) {
1748
- this.transcriptWindow.addEventListener('keydown', customKeyHandler, {
1749
- signal: this.player.lifecycleSignal
1750
- });
1751
- }
1752
- }
1753
-
1754
-
1755
- /**
1756
- * Toggle keyboard drag mode. Mirrors the sign-language flow: a
1757
- * persistent badge is shown on the transcript window while the mode
1758
- * is active, and a live-region announcement is made on each state
1759
- * change.
1760
- */
1761
- toggleKeyboardDragMode() {
1762
- if (this.draggableResizable) {
1763
- const wasEnabled = this.draggableResizable.keyboardDragMode;
1764
- this.draggableResizable.toggleKeyboardDragMode();
1765
- const isEnabled = this.draggableResizable.keyboardDragMode;
1766
- if (!wasEnabled && isEnabled) {
1767
- this.enableMoveMode();
1768
- this._panel?.showBadge(i18n.t('transcript.dragModeBadge'));
1769
- this.announceLive(i18n.t('transcript.dragModeEnabled'));
1770
- } else if (wasEnabled && !isEnabled) {
1771
- this._panel?.hideBadge();
1772
- this.announceLive(i18n.t('transcript.dragModeDisabled'));
1773
- }
1774
-
1775
- this.updateDragOptionState();
1776
-
1777
- if (this.settingsMenuVisible) {
1778
- this.hideSettingsMenu();
1779
- }
1780
-
1781
- this.transcriptWindow?.focus({ preventScroll: true });
1782
- }
1783
- }
1784
-
1785
- /**
1786
- * Toggle settings menu visibility
1787
- */
1788
- toggleSettingsMenu() {
1789
- if (this.settingsMenuVisible) {
1790
- this.hideSettingsMenu();
1791
- } else {
1792
- this.showSettingsMenu();
1793
- }
1794
- }
1795
-
1796
- /**
1797
- * Show the settings menu. Delegates to the shared {@link DraggablePanel};
1798
- * kept as a named method so external callers (tests, other managers)
1799
- * that referenced the legacy API keep working.
1800
- */
1801
- showSettingsMenu() {
1802
- this._panel?.show();
1803
- }
1804
-
1805
- /** @see {@link showSettingsMenu} */
1806
- positionSettingsMenu() {
1807
- this._panel?.reposition();
1808
- }
1809
-
1810
- /** @see {@link showSettingsMenu} */
1811
- hideSettingsMenu({ focusButton = true } = {}) {
1812
- this._panel?.hide({ focusButton });
1813
- }
1814
-
1815
- /**
1816
- * Enable move mode (gives visual feedback)
1817
- */
1818
- /**
1819
- * Brief pulse animation on the transcript window to confirm entry
1820
- * into keyboard drag mode. The textual hint that used to also flash
1821
- * here has been replaced by a persistent {@link DraggablePanel}
1822
- * badge (see `toggleKeyboardDragMode`), so this method only owns
1823
- * the 1s visual cue now.
1824
- */
1825
- enableMoveMode() {
1826
- this.transcriptWindow?.classList.add(
1827
- `${this.player.options.classPrefix}-transcript-move-mode`
1828
- );
1829
-
1830
- setTimeout(() => {
1831
- this.transcriptWindow?.classList.remove(
1832
- `${this.player.options.classPrefix}-transcript-move-mode`
1833
- );
1834
- }, 2000);
1835
- }
1836
-
1837
- /**
1838
- * Toggle resize mode
1839
- */
1840
- toggleResizeMode({ focus = true } = {}) {
1841
- if (!this.draggableResizable) {
1842
- return false;
1843
- }
1844
-
1845
- if (this.draggableResizable.pointerResizeMode) {
1846
- this.draggableResizable.disablePointerResizeMode({ focus });
1847
- return false;
1848
- }
1849
-
1850
- this.draggableResizable.enablePointerResizeMode({ focus });
1851
- return true;
1852
- }
1853
-
1854
- // Thin delegates to the panel's refreshState. Kept as named methods
1855
- // so the existing internal call sites (e.g. `toggleKeyboardDragMode`
1856
- // and `toggleResizeMode`) read naturally without having to chain
1857
- // through the optional panel reference every time.
1858
- updateDragOptionState() {
1859
- this._panel?.refreshDragState();
1860
- }
1861
-
1862
- updateResizeOptionState() {
1863
- this._panel?.refreshResizeState();
1864
- }
1865
-
1866
- toggleShowTimestamps() {
1867
- this.showTimestamps = !this.showTimestamps;
1868
- this.updateShowTimestampsState();
1869
- this.updateTimestampVisibility();
1870
- this.saveTimestampsPreference();
1871
- }
1872
-
1873
- updateShowTimestampsState() {
1874
- if (!this.showTimestampsButton) {
1875
- return;
1876
- }
1877
-
1878
- const text = this.showTimestamps
1879
- ? i18n.t('transcript.hideTimestamps')
1880
- : i18n.t('transcript.showTimestamps');
1881
- const ariaLabel = this.showTimestamps
1882
- ? i18n.t('transcript.hideTimestampsAria')
1883
- : i18n.t('transcript.showTimestampsAria');
1884
-
1885
- this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1886
- this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
1887
-
1888
- if (this.showTimestampsText) {
1889
- this.showTimestampsText.textContent = text;
1890
- }
1891
- }
1892
-
1893
- updateTimestampVisibility() {
1894
- if (!this.transcriptContent) return;
1895
-
1896
- const timestamps = this.transcriptContent.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
1897
- timestamps.forEach((timestamp) => {
1898
- timestamp.style.display = this.showTimestamps ? '' : 'none';
1899
- });
1900
- }
1901
-
1902
- saveTimestampsPreference() {
1903
- const savedPreferences = this.storage.getTranscriptPreferences() || {};
1904
- savedPreferences.showTimestamps = this.showTimestamps;
1905
- this.storage.saveTranscriptPreferences(savedPreferences);
1906
- }
1907
-
1908
- // Legacy shims kept for any external callers that still invoke the
1909
- // old transient-tooltip API. The persistent badge owned by the
1910
- // {@link DraggablePanel} now replaces the 3-second indicator, so
1911
- // these simply forward to the panel. Safe to remove once the next
1912
- // consumer sweep confirms no external references.
1913
- showResizeModeIndicator() {
1914
- this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
1915
- }
1916
-
1917
- hideResizeModeIndicator() {
1918
- this._panel?.hideBadge();
1919
- }
1920
-
1921
- onPointerResizeModeChange(enabled: boolean) {
1922
- this.updateResizeOptionState();
1923
-
1924
- if (enabled) {
1925
- this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
1926
- this.announceLive(i18n.t('transcript.resizeModeEnabled'));
1927
- } else {
1928
- this._panel?.hideBadge();
1929
- this.announceLive(i18n.t('transcript.resizeModeDisabled'));
1930
- }
1931
- }
1932
-
1933
- /**
1934
- * Show style dialog
1935
- */
1936
- showStyleDialog() {
1937
- // The style dialog uses an outside-click listener to dismiss
1938
- // itself. Previously that listener was armed the first time the
1939
- // settings menu opened (the same handler also dismissed the
1940
- // settings menu). Now that the settings menu owns its own
1941
- // outside-click, we arm the style-dialog listener here on first
1942
- // open. Tied to `lifecycleSignal` so teardown is automatic.
1943
- if (!this.documentClickHandlerAdded) {
1944
- setTimeout(() => {
1945
- const documentClick = this.handlers.documentClick;
1946
- if (documentClick) {
1947
- document.addEventListener('click', documentClick, {
1948
- signal: this.player.lifecycleSignal
1949
- });
1950
- }
1951
- this.documentClickHandlerAdded = true;
1952
- }, 300);
1953
- }
1954
-
1955
- // If dialog already exists, just show it
1956
- if (this.styleDialog) {
1957
- this.styleDialog.style.display = 'block';
1958
- this.styleDialogVisible = true;
1959
-
1960
- // Re-add keyboard handler
1961
- if (this.handlers.styleDialogKeydown) {
1962
- document.addEventListener('keydown', this.handlers.styleDialogKeydown, {
1963
- signal: this.player.lifecycleSignal
1964
- });
1965
- }
1966
-
1967
- // Set flag to prevent immediate closing from document click
1968
- this.styleDialogJustOpened = true;
1969
- setTimeout(() => {
1970
- this.styleDialogJustOpened = false;
1971
- }, 350);
1972
-
1973
- setTimeout(() => {
1974
- const dialog = this.styleDialog;
1975
- if (!dialog) return;
1976
- const firstSelect = dialog.querySelector<HTMLElement>('select, input');
1977
- if (firstSelect) {
1978
- firstSelect.focus({ preventScroll: true });
1979
- }
1980
- }, 0);
1981
- return;
1982
- }
1983
-
1984
- const styleDialog = DOMUtils.createElement('div', {
1985
- className: `${this.player.options.classPrefix}-transcript-style-dialog`
1986
- });
1987
- this.styleDialog = styleDialog;
1988
-
1989
- const title = DOMUtils.createElement('h4', {
1990
- textContent: i18n.t('transcript.styleTitle'),
1991
- className: `${this.player.options.classPrefix}-transcript-style-title`
1992
- });
1993
- styleDialog.appendChild(title);
1994
-
1995
- const fontSizeControl = this.createStyleSelectControl(
1996
- i18n.t('captions.fontSize'),
1997
- 'fontSize',
1998
- [
1999
- { label: i18n.t('fontSizes.small'), value: '90%' },
2000
- { label: i18n.t('fontSizes.normal'), value: '100%' },
2001
- { label: i18n.t('fontSizes.large'), value: '110%' },
2002
- { label: i18n.t('fontSizes.xlarge'), value: '120%' }
2003
- ]
2004
- );
2005
- styleDialog.appendChild(fontSizeControl);
2006
-
2007
- const fontFamilyControl = this.createStyleSelectControl(
2008
- i18n.t('captions.fontFamily'),
2009
- 'fontFamily',
2010
- [
2011
- { label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
2012
- { label: i18n.t('fontFamilies.serif'), value: 'serif' },
2013
- { label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
2014
- ]
2015
- );
2016
- styleDialog.appendChild(fontFamilyControl);
2017
-
2018
- const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
2019
- styleDialog.appendChild(colorControl);
2020
-
2021
- const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
2022
- styleDialog.appendChild(bgColorControl);
2023
-
2024
- const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
2025
- styleDialog.appendChild(opacityControl);
2026
-
2027
- const closeBtn = DOMUtils.createElement('button', {
2028
- className: `${this.player.options.classPrefix}-transcript-style-close`,
2029
- textContent: i18n.t('settings.close'),
2030
- attributes: {
2031
- 'type': 'button'
2032
- }
2033
- });
2034
- closeBtn.addEventListener('click', () => this.hideStyleDialog());
2035
- styleDialog.appendChild(closeBtn);
2036
-
2037
- const styleKeyHandler = (e: KeyboardEvent) => {
2038
- if (!this.styleDialogVisible) return;
2039
-
2040
- if (e.key === 'Escape') {
2041
- e.preventDefault();
2042
- e.stopPropagation();
2043
- this.hideStyleDialog();
2044
- return;
2045
- }
2046
-
2047
- if (e.key === 'Tab') {
2048
- const focusableElements = styleDialog.querySelectorAll<HTMLElement>(
2049
- 'select, input, button'
2050
- );
2051
- const firstElement = focusableElements[0];
2052
- const lastElement = focusableElements[focusableElements.length - 1];
2053
- if (!firstElement || !lastElement) return;
2054
-
2055
- if (e.shiftKey && document.activeElement === firstElement) {
2056
- e.preventDefault();
2057
- lastElement.focus({ preventScroll: true });
2058
- } else if (!e.shiftKey && document.activeElement === lastElement) {
2059
- e.preventDefault();
2060
- firstElement.focus({ preventScroll: true });
2061
- }
2062
- }
2063
- };
2064
- this.handlers.styleDialogKeydown = styleKeyHandler;
2065
- document.addEventListener('keydown', styleKeyHandler, {
2066
- signal: this.player.lifecycleSignal
2067
- });
2068
-
2069
- if (this.headerLeft) {
2070
- this.headerLeft.appendChild(styleDialog);
2071
- } else if (this.transcriptHeader) {
2072
- this.transcriptHeader.appendChild(styleDialog);
2073
- }
2074
-
2075
- this.applyTranscriptStyles();
2076
-
2077
- this.styleDialogVisible = true;
2078
- styleDialog.style.display = 'block';
2079
-
2080
- this.styleDialogJustOpened = true;
2081
- setTimeout(() => {
2082
- this.styleDialogJustOpened = false;
2083
- }, 350);
2084
-
2085
- setTimeout(() => {
2086
- const firstSelect = styleDialog.querySelector<HTMLElement>('select, input');
2087
- if (firstSelect) {
2088
- firstSelect.focus({ preventScroll: true });
2089
- }
2090
- }, 0);
2091
- }
2092
-
2093
- /**
2094
- * Hide style dialog
2095
- */
2096
- hideStyleDialog() {
2097
- if (this.styleDialog) {
2098
- this.styleDialog.style.display = 'none';
2099
- this.styleDialogVisible = false;
2100
-
2101
- // Remove keyboard handler
2102
- if (this.handlers.styleDialogKeydown) {
2103
- document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2104
- }
2105
-
2106
- // Return focus to settings button
2107
- if (this.settingsButton) {
2108
- this.settingsButton.focus({ preventScroll: true });
2109
- }
2110
- }
2111
- }
2112
-
2113
- /**
2114
- * Create style select control
2115
- */
2116
- createStyleSelectControl(label: string, property: string, options: Array<{ label: string; value: string }>) {
2117
- const group = DOMUtils.createElement('div', {
2118
- className: `${this.player.options.classPrefix}-transcript-style-group`
2119
- });
2120
-
2121
- // Generate unique ID for the control
2122
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2123
-
2124
- const labelEl = DOMUtils.createElement('label', {
2125
- textContent: label,
2126
- attributes: {
2127
- 'for': controlId
2128
- }
2129
- });
2130
- group.appendChild(labelEl);
2131
-
2132
- const select = DOMUtils.createElement('select', {
2133
- className: `${this.player.options.classPrefix}-transcript-style-select`,
2134
- attributes: {
2135
- 'id': controlId
2136
- }
2137
- });
2138
-
2139
- options.forEach((opt: { label: string; value: string }) => {
2140
- const option = DOMUtils.createElement('option', {
2141
- textContent: opt.label,
2142
- attributes: {
2143
- 'value': opt.value
2144
- }
2145
- });
2146
- if (this.transcriptStyle[property as 'fontSize' | 'fontFamily'] === opt.value) {
2147
- option.selected = true;
2148
- }
2149
- select.appendChild(option);
2150
- });
2151
-
2152
- select.addEventListener('change', (e) => {
2153
- this.transcriptStyle[property as 'fontSize' | 'fontFamily'] = (e.target as HTMLSelectElement).value;
2154
- this.applyTranscriptStyles();
2155
- this.savePreferences();
2156
- });
2157
-
2158
- group.appendChild(select);
2159
- return group;
2160
- }
2161
-
2162
- /**
2163
- * Create style color control
2164
- */
2165
- createStyleColorControl(label: string, property: string) {
2166
- const group = DOMUtils.createElement('div', {
2167
- className: `${this.player.options.classPrefix}-transcript-style-group`
2168
- });
2169
-
2170
- // Generate unique ID for the control
2171
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2172
-
2173
- const labelEl = DOMUtils.createElement('label', {
2174
- textContent: label,
2175
- attributes: {
2176
- 'for': controlId
2177
- }
2178
- });
2179
- group.appendChild(labelEl);
2180
-
2181
- const input = DOMUtils.createElement('input', {
2182
- attributes: {
2183
- 'id': controlId,
2184
- 'type': 'color',
2185
- 'value': this.transcriptStyle[property as 'color' | 'backgroundColor']
2186
- },
2187
- className: `${this.player.options.classPrefix}-transcript-style-color`
2188
- });
2189
-
2190
- input.addEventListener('input', (e) => {
2191
- this.transcriptStyle[property as 'color' | 'backgroundColor'] = (e.target as HTMLInputElement).value;
2192
- this.applyTranscriptStyles();
2193
- this.savePreferences();
2194
- });
2195
-
2196
- group.appendChild(input);
2197
- return group;
2198
- }
2199
-
2200
- /**
2201
- * Create style opacity control
2202
- */
2203
- createStyleOpacityControl(label: string, property: string) {
2204
- const group = DOMUtils.createElement('div', {
2205
- className: `${this.player.options.classPrefix}-transcript-style-group`
2206
- });
2207
-
2208
- // Generate unique ID for the control
2209
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2210
-
2211
- const labelEl = DOMUtils.createElement('label', {
2212
- textContent: label,
2213
- attributes: {
2214
- 'for': controlId
2215
- }
2216
- });
2217
- group.appendChild(labelEl);
2218
-
2219
- const opacityProperty = property as 'opacity';
2220
- const valueDisplay = DOMUtils.createElement('span', {
2221
- textContent: Math.round(this.transcriptStyle[opacityProperty] * 100) + '%',
2222
- className: `${this.player.options.classPrefix}-transcript-style-value`
2223
- });
2224
-
2225
- const input = DOMUtils.createElement('input', {
2226
- attributes: {
2227
- 'id': controlId,
2228
- 'type': 'range',
2229
- 'min': '0',
2230
- 'max': '1',
2231
- 'step': '0.1',
2232
- 'value': String(this.transcriptStyle[opacityProperty])
2233
- },
2234
- className: `${this.player.options.classPrefix}-transcript-style-range`
2235
- });
2236
-
2237
- input.addEventListener('input', (e) => {
2238
- const value = parseFloat((e.target as HTMLInputElement).value);
2239
- this.transcriptStyle[opacityProperty] = value;
2240
- valueDisplay.textContent = Math.round(value * 100) + '%';
2241
- this.applyTranscriptStyles();
2242
- this.savePreferences();
2243
- });
2244
-
2245
- const inputContainer = DOMUtils.createElement('div', {
2246
- className: `${this.player.options.classPrefix}-transcript-style-range-container`
2247
- });
2248
- inputContainer.appendChild(input);
2249
- inputContainer.appendChild(valueDisplay);
2250
-
2251
- group.appendChild(labelEl);
2252
- group.appendChild(inputContainer);
2253
- return group;
2254
- }
2255
-
2256
- /**
2257
- * Save transcript preferences to localStorage
2258
- */
2259
- savePreferences() {
2260
- this.storage.saveTranscriptPreferences(this.transcriptStyle);
2261
- }
2262
-
2263
- /**
2264
- * Apply transcript styles
2265
- */
2266
- applyTranscriptStyles() {
2267
- if (!this.transcriptWindow) return;
2268
-
2269
- // Apply to transcript window background
2270
- this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
2271
- this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
2272
-
2273
- // Apply to content area
2274
- if (this.transcriptContent) {
2275
- this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
2276
- this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
2277
- this.transcriptContent.style.color = this.transcriptStyle.color;
2278
- }
2279
-
2280
- const textEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-text`);
2281
- textEntries.forEach((entry) => {
2282
- entry.style.fontSize = this.transcriptStyle.fontSize;
2283
- entry.style.fontFamily = this.transcriptStyle.fontFamily;
2284
- entry.style.color = this.transcriptStyle.color;
2285
- });
2286
-
2287
- const timeEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
2288
- timeEntries.forEach((entry) => {
2289
- entry.style.fontFamily = this.transcriptStyle.fontFamily;
2290
- });
2291
- }
2292
-
2293
- /**
2294
- * Set a managed timeout that will be cleaned up on destroy
2295
- * @param {Function} callback - Callback function
2296
- * @param {number} delay - Delay in milliseconds
2297
- * @returns {number} Timeout ID
2298
- */
2299
- setManagedTimeout(callback: () => void, delay: number) {
2300
- const timeoutId = setTimeout(() => {
2301
- this.timeouts.delete(timeoutId);
2302
- callback();
2303
- }, delay);
2304
- this.timeouts.add(timeoutId);
2305
- return timeoutId;
2306
- }
2307
-
2308
- /**
2309
- * Clear a managed timeout
2310
- * @param {number} timeoutId - Timeout ID to clear
2311
- */
2312
- clearManagedTimeout(timeoutId: ReturnType<typeof setTimeout> | null) {
2313
- if (timeoutId) {
2314
- clearTimeout(timeoutId);
2315
- this.timeouts.delete(timeoutId);
2316
- }
2317
- }
2318
-
2319
- /**
2320
- * Cleanup
2321
- */
2322
- destroy() {
2323
- this.hideResizeModeIndicator();
2324
-
2325
- // Tear down the settings-menu panel (removes its DOM + keyboard
2326
- // listeners). The outside-click listener was attached with the
2327
- // player's lifecycleSignal, so it cleans itself up as the player
2328
- // is destroyed.
2329
- if (this._panel) {
2330
- this._panel.destroy();
2331
- this._panel = null;
2332
- }
2333
-
2334
- // Destroy draggableResizable utility
2335
- if (this.draggableResizable) {
2336
- if (this.draggableResizable.pointerResizeMode) {
2337
- this.draggableResizable.disablePointerResizeMode();
2338
- this.updateResizeOptionState();
2339
- }
2340
- this.draggableResizable.destroy();
2341
- this.draggableResizable = null;
2342
- }
2343
-
2344
- // Remove custom key handler
2345
- if (this.transcriptWindow && this.customKeyHandler) {
2346
- this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
2347
- this.customKeyHandler = null;
2348
- }
2349
-
2350
- // Remove timeupdate and seeked listeners from player
2351
- if (this.handlers.timeupdate) {
2352
- this.player.off('timeupdate', this.handlers.timeupdate);
2353
- }
2354
- if (this.handlers.seeked) {
2355
- this.player.off('seeked', this.handlers.seeked);
2356
- }
2357
-
2358
- // Remove audio description listeners from player
2359
- if (this.handlers.audiodescriptionenabled) {
2360
- this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
2361
- }
2362
- if (this.handlers.audiodescriptiondisabled) {
2363
- this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
2364
- }
2365
-
2366
- // Remove text cue update listener
2367
- if (this.handlers.textcuesupdate) {
2368
- this.player.off('textcuesupdate', this.handlers.textcuesupdate);
2369
- }
2370
-
2371
- // Remove floating-state listener
2372
- if (this.handlers.floatingchange) {
2373
- this.player.off('floatingchange', this.handlers.floatingchange);
2374
- }
2375
-
2376
- // Remove settings button event listeners
2377
- if (this.settingsButton) {
2378
- if (this.handlers.settingsClick) {
2379
- this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
2380
- }
2381
- if (this.handlers.settingsKeydown) {
2382
- this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
2383
- }
2384
- }
2385
-
2386
- // Remove style dialog event listeners
2387
- if (this.handlers.styleDialogKeydown) {
2388
- document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2389
- }
2390
-
2391
- // Remove document click listener
2392
- if (this.handlers.documentClick) {
2393
- document.removeEventListener('click', this.handlers.documentClick);
2394
- }
2395
-
2396
- // Remove window-level listeners
2397
- if (this.handlers.resize) {
2398
- window.removeEventListener('resize', this.handlers.resize);
2399
- }
2400
-
2401
- // Cleanup all managed timeouts
2402
- this.timeouts.forEach((timeoutId: ReturnType<typeof setTimeout>) => clearTimeout(timeoutId));
2403
- this.timeouts.clear();
2404
-
2405
- this.handlers = {} as TranscriptHandlers;
2406
-
2407
- // Remove DOM element
2408
- if (this.transcriptWindow && this.transcriptWindow.parentNode) {
2409
- this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
2410
- }
2411
-
2412
- this.transcriptWindow = null;
2413
- this.transcriptHeader = null;
2414
- this.transcriptContent = null;
2415
- this.transcriptEntries = [];
2416
- this.styleDialog = null;
2417
- this.transcriptResizeHandles = [];
2418
- this.liveRegion = null;
2419
- this._vttCache.clear();
2420
- }
2421
-
2422
- announceLive(message: string) {
2423
- if (!this.liveRegion) return;
2424
- this.liveRegion.textContent = message || '';
2425
- }
2426
- }
1
+ /**
2
+ * Transcript Manager Component
3
+ * Manages transcript display and interaction
4
+ */
5
+
6
+ import { DOMUtils } from '../utils/DOMUtils.js';
7
+ import { TimeUtils } from '../utils/TimeUtils.js';
8
+ import { createIconElement } from '../icons/Icons.js';
9
+ import { i18n } from '../i18n/i18n.js';
10
+ import { StorageManager } from '../utils/StorageManager.js';
11
+ import { focusElement } from '../utils/FocusUtils.js';
12
+ import { createMenuItem } from '../utils/MenuUtils.js';
13
+ import { DraggableResizable } from '../utils/DraggableResizable.js';
14
+ import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
15
+ import { DraggablePanel } from '../utils/DraggablePanel.js';
16
+ import { deriveTrackLabel } from '../utils/TrackLabelUtils.js';
17
+ import type { Player } from '../core/Player.js';
18
+ import type { Renderer } from '../types/renderer.js';
19
+
20
+ type TranscriptCue = TextTrackCue;
21
+ type TranscriptTrack = TextTrack & { _vidplyStale?: boolean };
22
+ type TranscriptLanguageInfo = {
23
+ language: string;
24
+ label: string;
25
+ track: TranscriptTrack;
26
+ };
27
+ type TranscriptCueItem = {
28
+ cue: TranscriptCue;
29
+ type: 'caption' | 'description';
30
+ };
31
+ type TranscriptEntry = {
32
+ element: HTMLElement;
33
+ cue: TranscriptCue;
34
+ type: 'caption' | 'description';
35
+ startTime: number;
36
+ endTime: number;
37
+ };
38
+
39
+ interface TranscriptStyleOptions {
40
+ fontSize: string;
41
+ fontFamily: string;
42
+ color: string;
43
+ backgroundColor: string;
44
+ opacity: number;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ interface TranscriptHandlers {
49
+ timeupdate: () => void;
50
+ seeked: () => void;
51
+ audiodescriptionenabled: () => void;
52
+ audiodescriptiondisabled: () => void;
53
+ textcuesupdate: (() => void) | null;
54
+ resize: (() => void) | null;
55
+ settingsClick: ((e: MouseEvent) => void) | null;
56
+ settingsKeydown: ((e: KeyboardEvent) => void) | null;
57
+ documentClick: ((e: MouseEvent) => void) | null;
58
+ styleDialogKeydown: ((e: KeyboardEvent) => void) | null;
59
+ floatingchange: ((state: 'pinned' | 'auto' | null) => void) | null;
60
+ }
61
+
62
+ type TimerHandle = ReturnType<typeof setTimeout>;
63
+
64
+ export class TranscriptManager {
65
+ player: Player;
66
+ _cueUpdateTimeout: TimerHandle | null;
67
+ autoscrollCheckbox: HTMLInputElement | null = null;
68
+ autoscrollEnabled: boolean;
69
+ availableTranscriptLanguages: TranscriptLanguageInfo[];
70
+ currentActiveEntry: TranscriptEntry | null;
71
+ currentTranscriptLanguage: string | null;
72
+ customKeyHandler: ((e: KeyboardEvent) => void) | null = null;
73
+ /**
74
+ * True once the style-dialog's outside-click listener has been
75
+ * attached. The settings-menu's outside-click listener is now
76
+ * owned by {@link DraggablePanel} and tracked there; this flag
77
+ * covers the dialog half of the shared `handlers.documentClick`.
78
+ */
79
+ documentClickHandlerAdded: boolean = false;
80
+ draggableResizable: DraggableResizable | null;
81
+ handlers: TranscriptHandlers = {} as TranscriptHandlers;
82
+ headerLeft: HTMLElement | null = null;
83
+ isVisible: boolean;
84
+ languageLabel: HTMLElement | null;
85
+ languageSelector: HTMLSelectElement | null;
86
+ languageSelectorHandler: ((e: Event) => void) | null;
87
+ languageSelectorWrapper: HTMLElement | null = null;
88
+ liveRegion: HTMLElement | null;
89
+ metadataCueChangeHandler: (() => void) | null = null;
90
+ metadataCues: TextTrackCue[];
91
+ settingsButton: HTMLButtonElement | null;
92
+ showTimestamps: boolean;
93
+ showTimestampsButton: HTMLElement | null = null;
94
+ showTimestampsText: Element | null = null;
95
+ storage: StorageManager;
96
+ styleDialog: HTMLElement | null;
97
+ styleDialogJustOpened: boolean;
98
+ styleDialogVisible: boolean;
99
+ timeouts: Set<TimerHandle>;
100
+ transcriptContent: HTMLElement | null = null;
101
+ transcriptEntries: TranscriptEntry[];
102
+ transcriptHeader: HTMLElement | null = null;
103
+ transcriptResizeHandles: HTMLElement[];
104
+ transcriptStyle: TranscriptStyleOptions;
105
+ transcriptWindow: HTMLElement | null;
106
+ _dashActiveLang: string | null;
107
+ _vttCache: Map<string, TranscriptCueItem[]>;
108
+
109
+ /**
110
+ * Owns the settings-menu DOM scaffold, its outside-click
111
+ * dismissal, keyboard navigation, viewport-aware positioning,
112
+ * and the drag-mode / resize-mode toggle items. Instantiated
113
+ * lazily once the header is built in {@link createTranscriptHeader}.
114
+ */
115
+ private _panel: DraggablePanel | null = null;
116
+
117
+ // Back-compat getters for panel-owned state. External callers and
118
+ // internal reads (e.g. `this.settingsMenuVisible` inside other
119
+ // transcript methods) keep reading the same properties; the
120
+ // setters are no-ops because the panel is now the authoritative
121
+ // owner of those values.
122
+ get settingsMenu(): HTMLElement | null {
123
+ return this._panel?.settingsMenu ?? null;
124
+ }
125
+ set settingsMenu(_v: HTMLElement | null) {
126
+ // panel-owned.
127
+ }
128
+
129
+ get settingsMenuVisible(): boolean {
130
+ return this._panel?.settingsMenuVisible ?? false;
131
+ }
132
+ set settingsMenuVisible(_v: boolean) {
133
+ // panel-owned.
134
+ }
135
+
136
+ get settingsMenuJustOpened(): boolean {
137
+ return this._panel?.justOpened ?? false;
138
+ }
139
+ set settingsMenuJustOpened(_v: boolean) {
140
+ // panel-owned.
141
+ }
142
+
143
+ get dragOptionButton(): HTMLElement | null {
144
+ return this._panel?.dragOptionButton ?? null;
145
+ }
146
+ get dragOptionText(): Element | null {
147
+ return this._panel?.dragOptionText ?? null;
148
+ }
149
+ get resizeOptionButton(): HTMLElement | null {
150
+ return this._panel?.resizeOptionButton ?? null;
151
+ }
152
+ get resizeOptionText(): Element | null {
153
+ return this._panel?.resizeOptionText ?? null;
154
+ }
155
+
156
+ constructor(player: Player) {
157
+ this.player = player;
158
+ this.transcriptWindow = null;
159
+ this.transcriptEntries = [];
160
+ this.metadataCues = [];
161
+ this.currentActiveEntry = null;
162
+ this.isVisible = false;
163
+
164
+ // Storage manager
165
+ this.storage = new StorageManager('vidply');
166
+
167
+ // Draggable/Resizable utility
168
+ this.draggableResizable = null;
169
+ this.settingsButton = null;
170
+
171
+ // Resize mode state
172
+ this.transcriptResizeHandles = [];
173
+ this.liveRegion = null;
174
+
175
+ // Style dialog state
176
+ this.styleDialog = null;
177
+ this.styleDialogVisible = false;
178
+ this.styleDialogJustOpened = false;
179
+
180
+ // Language selector state
181
+ this.languageSelector = null;
182
+ this.languageLabel = null;
183
+ this.currentTranscriptLanguage = null;
184
+ this.availableTranscriptLanguages = [];
185
+ this.languageSelectorHandler = null;
186
+
187
+ const savedPreferences = this.storage.getTranscriptPreferences();
188
+
189
+ this.autoscrollEnabled = typeof savedPreferences?.autoscroll === 'boolean' ? savedPreferences.autoscroll : true;
190
+
191
+ this.showTimestamps = typeof savedPreferences?.showTimestamps === 'boolean' ? savedPreferences.showTimestamps : false;
192
+
193
+ const savedFontSize = typeof savedPreferences?.fontSize === 'string' ? savedPreferences.fontSize : undefined;
194
+ const savedFontFamily = typeof savedPreferences?.fontFamily === 'string' ? savedPreferences.fontFamily : undefined;
195
+ const savedColor = typeof savedPreferences?.color === 'string' ? savedPreferences.color : undefined;
196
+ const savedBackgroundColor = typeof savedPreferences?.backgroundColor === 'string' ? savedPreferences.backgroundColor : undefined;
197
+ const savedOpacity = typeof savedPreferences?.opacity === 'number' ? savedPreferences.opacity : undefined;
198
+ const optFontSize = typeof this.player.options.transcriptFontSize === 'string' ? this.player.options.transcriptFontSize : undefined;
199
+ const optFontFamily = typeof this.player.options.transcriptFontFamily === 'string' ? this.player.options.transcriptFontFamily : undefined;
200
+ const optColor = typeof this.player.options.transcriptColor === 'string' ? this.player.options.transcriptColor : undefined;
201
+ const optBackgroundColor = typeof this.player.options.transcriptBackgroundColor === 'string' ? this.player.options.transcriptBackgroundColor : undefined;
202
+ const optOpacity = typeof this.player.options.transcriptOpacity === 'number' ? this.player.options.transcriptOpacity : undefined;
203
+ this.transcriptStyle = {
204
+ fontSize: savedFontSize || optFontSize || '100%',
205
+ fontFamily: savedFontFamily || optFontFamily || 'sans-serif',
206
+ color: savedColor || optColor || '#ffffff',
207
+ backgroundColor: savedBackgroundColor || optBackgroundColor || '#1e1e1e',
208
+ opacity: savedOpacity ?? optOpacity ?? 0.98
209
+ };
210
+
211
+ // Store event handlers for cleanup
212
+ this.handlers = {
213
+ timeupdate: () => this.updateActiveEntry(),
214
+ seeked: () => this.updateActiveEntry(),
215
+ audiodescriptionenabled: () => {
216
+ if (this.isVisible) {
217
+ this.loadTranscriptData();
218
+ }
219
+ },
220
+ audiodescriptiondisabled: () => {
221
+ if (this.isVisible) {
222
+ this.loadTranscriptData();
223
+ }
224
+ },
225
+ textcuesupdate: null,
226
+ resize: null,
227
+ settingsClick: null,
228
+ settingsKeydown: null,
229
+ documentClick: null,
230
+ styleDialogKeydown: null,
231
+ floatingchange: null
232
+ };
233
+
234
+ this._cueUpdateTimeout = null;
235
+ this._dashActiveLang = null;
236
+ this._vttCache = new Map<string, TranscriptCueItem[]>();
237
+
238
+ // Timeout management (for cleanup)
239
+ this.timeouts = new Set();
240
+
241
+ this.init();
242
+ }
243
+
244
+ init() {
245
+ // Set up metadata handling immediately (independent of transcript display)
246
+ this.setupMetadataHandlingOnLoad();
247
+
248
+ // Listen for time updates to highlight active transcript entry
249
+ this.player.on('timeupdate', this.handlers.timeupdate);
250
+ this.player.on('seeked', this.handlers.seeked);
251
+
252
+ // Listen for audio description changes to reload transcript
253
+ this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
254
+ this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
255
+
256
+ // Listen for text cue updates (cues arrive incrementally as segments load in HLS/DASH).
257
+ // Skip re-render when we already fetched the full VTT for the active language.
258
+ this.handlers.textcuesupdate = () => {
259
+ if (!this.isVisible) return;
260
+ if (this.currentTranscriptLanguage && this._vttCache.has(this.currentTranscriptLanguage)) return;
261
+ if (this._cueUpdateTimeout) {
262
+ this.clearManagedTimeout(this._cueUpdateTimeout);
263
+ }
264
+ this._cueUpdateTimeout = this.setManagedTimeout(() => {
265
+ this._cueUpdateTimeout = null;
266
+ this.loadTranscriptData();
267
+ }, 400);
268
+ };
269
+ this.player.on('textcuesupdate', this.handlers.textcuesupdate);
270
+
271
+ // Reposition transcript when entering/exiting fullscreen
272
+ this.player.on('fullscreenchange', () => {
273
+ if (this.isVisible) {
274
+ // Re-setup drag/drop when entering/exiting fullscreen on mobile devices
275
+ // This enables drag/resize when entering fullscreen on mobile
276
+ const isMobile = window.innerWidth < 768;
277
+ if (isMobile) {
278
+ this.setupDragAndDrop();
279
+ }
280
+
281
+ // Only auto-position if user hasn't manually positioned it
282
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
283
+ // Add a small delay to ensure DOM has updated after fullscreen transition
284
+ this.setManagedTimeout(() => this.positionTranscript(), 100);
285
+ }
286
+ }
287
+ });
288
+
289
+ // Collapse the transcript whenever the player enters the floating
290
+ // shell. The floating miniplayer is intentionally narrow and only
291
+ // shows a whitelisted subset of controls, so a full-size transcript
292
+ // popup would either overflow the shell or travel with it and break
293
+ // the miniplayer UX. We redock the transcript on exit by simply
294
+ // letting the user re-open it if desired.
295
+ this.handlers.floatingchange = (state) => {
296
+ if (state && this.isVisible) {
297
+ this.hideTranscript();
298
+ }
299
+ };
300
+ this.player.on('floatingchange', this.handlers.floatingchange);
301
+ }
302
+
303
+ /**
304
+ * For streaming renderers (DASH), tell the renderer to activate the text
305
+ * track for `lang` so dash.js starts downloading subtitle segments and
306
+ * populating cues. Skips the call if the language is already active.
307
+ */
308
+ private _requestStreamingTrack(lang: string | null) {
309
+ if (!lang) return;
310
+ const renderer = this.player.renderer as Renderer | null | undefined;
311
+ if (renderer?.isStreaming && typeof renderer.activateTextTrackForLanguage === 'function') {
312
+ if (this._dashActiveLang !== lang) {
313
+ this._dashActiveLang = lang;
314
+ renderer.activateTextTrackForLanguage(lang);
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Toggle transcript window visibility
321
+ */
322
+ toggleTranscript() {
323
+ if (this.isVisible) {
324
+ this.hideTranscript();
325
+ } else {
326
+ this.showTranscript();
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Show transcript window
332
+ */
333
+ showTranscript() {
334
+ // The floating miniplayer hides the transcript control button and is
335
+ // too narrow to host the transcript window, so refuse to open while
336
+ // floating. Covers both the control-bar click (already gated by CSS)
337
+ // and the keyboard shortcut / public API paths.
338
+ if (this.player.state?.floating) {
339
+ return;
340
+ }
341
+
342
+ // Always invalidate track cache to get fresh HLS subtitle tracks
343
+ this.player.invalidateTrackCache();
344
+
345
+ if (this.transcriptWindow) {
346
+ this.transcriptWindow.style.display = 'flex';
347
+ this.isVisible = true;
348
+
349
+ // Reload transcript data to get fresh HLS tracks
350
+ this.loadTranscriptData();
351
+ this._requestStreamingTrack(this.currentTranscriptLanguage);
352
+ this.updateLanguageSelector();
353
+
354
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
355
+ this.player.controlBar.updateTranscriptButton();
356
+ }
357
+
358
+ // Focus the settings button for keyboard accessibility
359
+ focusElement(this.settingsButton, { delay: 150 });
360
+ return;
361
+ }
362
+
363
+ this.createTranscriptWindow();
364
+ this.loadTranscriptData();
365
+ this._requestStreamingTrack(this.currentTranscriptLanguage);
366
+
367
+ const transcriptWindow = this.transcriptWindow as HTMLElement | null;
368
+ if (transcriptWindow) {
369
+ transcriptWindow.style.display = 'flex';
370
+
371
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
372
+ this.setManagedTimeout(() => this.positionTranscript(), 0);
373
+ }
374
+
375
+ focusElement(this.settingsButton, { delay: 150 });
376
+ }
377
+ this.isVisible = true;
378
+ }
379
+
380
+ /**
381
+ * Hide transcript window
382
+ */
383
+ hideTranscript({ focusButton = false } = {}) {
384
+ if (this.transcriptWindow) {
385
+ this.transcriptWindow.style.display = 'none';
386
+ this.isVisible = false;
387
+ }
388
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
389
+ this.draggableResizable.disablePointerResizeMode();
390
+ this.updateResizeOptionState();
391
+ }
392
+ this.hideResizeModeIndicator();
393
+ this.announceLive('');
394
+
395
+ // Update transcript button state in control bar
396
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
397
+ this.player.controlBar.updateTranscriptButton();
398
+ }
399
+
400
+ if (focusButton) {
401
+ const transcriptButton = this.player.controlBar?.controls?.transcript;
402
+ if (transcriptButton && typeof transcriptButton.focus === 'function') {
403
+ transcriptButton.focus({ preventScroll: true });
404
+ }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Create the transcript window UI
410
+ */
411
+ createTranscriptWindow() {
412
+ this.transcriptWindow = DOMUtils.createElement('div', {
413
+ className: `${this.player.options.classPrefix}-transcript-window`,
414
+ attributes: {
415
+ 'role': 'dialog',
416
+ 'aria-label': i18n.t('transcript.ariaLabel'),
417
+ 'tabindex': '-1'
418
+ }
419
+ });
420
+
421
+ // Header (draggable)
422
+ this.transcriptHeader = DOMUtils.createElement('div', {
423
+ className: `${this.player.options.classPrefix}-transcript-header`,
424
+ attributes: {
425
+ 'tabindex': '0'
426
+ }
427
+ });
428
+
429
+ // Header left side (settings button + title)
430
+ this.headerLeft = DOMUtils.createElement('div', {
431
+ className: `${this.player.options.classPrefix}-transcript-header-left`
432
+ });
433
+
434
+ // Settings button
435
+ const settingsAriaLabel = i18n.t('transcript.settingsMenu');
436
+ this.settingsButton = DOMUtils.createElement('button', {
437
+ className: `${this.player.options.classPrefix}-transcript-settings`,
438
+ attributes: {
439
+ 'type': 'button',
440
+ 'aria-label': settingsAriaLabel,
441
+ 'aria-expanded': 'false'
442
+ }
443
+ });
444
+ this.settingsButton.appendChild(createIconElement('settings'));
445
+ DOMUtils.attachTooltip(this.settingsButton, settingsAriaLabel, this.player.options.classPrefix);
446
+
447
+ // The panel owns the settings-menu DOM, lifecycle, and the drag
448
+ // + resize toggle items. Transcript-specific extras (style dialog
449
+ // opener and timestamps toggle) are appended via the extras
450
+ // builder below. The manager keeps ownership of the toggle
451
+ // semantics (announcements, focus target), which the panel
452
+ // invokes via the click callbacks.
453
+ this._panel = new DraggablePanel({
454
+ player: this.player,
455
+ namespace: 'transcript',
456
+ settingsButton: this.settingsButton,
457
+ getDraggable: () => this.draggableResizable,
458
+ i18nKeys: {
459
+ enableDrag: 'transcript.enableDragMode',
460
+ disableDrag: 'transcript.disableDragMode',
461
+ enableDragAria: 'transcript.enableDragModeAria',
462
+ disableDragAria: 'transcript.disableDragModeAria',
463
+ enableResize: 'transcript.enableResizeMode',
464
+ disableResize: 'transcript.disableResizeMode',
465
+ enableResizeAria: 'transcript.enableResizeModeAria',
466
+ disableResizeAria: 'transcript.disableResizeModeAria',
467
+ closeMenu: 'transcript.closeMenu',
468
+ },
469
+ menuAlign: 'left',
470
+ getMenuParent: () =>
471
+ this.headerLeft ?? this.transcriptHeader ?? this.transcriptWindow,
472
+ // The transcript window itself is the anchor for the mode badge
473
+ // so it sits above the panel regardless of where the settings
474
+ // menu was opened from. The default class name is derived from
475
+ // the namespace → `{classPrefix}-transcript-mode-badge`.
476
+ getBadgeHost: () => this.transcriptWindow,
477
+ onDragItemClick: (panel) => {
478
+ this.toggleKeyboardDragMode();
479
+ panel.hide();
480
+ },
481
+ onResizeItemClick: (panel) => {
482
+ const enabled = this.toggleResizeMode({ focus: false });
483
+ if (enabled) {
484
+ panel.hide({ focusButton: false });
485
+ setTimeout(() => {
486
+ this.transcriptWindow?.focus({ preventScroll: true });
487
+ }, 20);
488
+ } else {
489
+ panel.hide({ focusButton: true });
490
+ }
491
+ },
492
+ buildExtraItems: ({ menu, itemClass, classPrefix, stripInlineTooltip }) => {
493
+ const styleOption = createMenuItem({
494
+ classPrefix,
495
+ itemClass,
496
+ icon: 'settings',
497
+ label: 'transcript.styleTranscript',
498
+ onClick: (e: Event) => {
499
+ e.preventDefault();
500
+ e.stopPropagation();
501
+ this._panel?.hide();
502
+ setTimeout(() => {
503
+ this.showStyleDialog();
504
+ }, 50);
505
+ },
506
+ });
507
+ stripInlineTooltip(styleOption);
508
+ menu.appendChild(styleOption);
509
+
510
+ const timestampsOption = createMenuItem({
511
+ classPrefix,
512
+ itemClass,
513
+ icon: 'clock',
514
+ label: 'transcript.showTimestamps',
515
+ hasTextClass: true,
516
+ onClick: () => {
517
+ this.toggleShowTimestamps();
518
+ },
519
+ });
520
+ timestampsOption.setAttribute('role', 'switch');
521
+ timestampsOption.setAttribute(
522
+ 'aria-checked',
523
+ this.showTimestamps ? 'true' : 'false'
524
+ );
525
+ stripInlineTooltip(timestampsOption);
526
+ this.showTimestampsButton = timestampsOption;
527
+ this.showTimestampsText = timestampsOption.querySelector(
528
+ `.${classPrefix}-settings-text`
529
+ );
530
+ this.updateShowTimestampsState();
531
+ menu.appendChild(timestampsOption);
532
+ },
533
+ });
534
+
535
+ this.handlers.settingsClick = (e: MouseEvent) => {
536
+ e.preventDefault();
537
+ e.stopPropagation();
538
+ this._panel?.markJustOpenedForClick();
539
+ this._panel?.toggle();
540
+ };
541
+ this.settingsButton.addEventListener('click', this.handlers.settingsClick);
542
+
543
+ // Keyboard handler for settings button
544
+ this.handlers.settingsKeydown = (e: KeyboardEvent) => {
545
+ // D key to toggle keyboard drag mode
546
+ if (e.key === 'd' || e.key === 'D') {
547
+ e.preventDefault();
548
+ e.stopPropagation();
549
+ this.toggleKeyboardDragMode();
550
+ }
551
+ // R key to toggle resize mode
552
+ else if (e.key === 'r' || e.key === 'R') {
553
+ e.preventDefault();
554
+ e.stopPropagation();
555
+ this.toggleResizeMode();
556
+ }
557
+ // Escape to close menu if open
558
+ else if (e.key === 'Escape' && this.settingsMenuVisible) {
559
+ e.preventDefault();
560
+ e.stopPropagation();
561
+ this.hideSettingsMenu();
562
+ }
563
+ };
564
+ this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
565
+
566
+ const title = DOMUtils.createElement('h3', {
567
+ textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
568
+ });
569
+
570
+ // Autoscroll checkbox
571
+ const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
572
+
573
+ const autoscrollLabel = DOMUtils.createElement('label', {
574
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
575
+ attributes: {
576
+ 'for': autoscrollId
577
+ }
578
+ });
579
+
580
+ this.autoscrollCheckbox = DOMUtils.createElement('input', {
581
+ attributes: {
582
+ 'id': autoscrollId,
583
+ 'type': 'checkbox'
584
+ }
585
+ });
586
+ // Set checked property directly (boolean attribute, not "true" string)
587
+ if (this.autoscrollEnabled) {
588
+ this.autoscrollCheckbox.checked = true;
589
+ }
590
+
591
+ const autoscrollText = DOMUtils.createElement('span', {
592
+ textContent: i18n.t('transcript.autoscroll'),
593
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
594
+ });
595
+
596
+ autoscrollLabel.appendChild(this.autoscrollCheckbox);
597
+ autoscrollLabel.appendChild(autoscrollText);
598
+
599
+ // Handle autoscroll checkbox change
600
+ this.autoscrollCheckbox.addEventListener('change', (e: Event) => {
601
+ this.autoscrollEnabled = (e.target as HTMLInputElement).checked;
602
+ this.saveAutoscrollPreference();
603
+ });
604
+
605
+ preventDragOnElement(autoscrollLabel);
606
+
607
+ this.transcriptHeader.appendChild(title);
608
+ this.headerLeft.appendChild(this.settingsButton);
609
+ this.headerLeft.appendChild(autoscrollLabel);
610
+
611
+ // Language selector (will be populated after tracks are loaded)
612
+ const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
613
+ const { label: languageLabel, select: languageSelector } = createLabeledSelect({
614
+ classPrefix: this.player.options.classPrefix,
615
+ labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
616
+ selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
617
+ labelText: 'settings.language',
618
+ selectId: selectId,
619
+ hidden: false // Don't hide individual elements, we'll hide the wrapper instead
620
+ });
621
+
622
+ this.languageLabel = languageLabel;
623
+ this.languageSelector = languageSelector;
624
+
625
+ const languageSelectorWrapper = DOMUtils.createElement('div', {
626
+ className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
627
+ attributes: {
628
+ 'style': 'display: none;'
629
+ }
630
+ });
631
+ languageSelectorWrapper.appendChild(languageLabel);
632
+ languageSelectorWrapper.appendChild(languageSelector);
633
+ this.languageSelectorWrapper = languageSelectorWrapper;
634
+
635
+ preventDragOnElement(languageSelectorWrapper);
636
+
637
+ if (this.headerLeft) {
638
+ this.headerLeft.appendChild(languageSelectorWrapper);
639
+ }
640
+
641
+ const closeAriaLabel = i18n.t('transcript.close');
642
+ const closeButton = DOMUtils.createElement('button', {
643
+ className: `${this.player.options.classPrefix}-transcript-close`,
644
+ attributes: {
645
+ 'type': 'button',
646
+ 'aria-label': closeAriaLabel
647
+ }
648
+ });
649
+ closeButton.appendChild(createIconElement('close'));
650
+ DOMUtils.attachTooltip(closeButton, closeAriaLabel, this.player.options.classPrefix);
651
+ closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
652
+
653
+ this.transcriptHeader.appendChild(this.headerLeft);
654
+ this.transcriptHeader.appendChild(closeButton);
655
+
656
+ // Content container
657
+ this.transcriptContent = DOMUtils.createElement('div', {
658
+ className: `${this.player.options.classPrefix}-transcript-content`
659
+ });
660
+
661
+ this.transcriptWindow.appendChild(this.transcriptHeader);
662
+ this.transcriptWindow.appendChild(this.transcriptContent);
663
+
664
+ this.createResizeHandles();
665
+
666
+ // Live region for announcements (screen reader feedback)
667
+ this.liveRegion = DOMUtils.createElement('div', {
668
+ className: 'vidply-sr-only',
669
+ attributes: {
670
+ 'aria-live': 'polite',
671
+ 'aria-atomic': 'true'
672
+ }
673
+ });
674
+ this.transcriptWindow.appendChild(this.liveRegion);
675
+
676
+ // Append to player container
677
+ this.player.container.appendChild(this.transcriptWindow);
678
+
679
+ // Setup drag functionality FIRST (this will restore saved position if it exists)
680
+ this.setupDragAndDrop();
681
+
682
+ // Then position it next to the video wrapper ONLY if user hasn't manually positioned it
683
+ // This ensures we don't overwrite saved positions from localStorage
684
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
685
+ this.positionTranscript();
686
+ }
687
+
688
+ // Outside-click dismissal for the *style dialog* only. The
689
+ // settings-menu equivalent is owned by the DraggablePanel, which
690
+ // attaches its own listener (tied to the player's lifecycle
691
+ // signal) when the menu is first opened.
692
+ this.handlers.documentClick = (e: MouseEvent) => {
693
+ if (this.styleDialogJustOpened) {
694
+ return;
695
+ }
696
+ const target = e.target as Node | null;
697
+ if (this.styleDialogVisible && this.styleDialog &&
698
+ target && !this.styleDialog.contains(target)) {
699
+ this.hideStyleDialog();
700
+ }
701
+ };
702
+ this.documentClickHandlerAdded = false;
703
+
704
+ // Re-position on window resize (debounced) - but only if not manually positioned
705
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
706
+ this.handlers.resize = () => {
707
+ if (resizeTimeout) {
708
+ this.clearManagedTimeout(resizeTimeout);
709
+ }
710
+ resizeTimeout = this.setManagedTimeout(() => {
711
+ // Only auto-position if user hasn't manually moved it
712
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
713
+ this.positionTranscript();
714
+ }
715
+ }, 100);
716
+ };
717
+ window.addEventListener('resize', this.handlers.resize, {
718
+ signal: this.player.lifecycleSignal
719
+ });
720
+ }
721
+
722
+ createResizeHandles() {
723
+ const transcriptWindow = this.transcriptWindow;
724
+ if (!transcriptWindow) return;
725
+
726
+ const directions: Array<'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'> = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
727
+ this.transcriptResizeHandles = directions.map(direction => {
728
+ const handle = DOMUtils.createElement('div', {
729
+ className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
730
+ attributes: {
731
+ 'data-direction': direction,
732
+ 'data-vidply-managed-resize': 'true',
733
+ 'aria-hidden': 'true'
734
+ }
735
+ });
736
+
737
+ handle.style.display = 'none';
738
+ transcriptWindow.appendChild(handle);
739
+ return handle;
740
+ });
741
+ }
742
+
743
+ /**
744
+ * Position transcript window next to video
745
+ */
746
+ positionTranscript() {
747
+ const playerWithVideoWrapper = this.player as Player & { videoWrapper?: HTMLElement | null };
748
+ if (!this.transcriptWindow || !playerWithVideoWrapper.videoWrapper || !this.isVisible) return;
749
+
750
+ // Don't auto-position if user has manually positioned it
751
+ if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
752
+ return;
753
+ }
754
+
755
+ const isMobile = window.innerWidth < 768;
756
+ const videoRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
757
+
758
+ // Check if player is in fullscreen mode
759
+ const isFullscreen = this.player.state.fullscreen;
760
+
761
+ if (isMobile && !isFullscreen) {
762
+ // Mobile: Position directly underneath the video controls as part of the layout
763
+ this.transcriptWindow.style.position = 'relative';
764
+ this.transcriptWindow.style.left = '0';
765
+ this.transcriptWindow.style.right = '0';
766
+ this.transcriptWindow.style.bottom = 'auto';
767
+ this.transcriptWindow.style.top = 'auto';
768
+ this.transcriptWindow.style.width = '100%';
769
+ this.transcriptWindow.style.maxWidth = '100%';
770
+ this.transcriptWindow.style.maxHeight = '300px';
771
+ this.transcriptWindow.style.height = 'auto';
772
+ this.transcriptWindow.style.borderRadius = '0';
773
+ this.transcriptWindow.style.transform = 'none';
774
+ this.transcriptWindow.style.border = 'none';
775
+ this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
776
+ // Remove any empty border properties that might have been set
777
+ this.transcriptWindow.style.removeProperty('border-right');
778
+ this.transcriptWindow.style.removeProperty('border-bottom');
779
+ this.transcriptWindow.style.removeProperty('border-left');
780
+ // Remove border-image properties that can cause parse errors
781
+ this.transcriptWindow.style.removeProperty('border-image');
782
+ this.transcriptWindow.style.removeProperty('border-image-source');
783
+ this.transcriptWindow.style.removeProperty('border-image-slice');
784
+ this.transcriptWindow.style.removeProperty('border-image-width');
785
+ this.transcriptWindow.style.removeProperty('border-image-outset');
786
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
787
+ this.transcriptWindow.style.boxShadow = 'none';
788
+ // Disable dragging on mobile
789
+ if (this.transcriptHeader) {
790
+ this.transcriptHeader.style.cursor = 'default';
791
+ }
792
+
793
+ // Insert directly after video wrapper to appear right under controls
794
+ const videoWrapper = playerWithVideoWrapper.videoWrapper;
795
+ if (videoWrapper && videoWrapper.parentNode && videoWrapper.nextSibling !== this.transcriptWindow) {
796
+ videoWrapper.parentNode.insertBefore(this.transcriptWindow, videoWrapper.nextSibling);
797
+ }
798
+ } else if (isFullscreen) {
799
+ // In fullscreen: position in bottom right corner inside the video
800
+ this.transcriptWindow.style.position = 'fixed';
801
+ this.transcriptWindow.style.left = 'auto';
802
+ this.transcriptWindow.style.right = '20px';
803
+ this.transcriptWindow.style.bottom = '80px'; // Above controls
804
+ this.transcriptWindow.style.top = 'auto';
805
+ this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
806
+ this.transcriptWindow.style.height = 'auto';
807
+ const fullscreenMinWidth = 260;
808
+ const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
809
+ const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
810
+ const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
811
+ this.transcriptWindow.style.width = `${fullscreenWidth}px`;
812
+ this.transcriptWindow.style.maxWidth = 'none';
813
+ this.transcriptWindow.style.borderRadius = '8px';
814
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
815
+ // Remove borderTop and any other individual border properties to avoid empty values
816
+ this.transcriptWindow.style.removeProperty('border-top');
817
+ this.transcriptWindow.style.removeProperty('border-right');
818
+ this.transcriptWindow.style.removeProperty('border-bottom');
819
+ this.transcriptWindow.style.removeProperty('border-left');
820
+ // Remove border-image properties that can cause parse errors
821
+ this.transcriptWindow.style.removeProperty('border-image');
822
+ this.transcriptWindow.style.removeProperty('border-image-source');
823
+ this.transcriptWindow.style.removeProperty('border-image-slice');
824
+ this.transcriptWindow.style.removeProperty('border-image-width');
825
+ this.transcriptWindow.style.removeProperty('border-image-outset');
826
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
827
+ // Enable dragging in fullscreen (including touch devices)
828
+ if (this.transcriptHeader) {
829
+ this.transcriptHeader.style.cursor = 'move';
830
+ }
831
+
832
+ // Move back to container for fullscreen
833
+ if (this.transcriptWindow.parentNode !== this.player.container) {
834
+ this.player.container.appendChild(this.transcriptWindow);
835
+ }
836
+ } else {
837
+ // Desktop mode: position to the right of video, or overlay if insufficient space
838
+ const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
839
+ const padding = 20;
840
+ const minWidth = 260;
841
+ const containerRect = this.player.container.getBoundingClientRect();
842
+
843
+ const ensureContainerPositioned = () => {
844
+ const computed = window.getComputedStyle(this.player.container);
845
+ if (computed.position === 'static') {
846
+ this.player.container.style.position = 'relative';
847
+ }
848
+ };
849
+
850
+ ensureContainerPositioned();
851
+
852
+ // Calculate available space to the right of the video
853
+ // WCAG 1.4.10 Reflow: Avoid horizontal overflow and content being cut off
854
+ const availableWidth = window.innerWidth - videoRect.right - padding;
855
+
856
+ // Switch to overlay mode when:
857
+ // 1. Available width is less than the desired transcript width (would cause squeezing/cutoff)
858
+ // 2. OR available width is below minimum usable width
859
+ // This triggers earlier - as soon as the transcript would be squeezed
860
+ const wouldBeCutOff = availableWidth < transcriptWidth;
861
+ const hasMinimumSpace = availableWidth >= minWidth;
862
+ const useOverlay = wouldBeCutOff || !hasMinimumSpace;
863
+
864
+ if (!useOverlay) {
865
+ // Position to the right of the video (original behavior)
866
+ const left = (videoRect.right - containerRect.left) + padding;
867
+ const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
868
+ const appliedHeight = videoRect.height;
869
+
870
+ this.transcriptWindow.style.position = 'absolute';
871
+ this.transcriptWindow.style.left = `${left}px`;
872
+ this.transcriptWindow.style.right = 'auto';
873
+ this.transcriptWindow.style.bottom = 'auto';
874
+ this.transcriptWindow.style.top = '0';
875
+ this.transcriptWindow.style.height = `${appliedHeight}px`;
876
+ this.transcriptWindow.style.maxHeight = 'none';
877
+ this.transcriptWindow.style.width = `${appliedWidth}px`;
878
+ this.transcriptWindow.style.maxWidth = 'none';
879
+ this.transcriptWindow.style.boxShadow = '';
880
+ } else {
881
+ // Insufficient space: fall back to compact overlay mode inside video area
882
+ // Positioned above controls to prevent horizontal overflow
883
+ const overlayMaxWidth = 320;
884
+ const overlayMaxHeight = 280;
885
+ const overlayWidth = Math.min(overlayMaxWidth, videoRect.width - 40);
886
+ const overlayHeight = Math.min(overlayMaxHeight, videoRect.height - 120);
887
+
888
+ // Calculate position relative to container, placing overlay inside video area
889
+ const videoWrapperRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
890
+ const controlsHeight = 70; // Approximate height of controls
891
+
892
+ // Position from top of container to bottom of video minus controls and overlay height
893
+ const overlayActualHeight = Math.max(180, overlayHeight);
894
+ const topPosition = (videoWrapperRect.bottom - containerRect.top) - controlsHeight - overlayActualHeight;
895
+ const rightPosition = (containerRect.right - videoWrapperRect.right) + 12;
896
+
897
+ this.transcriptWindow.style.position = 'absolute';
898
+ this.transcriptWindow.style.left = 'auto';
899
+ this.transcriptWindow.style.right = `${rightPosition}px`;
900
+ this.transcriptWindow.style.top = `${topPosition}px`;
901
+ this.transcriptWindow.style.bottom = 'auto';
902
+ this.transcriptWindow.style.width = `${Math.max(minWidth, overlayWidth)}px`;
903
+ this.transcriptWindow.style.maxWidth = `${overlayMaxWidth}px`;
904
+ this.transcriptWindow.style.height = `${overlayActualHeight}px`;
905
+ this.transcriptWindow.style.maxHeight = `${overlayMaxHeight}px`;
906
+ this.transcriptWindow.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.6)'; // Enhanced shadow for overlay visibility
907
+ }
908
+
909
+ this.transcriptWindow.style.borderRadius = '8px';
910
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
911
+ // Remove borderTop and any other individual border properties to avoid empty values
912
+ this.transcriptWindow.style.removeProperty('border-top');
913
+ this.transcriptWindow.style.removeProperty('border-right');
914
+ this.transcriptWindow.style.removeProperty('border-bottom');
915
+ this.transcriptWindow.style.removeProperty('border-left');
916
+ // Remove border-image properties that can cause parse errors
917
+ this.transcriptWindow.style.removeProperty('border-image');
918
+ this.transcriptWindow.style.removeProperty('border-image-source');
919
+ this.transcriptWindow.style.removeProperty('border-image-slice');
920
+ this.transcriptWindow.style.removeProperty('border-image-width');
921
+ this.transcriptWindow.style.removeProperty('border-image-outset');
922
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
923
+ // Enable dragging on desktop
924
+ if (this.transcriptHeader) {
925
+ this.transcriptHeader.style.cursor = 'move';
926
+ }
927
+
928
+ // Move back to container for desktop
929
+ if (this.transcriptWindow.parentNode !== this.player.container) {
930
+ this.player.container.appendChild(this.transcriptWindow);
931
+ }
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Get available transcript languages from tracks
937
+ */
938
+ getAvailableTranscriptLanguages() {
939
+ const textTracks = this.player.textTracks as TranscriptTrack[];
940
+ const languages = new Map<string, TranscriptLanguageInfo>();
941
+
942
+ // Collect all caption/subtitle tracks with their languages.
943
+ // For DASH/HLS, tracks may have placeholder labels (AdaptationSet IDs,
944
+ // "null", etc.); deriveTrackLabel normalises them to human-readable names.
945
+ textTracks.forEach((track: TranscriptTrack) => {
946
+ if ((track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale) {
947
+ const lang = (track.language ?? '').trim();
948
+ const label = deriveTrackLabel(track.label, track.language, 'player.captions');
949
+ const key = lang || label;
950
+ if (key && !languages.has(key)) {
951
+ languages.set(key, {
952
+ language: lang,
953
+ label,
954
+ track: track
955
+ });
956
+ }
957
+ }
958
+ });
959
+
960
+ return Array.from(languages.values());
961
+ }
962
+
963
+ /**
964
+ * Update language selector dropdown
965
+ */
966
+ updateLanguageSelector() {
967
+ const languageSelector = this.languageSelector;
968
+ if (!languageSelector) return;
969
+
970
+ this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
971
+
972
+ languageSelector.innerHTML = '';
973
+
974
+ if (this.availableTranscriptLanguages.length < 2) {
975
+ if (this.languageSelectorWrapper) {
976
+ this.languageSelectorWrapper.style.display = 'none';
977
+ }
978
+ return;
979
+ }
980
+
981
+ if (this.languageSelectorWrapper) {
982
+ this.languageSelectorWrapper.style.display = 'flex';
983
+ }
984
+
985
+ this.availableTranscriptLanguages.forEach((langInfo: TranscriptLanguageInfo) => {
986
+ const attrs: Record<string, string> = {
987
+ 'value': langInfo.language || langInfo.label
988
+ };
989
+ if (langInfo.language) {
990
+ attrs['lang'] = langInfo.language;
991
+ }
992
+ const option = DOMUtils.createElement('option', {
993
+ textContent: langInfo.label,
994
+ attributes: attrs
995
+ });
996
+ languageSelector.appendChild(option);
997
+ });
998
+
999
+ if (this.currentTranscriptLanguage) {
1000
+ languageSelector.value = this.currentTranscriptLanguage;
1001
+ } else if (this.availableTranscriptLanguages.length > 0) {
1002
+ const activeTrack = this.player.textTracks.find(
1003
+ (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
1004
+ );
1005
+ const firstLang = this.availableTranscriptLanguages[0];
1006
+ const fallbackLang = firstLang ? firstLang.language : null;
1007
+ this.currentTranscriptLanguage = activeTrack ? activeTrack.language : fallbackLang;
1008
+ if (this.currentTranscriptLanguage) {
1009
+ languageSelector.value = this.currentTranscriptLanguage;
1010
+ }
1011
+ }
1012
+
1013
+ if (this.languageSelectorHandler) {
1014
+ languageSelector.removeEventListener('change', this.languageSelectorHandler);
1015
+ }
1016
+
1017
+ // Handle language change — tell the streaming renderer to switch tracks
1018
+ // first so dash.js starts downloading cues for the new language, then
1019
+ // render whatever is available (textcuesupdate will re-render later).
1020
+ const handler = (e: Event) => {
1021
+ this.currentTranscriptLanguage = (e.target as HTMLSelectElement).value;
1022
+ this._requestStreamingTrack(this.currentTranscriptLanguage);
1023
+ this.loadTranscriptData();
1024
+
1025
+ if (this.transcriptContent && this.currentTranscriptLanguage) {
1026
+ this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
1027
+ }
1028
+ };
1029
+ this.languageSelectorHandler = handler;
1030
+ languageSelector.addEventListener('change', handler);
1031
+ }
1032
+
1033
+ private _parseVTT(vttText: string): TranscriptCueItem[] {
1034
+ const cues: TranscriptCueItem[] = [];
1035
+ const blocks = vttText.replace(/\r\n/g, '\n').split(/\n\n+/);
1036
+ for (const block of blocks) {
1037
+ const lines = block.trim().split('\n');
1038
+ let tsLine = -1;
1039
+ for (let i = 0; i < lines.length; i++) {
1040
+ if (lines[i]?.includes('-->')) { tsLine = i; break; }
1041
+ }
1042
+ if (tsLine < 0) continue;
1043
+ const match = lines[tsLine]?.match(
1044
+ /(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})/
1045
+ );
1046
+ if (!match) continue;
1047
+ const startTime =
1048
+ (match[1] ? parseInt(match[1]) * 3600 : 0) +
1049
+ parseInt(match[2] ?? '0') * 60 +
1050
+ parseInt(match[3] ?? '0') +
1051
+ parseInt(match[4] ?? '0') / 1000;
1052
+ const endTime =
1053
+ (match[5] ? parseInt(match[5]) * 3600 : 0) +
1054
+ parseInt(match[6] ?? '0') * 60 +
1055
+ parseInt(match[7] ?? '0') +
1056
+ parseInt(match[8] ?? '0') / 1000;
1057
+ const text = lines.slice(tsLine + 1).join('\n').trim();
1058
+ if (!text) continue;
1059
+ cues.push({
1060
+ cue: { startTime, endTime, text, id: '' } as unknown as TranscriptCue,
1061
+ type: 'caption'
1062
+ });
1063
+ }
1064
+ return cues;
1065
+ }
1066
+
1067
+ private async _loadVttTranscript(lang: string): Promise<TranscriptCueItem[] | null> {
1068
+ const cached = this._vttCache.get(lang);
1069
+ if (cached) return cached;
1070
+
1071
+ const renderer = this.player.renderer as Renderer | null | undefined;
1072
+ if (!renderer?.isStreaming || typeof renderer.getTextTrackURLs !== 'function') return null;
1073
+
1074
+ const urls: { lang: string; url: string }[] = renderer.getTextTrackURLs();
1075
+ const entry = urls.find(u =>
1076
+ u.lang === lang || u.lang.startsWith(lang) || lang.startsWith(u.lang)
1077
+ );
1078
+ if (!entry) return null;
1079
+
1080
+ // Build a combined AbortSignal: the player's lifecycle controller plus
1081
+ // a 10s timeout per individual fetch.
1082
+ const signal = this._buildFetchSignal(10_000);
1083
+
1084
+ try {
1085
+ const res = await fetch(entry.url, { signal });
1086
+ if (!res.ok) return null;
1087
+ let text = await res.text();
1088
+
1089
+ // HLS subtitle playlists are m3u8 files that reference the actual VTT.
1090
+ // Resolve the VTT URI and fetch it instead.
1091
+ if (text.trimStart().startsWith('#EXTM3U')) {
1092
+ const vttUri = text.split('\n')
1093
+ .map(l => l.trim())
1094
+ .find(l => l && !l.startsWith('#'));
1095
+ if (!vttUri) return null;
1096
+ const baseUrl = entry.url.substring(0, entry.url.lastIndexOf('/') + 1);
1097
+ const vttUrl = vttUri.startsWith('http') ? vttUri : new URL(vttUri, baseUrl).href;
1098
+ const vttRes = await fetch(vttUrl, { signal: this._buildFetchSignal(10_000) });
1099
+ if (!vttRes.ok) return null;
1100
+ text = await vttRes.text();
1101
+ }
1102
+
1103
+ const cues = this._parseVTT(text);
1104
+ if (cues.length > 0) this._vttCache.set(lang, cues);
1105
+ return cues;
1106
+ } catch {
1107
+ return null;
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Build an AbortSignal that fires when either the player is destroyed
1113
+ * or `timeoutMs` elapses, whichever happens first.
1114
+ */
1115
+ private _buildFetchSignal(timeoutMs: number): AbortSignal | undefined {
1116
+ const signals: AbortSignal[] = [];
1117
+ const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
1118
+ if (lifecycle) signals.push(lifecycle);
1119
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
1120
+ signals.push(AbortSignal.timeout(timeoutMs));
1121
+ }
1122
+ if (signals.length === 0) return undefined;
1123
+ if (signals.length === 1) return signals[0];
1124
+ const anyFn = (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any;
1125
+ return anyFn ? anyFn(signals) : signals[0];
1126
+ }
1127
+
1128
+ /**
1129
+ * Load transcript data from caption/subtitle tracks
1130
+ */
1131
+ loadTranscriptData() {
1132
+ this.transcriptEntries = [];
1133
+ this.currentActiveEntry = null;
1134
+ if (this.transcriptContent) {
1135
+ this.transcriptContent.innerHTML = '';
1136
+ }
1137
+
1138
+ // Get all text tracks
1139
+ const textTracks = this.player.textTracks as TranscriptTrack[];
1140
+
1141
+ // Find track for selected language, or default to first available.
1142
+ // Safari native HLS can expose duplicate TextTrack objects for the same
1143
+ // language (SUBTITLES vs CLOSED-CAPTIONS); prefer the one with cues loaded.
1144
+ let captionTrack: TranscriptTrack | null = null;
1145
+ if (this.currentTranscriptLanguage) {
1146
+ const candidates = textTracks.filter(
1147
+ (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') &&
1148
+ track.language === this.currentTranscriptLanguage &&
1149
+ !track._vidplyStale
1150
+ );
1151
+ captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
1152
+ }
1153
+
1154
+ if (!captionTrack) {
1155
+ const candidates = textTracks.filter(
1156
+ (track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale
1157
+ );
1158
+ captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
1159
+ if (captionTrack) {
1160
+ this.currentTranscriptLanguage = captionTrack.language;
1161
+ }
1162
+ }
1163
+
1164
+ // Find description track matching the selected language
1165
+ let descriptionTrack: TranscriptTrack | null = null;
1166
+ if (this.currentTranscriptLanguage) {
1167
+ descriptionTrack = textTracks.find(
1168
+ (track: TranscriptTrack) => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
1169
+ ) || null;
1170
+ }
1171
+ // Fallback to first available description track if no match found
1172
+ if (!descriptionTrack) {
1173
+ descriptionTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'descriptions') || null;
1174
+ }
1175
+
1176
+ const metadataTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'metadata');
1177
+
1178
+ // We need at least one track type available for display
1179
+ // (captions, descriptions, or metadata - though metadata is not displayed)
1180
+ if (!captionTrack && !descriptionTrack && !metadataTrack) {
1181
+ this.showNoTranscriptMessage();
1182
+ return;
1183
+ }
1184
+
1185
+ // Enable all tracks to load cues so they're available for the transcript
1186
+ const tracksToLoad: TranscriptTrack[] = [captionTrack, descriptionTrack, metadataTrack].filter(
1187
+ (track): track is TranscriptTrack => Boolean(track)
1188
+ );
1189
+ tracksToLoad.forEach((track: TranscriptTrack) => {
1190
+ if (track.mode === 'disabled') {
1191
+ track.mode = 'hidden';
1192
+ }
1193
+ });
1194
+
1195
+ // For streaming renderers, fetch the complete VTT file directly rather
1196
+ // than relying on dash.js's partial segment-based cues.
1197
+ const renderer = this.player.renderer as Renderer | null | undefined;
1198
+ const isStreaming = renderer?.isStreaming && typeof renderer.getTextTrackURLs === 'function';
1199
+ const lang = this.currentTranscriptLanguage || (captionTrack?.language ?? '');
1200
+
1201
+ if (isStreaming && lang) {
1202
+ const loadingMessage = DOMUtils.createElement('div', {
1203
+ className: `${this.player.options.classPrefix}-transcript-loading`,
1204
+ textContent: i18n.t('transcript.loading')
1205
+ });
1206
+ this.transcriptContent?.appendChild(loadingMessage);
1207
+
1208
+ this._loadVttTranscript(lang).then(vttCues => {
1209
+ if (!this.isVisible) return;
1210
+ this._renderTranscriptCues(
1211
+ vttCues && vttCues.length > 0 ? vttCues : null,
1212
+ captionTrack,
1213
+ descriptionTrack,
1214
+ metadataTrack
1215
+ );
1216
+ });
1217
+ return;
1218
+ }
1219
+
1220
+ // Non-streaming path: wait for sidecar <track> elements to load cues.
1221
+ const primaryTrack = captionTrack;
1222
+ const primaryNeedsCues = primaryTrack && (!primaryTrack.cues || primaryTrack.cues.length === 0);
1223
+
1224
+ if (primaryNeedsCues) {
1225
+ const loadingMessage = DOMUtils.createElement('div', {
1226
+ className: `${this.player.options.classPrefix}-transcript-loading`,
1227
+ textContent: i18n.t('transcript.loading')
1228
+ });
1229
+ this.transcriptContent?.appendChild(loadingMessage);
1230
+
1231
+ const hasSidecarElement = this.player.findTrackElement?.(primaryTrack);
1232
+ if (hasSidecarElement) {
1233
+ primaryTrack.addEventListener('load', () => {
1234
+ this.loadTranscriptData();
1235
+ }, { once: true });
1236
+ this.setManagedTimeout(() => { this.loadTranscriptData(); }, 1000);
1237
+ } else {
1238
+ let attempts = 0;
1239
+ const poll = () => {
1240
+ attempts++;
1241
+ if (!this.isVisible) return;
1242
+ if (primaryTrack.cues && primaryTrack.cues.length > 0) {
1243
+ this.loadTranscriptData();
1244
+ return;
1245
+ }
1246
+ if (attempts < 40) {
1247
+ this.setManagedTimeout(poll, 500);
1248
+ }
1249
+ };
1250
+ this.setManagedTimeout(poll, 300);
1251
+ }
1252
+ return;
1253
+ }
1254
+
1255
+ this._renderTranscriptCues(null, captionTrack, descriptionTrack, metadataTrack);
1256
+ }
1257
+
1258
+ private _renderTranscriptCues(
1259
+ vttCues: TranscriptCueItem[] | null,
1260
+ captionTrack: TranscriptTrack | null,
1261
+ descriptionTrack: TranscriptTrack | null,
1262
+ metadataTrack: TranscriptTrack | undefined | null
1263
+ ) {
1264
+ this.transcriptEntries = [];
1265
+ this.currentActiveEntry = null;
1266
+ const transcriptContent = this.transcriptContent;
1267
+ if (transcriptContent) {
1268
+ transcriptContent.innerHTML = '';
1269
+ }
1270
+
1271
+ const allCues: TranscriptCueItem[] = [];
1272
+
1273
+ if (vttCues && vttCues.length > 0) {
1274
+ allCues.push(...vttCues);
1275
+ } else if (captionTrack && captionTrack.cues) {
1276
+ Array.from(captionTrack.cues).forEach((cue: TranscriptCue) => {
1277
+ allCues.push({ cue, type: 'caption' });
1278
+ });
1279
+ }
1280
+
1281
+ if (descriptionTrack && descriptionTrack.cues) {
1282
+ Array.from(descriptionTrack.cues).forEach((cue: TranscriptCue) => {
1283
+ allCues.push({ cue, type: 'description' });
1284
+ });
1285
+ }
1286
+
1287
+ if (metadataTrack && metadataTrack.cues) {
1288
+ this.metadataCues = Array.from(metadataTrack.cues) as TranscriptCue[];
1289
+ this.setupMetadataHandling();
1290
+ }
1291
+
1292
+ allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
1293
+
1294
+ allCues.forEach((item, index) => {
1295
+ const entry = this.createTranscriptEntry(item.cue, index, item.type);
1296
+ this.transcriptEntries.push({
1297
+ element: entry,
1298
+ cue: item.cue,
1299
+ type: item.type,
1300
+ startTime: item.cue.startTime,
1301
+ endTime: item.cue.endTime
1302
+ });
1303
+ transcriptContent?.appendChild(entry);
1304
+ });
1305
+
1306
+ this.applyTranscriptStyles();
1307
+ this.updateTimestampVisibility();
1308
+
1309
+ if (this.transcriptContent && this.currentTranscriptLanguage) {
1310
+ this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
1311
+ }
1312
+
1313
+ this.updateLanguageSelector();
1314
+ this.updateActiveEntry();
1315
+ }
1316
+
1317
+ /**
1318
+ * Setup metadata handling on player load
1319
+ * This runs independently of transcript loading
1320
+ */
1321
+ setupMetadataHandlingOnLoad() {
1322
+ // Wait for metadata to be loaded
1323
+ const setupMetadata = () => {
1324
+ const textTracks = this.player.textTracks;
1325
+ const metadataTrack = (textTracks as TranscriptTrack[]).find((track: TranscriptTrack) => track.kind === 'metadata');
1326
+
1327
+ if (metadataTrack) {
1328
+ // Enable the metadata track so cuechange events fire
1329
+ // Use 'hidden' mode so it doesn't display anything, but events still work
1330
+ if (metadataTrack.mode === 'disabled') {
1331
+ metadataTrack.mode = 'hidden';
1332
+ }
1333
+
1334
+ // Check if we already added the listener
1335
+ if (this.metadataCueChangeHandler) {
1336
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
1337
+ }
1338
+
1339
+ // Add event listener for cue changes
1340
+ this.metadataCueChangeHandler = () => {
1341
+ const activeCues = Array.from(metadataTrack.activeCues || []) as TranscriptCue[];
1342
+ if (activeCues.length > 0) {
1343
+ // Debug logging (can be removed in production)
1344
+ if (this.player.options.debug) {
1345
+ console.log('[VidPly Metadata] Active cues:', activeCues.map((c) => ({
1346
+ start: c.startTime,
1347
+ end: c.endTime,
1348
+ text: (c as VTTCue).text
1349
+ })));
1350
+ }
1351
+ }
1352
+ activeCues.forEach((cue: TranscriptCue) => {
1353
+ this.handleMetadataCue(cue);
1354
+ });
1355
+ };
1356
+
1357
+ metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
1358
+
1359
+ // Debug: Log metadata track setup
1360
+ if (this.player.options.debug) {
1361
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
1362
+ console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
1363
+ }
1364
+ } else if (this.player.options.debug) {
1365
+ console.warn('[VidPly Metadata] No metadata track found');
1366
+ }
1367
+ };
1368
+
1369
+ // Try immediately
1370
+ setupMetadata();
1371
+
1372
+ // Also try after loadedmetadata event
1373
+ this.player.on('loadedmetadata', setupMetadata);
1374
+ }
1375
+
1376
+ /**
1377
+ * Setup metadata handling
1378
+ * Metadata cues are not displayed but can be used programmatically
1379
+ * This is called when transcript data is loaded (for storing cues)
1380
+ */
1381
+ setupMetadataHandling() {
1382
+ if (!this.metadataCues || this.metadataCues.length === 0) {
1383
+ return;
1384
+ }
1385
+
1386
+ // The actual event handling is set up in setupMetadataHandlingOnLoad()
1387
+ // This method just stores the cues for reference
1388
+ if (this.player.options.debug) {
1389
+ console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Handle individual metadata cues
1395
+ * Parses metadata text and emits events or triggers actions
1396
+ */
1397
+ handleMetadataCue(cue: TranscriptCue) {
1398
+ const cueText = (cue as TextTrackCue & { text?: string }).text || '';
1399
+ const text = cueText.trim();
1400
+
1401
+ // Debug logging
1402
+ if (this.player.options.debug) {
1403
+ console.log('[VidPly Metadata] Processing cue:', {
1404
+ time: cue.startTime,
1405
+ text: text
1406
+ });
1407
+ }
1408
+
1409
+ // Emit a generic metadata event that developers can listen to
1410
+ this.player.emit('metadata', {
1411
+ time: cue.startTime,
1412
+ endTime: cue.endTime,
1413
+ text: text,
1414
+ cue: cue
1415
+ });
1416
+
1417
+ // Parse for specific commands (examples based on wwa_meta.vtt format)
1418
+ if (text.includes('PAUSE')) {
1419
+ // Automatically pause the video
1420
+ if (!this.player.state.paused) {
1421
+ if (this.player.options.debug) {
1422
+ console.log('[VidPly Metadata] Pausing video at', cue.startTime);
1423
+ }
1424
+ this.player.pause();
1425
+ }
1426
+ // Also emit event for developers who want to listen
1427
+ this.player.emit('metadata:pause', { time: cue.startTime, text: text });
1428
+ }
1429
+
1430
+ // Parse for focus directives
1431
+ const focusMatch = text.match(/FOCUS:([\w#-]+)/);
1432
+ if (focusMatch && focusMatch[1]) {
1433
+ const targetSelector = focusMatch[1];
1434
+ // Automatically focus the target element
1435
+ const targetElement = document.querySelector(targetSelector) as HTMLElement | null;
1436
+ if (targetElement) {
1437
+ if (this.player.options.debug) {
1438
+ console.log('[VidPly Metadata] Focusing element:', targetSelector);
1439
+ }
1440
+ // Use setTimeout to ensure DOM is ready
1441
+ this.setManagedTimeout(() => {
1442
+ targetElement.focus({ preventScroll: true });
1443
+ }, 10);
1444
+ } else if (this.player.options.debug) {
1445
+ console.warn('[VidPly Metadata] Element not found:', targetSelector);
1446
+ }
1447
+ // Also emit event for developers who want to listen
1448
+ this.player.emit('metadata:focus', {
1449
+ time: cue.startTime,
1450
+ target: targetSelector,
1451
+ element: targetElement,
1452
+ text: text
1453
+ });
1454
+ }
1455
+
1456
+ // Parse for hashtag references
1457
+ const hashtags = text.match(/#[\w-]+/g);
1458
+ if (hashtags) {
1459
+ if (this.player.options.debug) {
1460
+ console.log('[VidPly Metadata] Hashtags found:', hashtags);
1461
+ }
1462
+ this.player.emit('metadata:hashtags', {
1463
+ time: cue.startTime,
1464
+ hashtags: hashtags,
1465
+ text: text
1466
+ });
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * Create a single transcript entry element
1472
+ */
1473
+ createTranscriptEntry(cue: TranscriptCue, index: number, type: 'caption' | 'description' = 'caption') {
1474
+ const entryText = this.stripVTTFormatting(((cue as TextTrackCue & { text?: string }).text) || '');
1475
+
1476
+ // Use a real <button> so screen readers/AT report "button", Enter and
1477
+ // Space activate it natively, and forms-mode users can find it.
1478
+ const seekLabelTemplate = i18n.t('transcript.seekTo') || 'Seek to {time}';
1479
+ const ariaLabel = seekLabelTemplate
1480
+ .replace('{time}', TimeUtils.formatTime(cue.startTime))
1481
+ .replace('{text}', entryText);
1482
+
1483
+ const entry = DOMUtils.createElement('button', {
1484
+ className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
1485
+ attributes: {
1486
+ 'type': 'button',
1487
+ 'data-start': String(cue.startTime),
1488
+ 'data-end': String(cue.endTime),
1489
+ 'data-type': type,
1490
+ 'aria-label': `${ariaLabel} — ${entryText}`
1491
+ }
1492
+ });
1493
+
1494
+ const timestamp = DOMUtils.createElement('span', {
1495
+ className: `${this.player.options.classPrefix}-transcript-time`,
1496
+ textContent: TimeUtils.formatTime(cue.startTime),
1497
+ attributes: {
1498
+ 'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
1499
+ }
1500
+ });
1501
+
1502
+ const text = DOMUtils.createElement('span', {
1503
+ className: `${this.player.options.classPrefix}-transcript-text`,
1504
+ textContent: entryText
1505
+ });
1506
+
1507
+ entry.appendChild(timestamp);
1508
+ entry.appendChild(text);
1509
+
1510
+ // Click to seek. <button> elements activate on Enter/Space natively,
1511
+ // so a custom keydown handler is no longer needed.
1512
+ const seekToTime = () => {
1513
+ this.player.seek(cue.startTime);
1514
+ if (this.player.state.paused) {
1515
+ this.player.play();
1516
+ }
1517
+ };
1518
+
1519
+ entry.addEventListener('click', seekToTime);
1520
+
1521
+ return entry;
1522
+ }
1523
+
1524
+ /**
1525
+ * Strip VTT formatting tags from text
1526
+ */
1527
+ stripVTTFormatting(text: string) {
1528
+ // Remove VTT tags like <v Speaker>, <c>, etc.
1529
+ return text
1530
+ .replace(/<[^>]+>/g, '')
1531
+ .replace(/\n/g, ' ')
1532
+ .trim();
1533
+ }
1534
+
1535
+ /**
1536
+ * Show message when no transcript is available
1537
+ */
1538
+ showNoTranscriptMessage() {
1539
+ const message = DOMUtils.createElement('div', {
1540
+ className: `${this.player.options.classPrefix}-transcript-empty`,
1541
+ textContent: i18n.t('transcript.noTranscript')
1542
+ });
1543
+ this.transcriptContent?.appendChild(message);
1544
+ }
1545
+
1546
+ /**
1547
+ * Update active transcript entry based on current time
1548
+ */
1549
+ updateActiveEntry() {
1550
+ if (!this.isVisible || this.transcriptEntries.length === 0) return;
1551
+
1552
+ const currentTime = this.player.state.currentTime;
1553
+
1554
+ // Find the entry that matches current time
1555
+ const activeEntry = this.transcriptEntries.find(
1556
+ (entry: TranscriptEntry) => currentTime >= entry.startTime && currentTime < entry.endTime
1557
+ );
1558
+
1559
+ if (activeEntry && activeEntry !== this.currentActiveEntry) {
1560
+ // Remove previous active class
1561
+ if (this.currentActiveEntry) {
1562
+ this.currentActiveEntry.element.classList.remove(
1563
+ `${this.player.options.classPrefix}-transcript-entry-active`
1564
+ );
1565
+ }
1566
+
1567
+ // Add active class to current entry
1568
+ activeEntry.element.classList.add(
1569
+ `${this.player.options.classPrefix}-transcript-entry-active`
1570
+ );
1571
+
1572
+ // Scroll to active entry
1573
+ this.scrollToEntry(activeEntry.element);
1574
+
1575
+ this.currentActiveEntry = activeEntry;
1576
+ } else if (!activeEntry && this.currentActiveEntry) {
1577
+ // No active entry, remove active class
1578
+ this.currentActiveEntry.element.classList.remove(
1579
+ `${this.player.options.classPrefix}-transcript-entry-active`
1580
+ );
1581
+ this.currentActiveEntry = null;
1582
+ }
1583
+ }
1584
+
1585
+ /**
1586
+ * Scroll transcript window to show active entry
1587
+ */
1588
+ scrollToEntry(entryElement: HTMLElement) {
1589
+ if (!this.transcriptContent || !this.autoscrollEnabled) return;
1590
+
1591
+ const contentRect = this.transcriptContent.getBoundingClientRect();
1592
+ const entryRect = entryElement.getBoundingClientRect();
1593
+
1594
+ // Check if entry is out of view
1595
+ if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
1596
+ // Scroll to center the entry
1597
+ const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
1598
+ this.transcriptContent.scrollTo({
1599
+ top: scrollTop,
1600
+ behavior: 'smooth'
1601
+ });
1602
+ }
1603
+ }
1604
+
1605
+ /**
1606
+ * Save autoscroll preference to localStorage
1607
+ */
1608
+ saveAutoscrollPreference() {
1609
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
1610
+ savedPreferences.autoscroll = this.autoscrollEnabled;
1611
+ this.storage.saveTranscriptPreferences(savedPreferences);
1612
+ }
1613
+
1614
+ /**
1615
+ * Setup drag and drop functionality
1616
+ */
1617
+ setupDragAndDrop() {
1618
+ if (!this.transcriptHeader || !this.transcriptWindow) return;
1619
+
1620
+ // Check if we're on mobile and not in fullscreen
1621
+ const isMobile = window.innerWidth < 768;
1622
+ const isFullscreen = this.player.state.fullscreen;
1623
+
1624
+ // On mobile devices (< 768px), only enable drag/resize in fullscreen
1625
+ // On desktop/tablets (>= 768px), always enable drag/resize
1626
+ if (isMobile && !isFullscreen) {
1627
+ // Destroy existing instance if exiting fullscreen on mobile
1628
+ if (this.draggableResizable) {
1629
+ this.draggableResizable.destroy();
1630
+ this.draggableResizable = null;
1631
+ }
1632
+ return; // No drag/resize on mobile when not in fullscreen
1633
+ }
1634
+
1635
+ // If already initialized, don't re-initialize
1636
+ if (this.draggableResizable) {
1637
+ return;
1638
+ }
1639
+
1640
+ // Create DraggableResizable utility with touch support
1641
+ this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
1642
+ dragHandle: this.transcriptHeader,
1643
+ resizeHandles: this.transcriptResizeHandles,
1644
+ constrainToViewport: true,
1645
+ classPrefix: `${this.player.options.classPrefix}-transcript`,
1646
+ keyboardDragKey: 'd',
1647
+ keyboardResizeKey: 'r',
1648
+ keyboardStep: 10,
1649
+ keyboardStepLarge: 50,
1650
+ minWidth: 300,
1651
+ minHeight: 200,
1652
+ maxWidth: () => Math.max(320, window.innerWidth - 40),
1653
+ maxHeight: () => Math.max(200, window.innerHeight - 120),
1654
+ pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
1655
+ onPointerResizeToggle: (enabled) => {
1656
+ // Update resize handles visibility
1657
+ this.transcriptResizeHandles.forEach((handle: HTMLElement) => {
1658
+ handle.style.display = enabled ? 'block' : 'none';
1659
+ });
1660
+ // Call the state change handler
1661
+ this.onPointerResizeModeChange(enabled);
1662
+ },
1663
+ onDragStart: (e: Event) => {
1664
+ // Don't drag if clicking on certain elements
1665
+ const ignoreSelectors = [
1666
+ `.${this.player.options.classPrefix}-transcript-close`,
1667
+ `.${this.player.options.classPrefix}-transcript-settings`,
1668
+ `.${this.player.options.classPrefix}-transcript-language-select`,
1669
+ `.${this.player.options.classPrefix}-transcript-language-label`,
1670
+ `.${this.player.options.classPrefix}-transcript-settings-menu`,
1671
+ `.${this.player.options.classPrefix}-transcript-style-dialog`
1672
+ ];
1673
+
1674
+ for (const selector of ignoreSelectors) {
1675
+ if ((e.target as HTMLElement).closest(selector)) {
1676
+ return false; // Prevent drag
1677
+ }
1678
+ }
1679
+
1680
+ return true; // Allow drag
1681
+ }
1682
+ });
1683
+
1684
+ // Add custom keyboard handler for special keys (Escape, Home)
1685
+ this.customKeyHandler = (e: KeyboardEvent) => {
1686
+ const key = e.key.toLowerCase();
1687
+ const alreadyPrevented = e.defaultPrevented;
1688
+
1689
+ // Don't handle keys if settings menu or style dialog is open (let them handle keys)
1690
+ if (this.settingsMenuVisible || this.styleDialogVisible) {
1691
+ return;
1692
+ }
1693
+
1694
+ if (key === 'home') {
1695
+ e.preventDefault();
1696
+ e.stopPropagation();
1697
+ if (this.draggableResizable) {
1698
+ if (this.draggableResizable.pointerResizeMode) {
1699
+ this.draggableResizable.disablePointerResizeMode();
1700
+ }
1701
+ this.draggableResizable.manuallyPositioned = false;
1702
+ this.positionTranscript();
1703
+ this.updateResizeOptionState();
1704
+ this.announceLive(i18n.t('transcript.positionReset'));
1705
+ }
1706
+ return;
1707
+ }
1708
+
1709
+ if (key === 'r') {
1710
+ if (alreadyPrevented) {
1711
+ return;
1712
+ }
1713
+ e.preventDefault();
1714
+ e.stopPropagation();
1715
+ const enabled = this.toggleResizeMode();
1716
+ if (enabled) {
1717
+ this.transcriptWindow?.focus({ preventScroll: true });
1718
+ }
1719
+ return;
1720
+ }
1721
+
1722
+ if (key === 'escape') {
1723
+ // Check priority: resize mode > drag mode > close transcript
1724
+ // (settings menu and style dialog already handled by early return above)
1725
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
1726
+ e.preventDefault();
1727
+ e.stopPropagation();
1728
+ this.draggableResizable.disablePointerResizeMode();
1729
+ return;
1730
+ }
1731
+ if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
1732
+ e.preventDefault();
1733
+ e.stopPropagation();
1734
+ this.draggableResizable.disableKeyboardDragMode();
1735
+ this.announceLive(i18n.t('transcript.dragModeDisabled'));
1736
+ return;
1737
+ }
1738
+ // Only close transcript if nothing else is open
1739
+ e.preventDefault();
1740
+ e.stopPropagation();
1741
+ this.hideTranscript({ focusButton: true });
1742
+ return;
1743
+ }
1744
+ };
1745
+
1746
+ const customKeyHandler = this.customKeyHandler;
1747
+ if (this.transcriptWindow && customKeyHandler) {
1748
+ this.transcriptWindow.addEventListener('keydown', customKeyHandler, {
1749
+ signal: this.player.lifecycleSignal
1750
+ });
1751
+ }
1752
+ }
1753
+
1754
+
1755
+ /**
1756
+ * Toggle keyboard drag mode. Mirrors the sign-language flow: a
1757
+ * persistent badge is shown on the transcript window while the mode
1758
+ * is active, and a live-region announcement is made on each state
1759
+ * change.
1760
+ */
1761
+ toggleKeyboardDragMode() {
1762
+ if (this.draggableResizable) {
1763
+ const wasEnabled = this.draggableResizable.keyboardDragMode;
1764
+ this.draggableResizable.toggleKeyboardDragMode();
1765
+ const isEnabled = this.draggableResizable.keyboardDragMode;
1766
+ if (!wasEnabled && isEnabled) {
1767
+ this.enableMoveMode();
1768
+ this._panel?.showBadge(i18n.t('transcript.dragModeBadge'));
1769
+ this.announceLive(i18n.t('transcript.dragModeEnabled'));
1770
+ } else if (wasEnabled && !isEnabled) {
1771
+ this._panel?.hideBadge();
1772
+ this.announceLive(i18n.t('transcript.dragModeDisabled'));
1773
+ }
1774
+
1775
+ this.updateDragOptionState();
1776
+
1777
+ if (this.settingsMenuVisible) {
1778
+ this.hideSettingsMenu();
1779
+ }
1780
+
1781
+ this.transcriptWindow?.focus({ preventScroll: true });
1782
+ }
1783
+ }
1784
+
1785
+ /**
1786
+ * Toggle settings menu visibility
1787
+ */
1788
+ toggleSettingsMenu() {
1789
+ if (this.settingsMenuVisible) {
1790
+ this.hideSettingsMenu();
1791
+ } else {
1792
+ this.showSettingsMenu();
1793
+ }
1794
+ }
1795
+
1796
+ /**
1797
+ * Show the settings menu. Delegates to the shared {@link DraggablePanel};
1798
+ * kept as a named method so external callers (tests, other managers)
1799
+ * that referenced the legacy API keep working.
1800
+ */
1801
+ showSettingsMenu() {
1802
+ this._panel?.show();
1803
+ }
1804
+
1805
+ /** @see {@link showSettingsMenu} */
1806
+ positionSettingsMenu() {
1807
+ this._panel?.reposition();
1808
+ }
1809
+
1810
+ /** @see {@link showSettingsMenu} */
1811
+ hideSettingsMenu({ focusButton = true } = {}) {
1812
+ this._panel?.hide({ focusButton });
1813
+ }
1814
+
1815
+ /**
1816
+ * Enable move mode (gives visual feedback)
1817
+ */
1818
+ /**
1819
+ * Brief pulse animation on the transcript window to confirm entry
1820
+ * into keyboard drag mode. The textual hint that used to also flash
1821
+ * here has been replaced by a persistent {@link DraggablePanel}
1822
+ * badge (see `toggleKeyboardDragMode`), so this method only owns
1823
+ * the 1s visual cue now.
1824
+ */
1825
+ enableMoveMode() {
1826
+ this.transcriptWindow?.classList.add(
1827
+ `${this.player.options.classPrefix}-transcript-move-mode`
1828
+ );
1829
+
1830
+ setTimeout(() => {
1831
+ this.transcriptWindow?.classList.remove(
1832
+ `${this.player.options.classPrefix}-transcript-move-mode`
1833
+ );
1834
+ }, 2000);
1835
+ }
1836
+
1837
+ /**
1838
+ * Toggle resize mode
1839
+ */
1840
+ toggleResizeMode({ focus = true } = {}) {
1841
+ if (!this.draggableResizable) {
1842
+ return false;
1843
+ }
1844
+
1845
+ if (this.draggableResizable.pointerResizeMode) {
1846
+ this.draggableResizable.disablePointerResizeMode({ focus });
1847
+ return false;
1848
+ }
1849
+
1850
+ this.draggableResizable.enablePointerResizeMode({ focus });
1851
+ return true;
1852
+ }
1853
+
1854
+ // Thin delegates to the panel's refreshState. Kept as named methods
1855
+ // so the existing internal call sites (e.g. `toggleKeyboardDragMode`
1856
+ // and `toggleResizeMode`) read naturally without having to chain
1857
+ // through the optional panel reference every time.
1858
+ updateDragOptionState() {
1859
+ this._panel?.refreshDragState();
1860
+ }
1861
+
1862
+ updateResizeOptionState() {
1863
+ this._panel?.refreshResizeState();
1864
+ }
1865
+
1866
+ toggleShowTimestamps() {
1867
+ this.showTimestamps = !this.showTimestamps;
1868
+ this.updateShowTimestampsState();
1869
+ this.updateTimestampVisibility();
1870
+ this.saveTimestampsPreference();
1871
+ }
1872
+
1873
+ updateShowTimestampsState() {
1874
+ if (!this.showTimestampsButton) {
1875
+ return;
1876
+ }
1877
+
1878
+ const text = this.showTimestamps
1879
+ ? i18n.t('transcript.hideTimestamps')
1880
+ : i18n.t('transcript.showTimestamps');
1881
+ const ariaLabel = this.showTimestamps
1882
+ ? i18n.t('transcript.hideTimestampsAria')
1883
+ : i18n.t('transcript.showTimestampsAria');
1884
+
1885
+ this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1886
+ this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
1887
+
1888
+ if (this.showTimestampsText) {
1889
+ this.showTimestampsText.textContent = text;
1890
+ }
1891
+ }
1892
+
1893
+ updateTimestampVisibility() {
1894
+ if (!this.transcriptContent) return;
1895
+
1896
+ const timestamps = this.transcriptContent.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
1897
+ timestamps.forEach((timestamp) => {
1898
+ timestamp.style.display = this.showTimestamps ? '' : 'none';
1899
+ });
1900
+ }
1901
+
1902
+ saveTimestampsPreference() {
1903
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
1904
+ savedPreferences.showTimestamps = this.showTimestamps;
1905
+ this.storage.saveTranscriptPreferences(savedPreferences);
1906
+ }
1907
+
1908
+ // Legacy shims kept for any external callers that still invoke the
1909
+ // old transient-tooltip API. The persistent badge owned by the
1910
+ // {@link DraggablePanel} now replaces the 3-second indicator, so
1911
+ // these simply forward to the panel. Safe to remove once the next
1912
+ // consumer sweep confirms no external references.
1913
+ showResizeModeIndicator() {
1914
+ this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
1915
+ }
1916
+
1917
+ hideResizeModeIndicator() {
1918
+ this._panel?.hideBadge();
1919
+ }
1920
+
1921
+ onPointerResizeModeChange(enabled: boolean) {
1922
+ this.updateResizeOptionState();
1923
+
1924
+ if (enabled) {
1925
+ this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
1926
+ this.announceLive(i18n.t('transcript.resizeModeEnabled'));
1927
+ } else {
1928
+ this._panel?.hideBadge();
1929
+ this.announceLive(i18n.t('transcript.resizeModeDisabled'));
1930
+ }
1931
+ }
1932
+
1933
+ /**
1934
+ * Show style dialog
1935
+ */
1936
+ showStyleDialog() {
1937
+ // The style dialog uses an outside-click listener to dismiss
1938
+ // itself. Previously that listener was armed the first time the
1939
+ // settings menu opened (the same handler also dismissed the
1940
+ // settings menu). Now that the settings menu owns its own
1941
+ // outside-click, we arm the style-dialog listener here on first
1942
+ // open. Tied to `lifecycleSignal` so teardown is automatic.
1943
+ if (!this.documentClickHandlerAdded) {
1944
+ setTimeout(() => {
1945
+ const documentClick = this.handlers.documentClick;
1946
+ if (documentClick) {
1947
+ document.addEventListener('click', documentClick, {
1948
+ signal: this.player.lifecycleSignal
1949
+ });
1950
+ }
1951
+ this.documentClickHandlerAdded = true;
1952
+ }, 300);
1953
+ }
1954
+
1955
+ // If dialog already exists, just show it
1956
+ if (this.styleDialog) {
1957
+ this.styleDialog.style.display = 'block';
1958
+ this.styleDialogVisible = true;
1959
+
1960
+ // Re-add keyboard handler
1961
+ if (this.handlers.styleDialogKeydown) {
1962
+ document.addEventListener('keydown', this.handlers.styleDialogKeydown, {
1963
+ signal: this.player.lifecycleSignal
1964
+ });
1965
+ }
1966
+
1967
+ // Set flag to prevent immediate closing from document click
1968
+ this.styleDialogJustOpened = true;
1969
+ setTimeout(() => {
1970
+ this.styleDialogJustOpened = false;
1971
+ }, 350);
1972
+
1973
+ setTimeout(() => {
1974
+ const dialog = this.styleDialog;
1975
+ if (!dialog) return;
1976
+ const firstSelect = dialog.querySelector<HTMLElement>('select, input');
1977
+ if (firstSelect) {
1978
+ firstSelect.focus({ preventScroll: true });
1979
+ }
1980
+ }, 0);
1981
+ return;
1982
+ }
1983
+
1984
+ const styleDialog = DOMUtils.createElement('div', {
1985
+ className: `${this.player.options.classPrefix}-transcript-style-dialog`
1986
+ });
1987
+ this.styleDialog = styleDialog;
1988
+
1989
+ const title = DOMUtils.createElement('h4', {
1990
+ textContent: i18n.t('transcript.styleTitle'),
1991
+ className: `${this.player.options.classPrefix}-transcript-style-title`
1992
+ });
1993
+ styleDialog.appendChild(title);
1994
+
1995
+ const fontSizeControl = this.createStyleSelectControl(
1996
+ i18n.t('captions.fontSize'),
1997
+ 'fontSize',
1998
+ [
1999
+ { label: i18n.t('fontSizes.small'), value: '90%' },
2000
+ { label: i18n.t('fontSizes.normal'), value: '100%' },
2001
+ { label: i18n.t('fontSizes.large'), value: '110%' },
2002
+ { label: i18n.t('fontSizes.xlarge'), value: '120%' }
2003
+ ]
2004
+ );
2005
+ styleDialog.appendChild(fontSizeControl);
2006
+
2007
+ const fontFamilyControl = this.createStyleSelectControl(
2008
+ i18n.t('captions.fontFamily'),
2009
+ 'fontFamily',
2010
+ [
2011
+ { label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
2012
+ { label: i18n.t('fontFamilies.serif'), value: 'serif' },
2013
+ { label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
2014
+ ]
2015
+ );
2016
+ styleDialog.appendChild(fontFamilyControl);
2017
+
2018
+ const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
2019
+ styleDialog.appendChild(colorControl);
2020
+
2021
+ const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
2022
+ styleDialog.appendChild(bgColorControl);
2023
+
2024
+ const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
2025
+ styleDialog.appendChild(opacityControl);
2026
+
2027
+ const closeBtn = DOMUtils.createElement('button', {
2028
+ className: `${this.player.options.classPrefix}-transcript-style-close`,
2029
+ textContent: i18n.t('settings.close'),
2030
+ attributes: {
2031
+ 'type': 'button'
2032
+ }
2033
+ });
2034
+ closeBtn.addEventListener('click', () => this.hideStyleDialog());
2035
+ styleDialog.appendChild(closeBtn);
2036
+
2037
+ const styleKeyHandler = (e: KeyboardEvent) => {
2038
+ if (!this.styleDialogVisible) return;
2039
+
2040
+ if (e.key === 'Escape') {
2041
+ e.preventDefault();
2042
+ e.stopPropagation();
2043
+ this.hideStyleDialog();
2044
+ return;
2045
+ }
2046
+
2047
+ if (e.key === 'Tab') {
2048
+ const focusableElements = styleDialog.querySelectorAll<HTMLElement>(
2049
+ 'select, input, button'
2050
+ );
2051
+ const firstElement = focusableElements[0];
2052
+ const lastElement = focusableElements[focusableElements.length - 1];
2053
+ if (!firstElement || !lastElement) return;
2054
+
2055
+ if (e.shiftKey && document.activeElement === firstElement) {
2056
+ e.preventDefault();
2057
+ lastElement.focus({ preventScroll: true });
2058
+ } else if (!e.shiftKey && document.activeElement === lastElement) {
2059
+ e.preventDefault();
2060
+ firstElement.focus({ preventScroll: true });
2061
+ }
2062
+ }
2063
+ };
2064
+ this.handlers.styleDialogKeydown = styleKeyHandler;
2065
+ document.addEventListener('keydown', styleKeyHandler, {
2066
+ signal: this.player.lifecycleSignal
2067
+ });
2068
+
2069
+ if (this.headerLeft) {
2070
+ this.headerLeft.appendChild(styleDialog);
2071
+ } else if (this.transcriptHeader) {
2072
+ this.transcriptHeader.appendChild(styleDialog);
2073
+ }
2074
+
2075
+ this.applyTranscriptStyles();
2076
+
2077
+ this.styleDialogVisible = true;
2078
+ styleDialog.style.display = 'block';
2079
+
2080
+ this.styleDialogJustOpened = true;
2081
+ setTimeout(() => {
2082
+ this.styleDialogJustOpened = false;
2083
+ }, 350);
2084
+
2085
+ setTimeout(() => {
2086
+ const firstSelect = styleDialog.querySelector<HTMLElement>('select, input');
2087
+ if (firstSelect) {
2088
+ firstSelect.focus({ preventScroll: true });
2089
+ }
2090
+ }, 0);
2091
+ }
2092
+
2093
+ /**
2094
+ * Hide style dialog
2095
+ */
2096
+ hideStyleDialog() {
2097
+ if (this.styleDialog) {
2098
+ this.styleDialog.style.display = 'none';
2099
+ this.styleDialogVisible = false;
2100
+
2101
+ // Remove keyboard handler
2102
+ if (this.handlers.styleDialogKeydown) {
2103
+ document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2104
+ }
2105
+
2106
+ // Return focus to settings button
2107
+ if (this.settingsButton) {
2108
+ this.settingsButton.focus({ preventScroll: true });
2109
+ }
2110
+ }
2111
+ }
2112
+
2113
+ /**
2114
+ * Create style select control
2115
+ */
2116
+ createStyleSelectControl(label: string, property: string, options: Array<{ label: string; value: string }>) {
2117
+ const group = DOMUtils.createElement('div', {
2118
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2119
+ });
2120
+
2121
+ // Generate unique ID for the control
2122
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2123
+
2124
+ const labelEl = DOMUtils.createElement('label', {
2125
+ textContent: label,
2126
+ attributes: {
2127
+ 'for': controlId
2128
+ }
2129
+ });
2130
+ group.appendChild(labelEl);
2131
+
2132
+ const select = DOMUtils.createElement('select', {
2133
+ className: `${this.player.options.classPrefix}-transcript-style-select`,
2134
+ attributes: {
2135
+ 'id': controlId
2136
+ }
2137
+ });
2138
+
2139
+ options.forEach((opt: { label: string; value: string }) => {
2140
+ const option = DOMUtils.createElement('option', {
2141
+ textContent: opt.label,
2142
+ attributes: {
2143
+ 'value': opt.value
2144
+ }
2145
+ });
2146
+ if (this.transcriptStyle[property as 'fontSize' | 'fontFamily'] === opt.value) {
2147
+ option.selected = true;
2148
+ }
2149
+ select.appendChild(option);
2150
+ });
2151
+
2152
+ select.addEventListener('change', (e) => {
2153
+ this.transcriptStyle[property as 'fontSize' | 'fontFamily'] = (e.target as HTMLSelectElement).value;
2154
+ this.applyTranscriptStyles();
2155
+ this.savePreferences();
2156
+ });
2157
+
2158
+ group.appendChild(select);
2159
+ return group;
2160
+ }
2161
+
2162
+ /**
2163
+ * Create style color control
2164
+ */
2165
+ createStyleColorControl(label: string, property: string) {
2166
+ const group = DOMUtils.createElement('div', {
2167
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2168
+ });
2169
+
2170
+ // Generate unique ID for the control
2171
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2172
+
2173
+ const labelEl = DOMUtils.createElement('label', {
2174
+ textContent: label,
2175
+ attributes: {
2176
+ 'for': controlId
2177
+ }
2178
+ });
2179
+ group.appendChild(labelEl);
2180
+
2181
+ const input = DOMUtils.createElement('input', {
2182
+ attributes: {
2183
+ 'id': controlId,
2184
+ 'type': 'color',
2185
+ 'value': this.transcriptStyle[property as 'color' | 'backgroundColor']
2186
+ },
2187
+ className: `${this.player.options.classPrefix}-transcript-style-color`
2188
+ });
2189
+
2190
+ input.addEventListener('input', (e) => {
2191
+ this.transcriptStyle[property as 'color' | 'backgroundColor'] = (e.target as HTMLInputElement).value;
2192
+ this.applyTranscriptStyles();
2193
+ this.savePreferences();
2194
+ });
2195
+
2196
+ group.appendChild(input);
2197
+ return group;
2198
+ }
2199
+
2200
+ /**
2201
+ * Create style opacity control
2202
+ */
2203
+ createStyleOpacityControl(label: string, property: string) {
2204
+ const group = DOMUtils.createElement('div', {
2205
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2206
+ });
2207
+
2208
+ // Generate unique ID for the control
2209
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2210
+
2211
+ const labelEl = DOMUtils.createElement('label', {
2212
+ textContent: label,
2213
+ attributes: {
2214
+ 'for': controlId
2215
+ }
2216
+ });
2217
+ group.appendChild(labelEl);
2218
+
2219
+ const opacityProperty = property as 'opacity';
2220
+ const valueDisplay = DOMUtils.createElement('span', {
2221
+ textContent: Math.round(this.transcriptStyle[opacityProperty] * 100) + '%',
2222
+ className: `${this.player.options.classPrefix}-transcript-style-value`
2223
+ });
2224
+
2225
+ const input = DOMUtils.createElement('input', {
2226
+ attributes: {
2227
+ 'id': controlId,
2228
+ 'type': 'range',
2229
+ 'min': '0',
2230
+ 'max': '1',
2231
+ 'step': '0.1',
2232
+ 'value': String(this.transcriptStyle[opacityProperty])
2233
+ },
2234
+ className: `${this.player.options.classPrefix}-transcript-style-range`
2235
+ });
2236
+
2237
+ input.addEventListener('input', (e) => {
2238
+ const value = parseFloat((e.target as HTMLInputElement).value);
2239
+ this.transcriptStyle[opacityProperty] = value;
2240
+ valueDisplay.textContent = Math.round(value * 100) + '%';
2241
+ this.applyTranscriptStyles();
2242
+ this.savePreferences();
2243
+ });
2244
+
2245
+ const inputContainer = DOMUtils.createElement('div', {
2246
+ className: `${this.player.options.classPrefix}-transcript-style-range-container`
2247
+ });
2248
+ inputContainer.appendChild(input);
2249
+ inputContainer.appendChild(valueDisplay);
2250
+
2251
+ group.appendChild(labelEl);
2252
+ group.appendChild(inputContainer);
2253
+ return group;
2254
+ }
2255
+
2256
+ /**
2257
+ * Save transcript preferences to localStorage
2258
+ */
2259
+ savePreferences() {
2260
+ this.storage.saveTranscriptPreferences(this.transcriptStyle);
2261
+ }
2262
+
2263
+ /**
2264
+ * Apply transcript styles
2265
+ */
2266
+ applyTranscriptStyles() {
2267
+ if (!this.transcriptWindow) return;
2268
+
2269
+ // Apply to transcript window background
2270
+ this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
2271
+ this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
2272
+
2273
+ // Apply to content area
2274
+ if (this.transcriptContent) {
2275
+ this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
2276
+ this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
2277
+ this.transcriptContent.style.color = this.transcriptStyle.color;
2278
+ }
2279
+
2280
+ const textEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-text`);
2281
+ textEntries.forEach((entry) => {
2282
+ entry.style.fontSize = this.transcriptStyle.fontSize;
2283
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
2284
+ entry.style.color = this.transcriptStyle.color;
2285
+ });
2286
+
2287
+ const timeEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
2288
+ timeEntries.forEach((entry) => {
2289
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
2290
+ });
2291
+ }
2292
+
2293
+ /**
2294
+ * Set a managed timeout that will be cleaned up on destroy
2295
+ * @param {Function} callback - Callback function
2296
+ * @param {number} delay - Delay in milliseconds
2297
+ * @returns {number} Timeout ID
2298
+ */
2299
+ setManagedTimeout(callback: () => void, delay: number) {
2300
+ const timeoutId = setTimeout(() => {
2301
+ this.timeouts.delete(timeoutId);
2302
+ callback();
2303
+ }, delay);
2304
+ this.timeouts.add(timeoutId);
2305
+ return timeoutId;
2306
+ }
2307
+
2308
+ /**
2309
+ * Clear a managed timeout
2310
+ * @param {number} timeoutId - Timeout ID to clear
2311
+ */
2312
+ clearManagedTimeout(timeoutId: ReturnType<typeof setTimeout> | null) {
2313
+ if (timeoutId) {
2314
+ clearTimeout(timeoutId);
2315
+ this.timeouts.delete(timeoutId);
2316
+ }
2317
+ }
2318
+
2319
+ /**
2320
+ * Cleanup
2321
+ */
2322
+ destroy() {
2323
+ this.hideResizeModeIndicator();
2324
+
2325
+ // Tear down the settings-menu panel (removes its DOM + keyboard
2326
+ // listeners). The outside-click listener was attached with the
2327
+ // player's lifecycleSignal, so it cleans itself up as the player
2328
+ // is destroyed.
2329
+ if (this._panel) {
2330
+ this._panel.destroy();
2331
+ this._panel = null;
2332
+ }
2333
+
2334
+ // Destroy draggableResizable utility
2335
+ if (this.draggableResizable) {
2336
+ if (this.draggableResizable.pointerResizeMode) {
2337
+ this.draggableResizable.disablePointerResizeMode();
2338
+ this.updateResizeOptionState();
2339
+ }
2340
+ this.draggableResizable.destroy();
2341
+ this.draggableResizable = null;
2342
+ }
2343
+
2344
+ // Remove custom key handler
2345
+ if (this.transcriptWindow && this.customKeyHandler) {
2346
+ this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
2347
+ this.customKeyHandler = null;
2348
+ }
2349
+
2350
+ // Remove timeupdate and seeked listeners from player
2351
+ if (this.handlers.timeupdate) {
2352
+ this.player.off('timeupdate', this.handlers.timeupdate);
2353
+ }
2354
+ if (this.handlers.seeked) {
2355
+ this.player.off('seeked', this.handlers.seeked);
2356
+ }
2357
+
2358
+ // Remove audio description listeners from player
2359
+ if (this.handlers.audiodescriptionenabled) {
2360
+ this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
2361
+ }
2362
+ if (this.handlers.audiodescriptiondisabled) {
2363
+ this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
2364
+ }
2365
+
2366
+ // Remove text cue update listener
2367
+ if (this.handlers.textcuesupdate) {
2368
+ this.player.off('textcuesupdate', this.handlers.textcuesupdate);
2369
+ }
2370
+
2371
+ // Remove floating-state listener
2372
+ if (this.handlers.floatingchange) {
2373
+ this.player.off('floatingchange', this.handlers.floatingchange);
2374
+ }
2375
+
2376
+ // Remove settings button event listeners
2377
+ if (this.settingsButton) {
2378
+ if (this.handlers.settingsClick) {
2379
+ this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
2380
+ }
2381
+ if (this.handlers.settingsKeydown) {
2382
+ this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
2383
+ }
2384
+ }
2385
+
2386
+ // Remove style dialog event listeners
2387
+ if (this.handlers.styleDialogKeydown) {
2388
+ document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2389
+ }
2390
+
2391
+ // Remove document click listener
2392
+ if (this.handlers.documentClick) {
2393
+ document.removeEventListener('click', this.handlers.documentClick);
2394
+ }
2395
+
2396
+ // Remove window-level listeners
2397
+ if (this.handlers.resize) {
2398
+ window.removeEventListener('resize', this.handlers.resize);
2399
+ }
2400
+
2401
+ // Cleanup all managed timeouts
2402
+ this.timeouts.forEach((timeoutId: ReturnType<typeof setTimeout>) => clearTimeout(timeoutId));
2403
+ this.timeouts.clear();
2404
+
2405
+ this.handlers = {} as TranscriptHandlers;
2406
+
2407
+ // Remove DOM element
2408
+ if (this.transcriptWindow && this.transcriptWindow.parentNode) {
2409
+ this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
2410
+ }
2411
+
2412
+ this.transcriptWindow = null;
2413
+ this.transcriptHeader = null;
2414
+ this.transcriptContent = null;
2415
+ this.transcriptEntries = [];
2416
+ this.styleDialog = null;
2417
+ this.transcriptResizeHandles = [];
2418
+ this.liveRegion = null;
2419
+ this._vttCache.clear();
2420
+ }
2421
+
2422
+ announceLive(message: string) {
2423
+ if (!this.liveRegion) return;
2424
+ this.liveRegion.textContent = message || '';
2425
+ }
2426
+ }