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,1308 +1,1308 @@
1
- /**
2
- * Sign Language Video Manager
3
- * Handles picture-in-picture sign language video overlay
4
- */
5
-
6
- import { DOMUtils } from '../utils/DOMUtils.js';
7
- import { createIconElement } from '../icons/Icons.js';
8
- import { i18n } from '../i18n/i18n.js';
9
- import { DraggableResizable } from '../utils/DraggableResizable.js';
10
- import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
11
- import { DraggablePanel } from '../utils/DraggablePanel.js';
12
- import type { Player } from './Player.js';
13
-
14
- /**
15
- * Bag of player-event handlers the sign-language overlay binds to so it
16
- * can mirror the main player's playback state.
17
- */
18
- interface SignLanguageHandlers {
19
- play: () => void;
20
- pause: () => void;
21
- timeupdate: () => void;
22
- ratechange: () => void;
23
- captionChange?: () => void;
24
- }
25
-
26
- interface SignLanguageSettingsHandlers {
27
- click: (e: MouseEvent) => void;
28
- keydown: (e: KeyboardEvent) => void;
29
- }
30
-
31
- interface SignLanguageInteractionHandlers {
32
- draggable: DraggableResizable | null;
33
- customKeyHandler: ((e: KeyboardEvent) => void) | null;
34
- }
35
-
36
- export class SignLanguageManager {
37
- player: Player;
38
- _mainViewMutedBefore: boolean;
39
- _mainViewUsingSourceSwap: boolean;
40
- currentLanguage: string | null;
41
- customKeyHandler: ((e: KeyboardEvent) => void) | null;
42
- desiredPosition: string;
43
- draggable: DraggableResizable | null;
44
- handlers: SignLanguageHandlers | null;
45
- header: HTMLElement | null;
46
- inMainView: boolean;
47
- interactionHandlers: SignLanguageInteractionHandlers | null;
48
- mainViewOriginalSources: HTMLSourceElement[] | null;
49
- mainViewOriginalSrc: string | null;
50
- resizeHandles: HTMLElement[];
51
- selector: HTMLSelectElement | null;
52
- settingsButton: HTMLButtonElement | null;
53
- settingsHandlers: SignLanguageSettingsHandlers | null;
54
- sources: Record<string, string>;
55
- src: string | null;
56
- enabled: boolean;
57
- video: HTMLVideoElement | null;
58
- wrapper: HTMLElement | null;
59
-
60
- /**
61
- * Encapsulates the settings-menu DOM, lifecycle (show/hide/outside-
62
- * click/keyboard nav/positioning), and the drag/resize toggle items.
63
- * Owned here but lazily created once {@link _setupSettingsButton}
64
- * instantiates the button it needs to anchor from.
65
- */
66
- private _panel: DraggablePanel | null = null;
67
-
68
- // Back-compat getters for external callers (Player.ts exposes these
69
- // under `signLanguageSettingsMenu` / `signLanguageSettingsMenuVisible`)
70
- // and for internal readers of the shared menu-item state (which is now
71
- // panel-owned). Setters are no-ops by design — the panel is the
72
- // authoritative owner of these values.
73
- get settingsMenu(): HTMLElement | null {
74
- return this._panel?.settingsMenu ?? null;
75
- }
76
- set settingsMenu(_v: HTMLElement | null) {
77
- // panel-owned; ignored to keep legacy API surface inert.
78
- }
79
-
80
- get settingsMenuVisible(): boolean {
81
- return this._panel?.settingsMenuVisible ?? false;
82
- }
83
- set settingsMenuVisible(_v: boolean) {
84
- // panel-owned.
85
- }
86
-
87
- get settingsMenuJustOpened(): boolean {
88
- return this._panel?.justOpened ?? false;
89
- }
90
- set settingsMenuJustOpened(_v: boolean) {
91
- // panel-owned.
92
- }
93
-
94
- get dragOptionButton(): HTMLElement | null {
95
- return this._panel?.dragOptionButton ?? null;
96
- }
97
- get dragOptionText(): Element | null {
98
- return this._panel?.dragOptionText ?? null;
99
- }
100
- get resizeOptionButton(): HTMLElement | null {
101
- return this._panel?.resizeOptionButton ?? null;
102
- }
103
- get resizeOptionText(): Element | null {
104
- return this._panel?.resizeOptionText ?? null;
105
- }
106
-
107
- constructor(player: Player) {
108
- this.player = player;
109
-
110
- // Sources
111
- this.src = player.options.signLanguageSrc;
112
- this.sources = player.options.signLanguageSources || {};
113
- this.currentLanguage = null;
114
- this.desiredPosition = player.options.signLanguagePosition || 'bottom-right';
115
-
116
- // DOM elements
117
- this.wrapper = null;
118
- this.header = null;
119
- this.video = null;
120
- this.selector = null;
121
- this.settingsButton = null;
122
- this.resizeHandles = [];
123
-
124
- // State
125
- this.enabled = false;
126
- this.inMainView = false; // sign language shown in main video (src swap, like audio description)
127
- this.mainViewOriginalSrc = null;
128
- this.mainViewOriginalSources = null;
129
- this._mainViewUsingSourceSwap = false;
130
- this._mainViewMutedBefore = false;
131
-
132
- // Handlers
133
- this.handlers = null;
134
- this.settingsHandlers = null;
135
- this.interactionHandlers = null;
136
- this.draggable = null;
137
- this.customKeyHandler = null;
138
- }
139
-
140
- /**
141
- * Check if sign language is available
142
- */
143
- isAvailable() {
144
- return Object.keys(this.sources).length > 0 || Boolean(this.src);
145
- }
146
-
147
- /**
148
- * Enable sign language video
149
- */
150
- enable() {
151
- const hasMultipleSources = Object.keys(this.sources).length > 0;
152
- const hasSingleSource = Boolean(this.src);
153
-
154
- if (!hasMultipleSources && !hasSingleSource) {
155
- console.warn('No sign language video source provided');
156
- return;
157
- }
158
-
159
- if (this.wrapper) {
160
- // Already exists, just show it
161
- this.wrapper.style.display = 'block';
162
- this.enabled = true;
163
- this.player.state.signLanguageEnabled = true;
164
- this.player.emit('signlanguageenabled');
165
-
166
- // Focus settings button
167
- this.player.setManagedTimeout(() => {
168
- if (this.settingsButton && document.contains(this.settingsButton)) {
169
- this.settingsButton.focus({ preventScroll: true });
170
- }
171
- }, 150);
172
- return;
173
- }
174
-
175
- // Determine initial language
176
- let initialLang = null;
177
- let initialSrc = null;
178
-
179
- if (hasMultipleSources) {
180
- initialLang = this._determineInitialLanguage();
181
- initialSrc = this.sources[initialLang] ?? null;
182
- this.currentLanguage = initialLang;
183
- } else {
184
- initialSrc = this.src;
185
- }
186
-
187
- // Create UI
188
- this._createWrapper();
189
- this._createHeader(hasMultipleSources, initialLang);
190
- this._createVideo(initialSrc);
191
- this._createResizeHandles();
192
-
193
- // Assemble. The create methods mutate this.wrapper/this.video, but
194
- // TypeScript still sees them as null due to the earlier
195
- // `if (this.wrapper) return` narrowing, so re-read via a typed alias.
196
- const wrapper = this.wrapper as HTMLElement | null;
197
- const video = this.video as HTMLVideoElement | null;
198
- if (!wrapper || !video) {
199
- return;
200
- }
201
- wrapper.appendChild(this.header as Node);
202
- wrapper.appendChild(video);
203
- this.resizeHandles.forEach(handle => wrapper.appendChild(handle));
204
-
205
- // Set initial size
206
- this._applyInitialSize();
207
-
208
- // Add to container
209
- this.player.container.appendChild(wrapper);
210
-
211
- // Position
212
- requestAnimationFrame(() => {
213
- this.constrainPosition();
214
- });
215
-
216
- // Sync with main video
217
- video.currentTime = this.player.state.currentTime;
218
- if (!this.player.state.paused) {
219
- video.play();
220
- }
221
-
222
- // Setup interaction
223
- this._setupInteraction();
224
-
225
- // Setup event handlers
226
- this._setupEventHandlers(hasMultipleSources);
227
-
228
- this.enabled = true;
229
- this.player.state.signLanguageEnabled = true;
230
- this.player.emit('signlanguageenabled');
231
-
232
- // Focus settings button
233
- this.player.setManagedTimeout(() => {
234
- if (this.settingsButton && document.contains(this.settingsButton)) {
235
- this.settingsButton.focus({ preventScroll: true });
236
- }
237
- }, 150);
238
- }
239
-
240
- /**
241
- * Disable sign language video
242
- */
243
- disable() {
244
- if (this.settingsMenuVisible) {
245
- this.hideSettingsMenu({ focusButton: false });
246
- }
247
-
248
- // Remove the mode badge so it doesn't linger past the overlay
249
- // (e.g. if user hides sign language while drag/resize mode was on).
250
- this._hideModeBadge();
251
-
252
- if (this.wrapper) {
253
- this.wrapper.style.display = 'none';
254
- }
255
- this.enabled = false;
256
- this.player.state.signLanguageEnabled = false;
257
- this.player.emit('signlanguagedisabled');
258
- }
259
-
260
- /**
261
- * Toggle sign language video
262
- */
263
- toggle() {
264
- if (this.enabled) {
265
- this.disable();
266
- } else {
267
- this.enable();
268
- }
269
- }
270
-
271
- /**
272
- * Enable sign language in main view: replace main video src with sign language URL (like audio description).
273
- * Same video element, different URL; no overlay.
274
- */
275
- async enableInMainView() {
276
- const hasMultipleSources = Object.keys(this.sources).length > 0;
277
- const hasSingleSource = Boolean(this.src);
278
- if (!hasMultipleSources && !hasSingleSource) return;
279
- if (!this.player.element || this.player.element.tagName !== 'VIDEO') return;
280
- if (this.inMainView) return;
281
-
282
- let signSrc;
283
- if (hasMultipleSources) {
284
- const initialLang = this._determineInitialLanguage();
285
- this.currentLanguage = initialLang;
286
- signSrc = this.sources[initialLang];
287
- } else {
288
- signSrc = this.src;
289
- }
290
-
291
- const el = this.player.element as HTMLVideoElement;
292
- const currentTime = this.player.state.currentTime;
293
- const wasPlaying = this.player.state.playing;
294
- const posterValue = el.poster || el.getAttribute('poster') || this.player.options.poster;
295
- const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
296
-
297
- const sourceElements: HTMLSourceElement[] = Array.from(el.querySelectorAll('source'));
298
- const firstSource = sourceElements[0];
299
- this.mainViewOriginalSrc = (el.currentSrc && el.currentSrc.length > 0)
300
- ? el.currentSrc
301
- : (el.src && el.src.length > 0)
302
- ? el.src
303
- : (firstSource && firstSource.getAttribute('src'))
304
- ? firstSource.getAttribute('src')
305
- : '';
306
- this._mainViewMutedBefore = this.player.state.muted;
307
-
308
- if (posterValue && shouldKeepPoster && el.tagName === 'VIDEO') {
309
- el.poster = posterValue;
310
- }
311
- if (sourceElements.length > 0) {
312
- this.mainViewOriginalSources = sourceElements;
313
- this.mainViewOriginalSources.forEach(source => source.remove());
314
- const signSource = document.createElement('source');
315
- const signSrcValue = signSrc ?? '';
316
- signSource.setAttribute('src', signSrcValue);
317
- const type = this._inferVideoType(signSrcValue);
318
- if (type) {
319
- signSource.setAttribute('type', type);
320
- }
321
- const trackNode = el.querySelector('track');
322
- if (trackNode) {
323
- el.insertBefore(signSource, trackNode);
324
- } else {
325
- el.appendChild(signSource);
326
- }
327
- this._mainViewUsingSourceSwap = true;
328
- } else {
329
- el.src = signSrc ?? '';
330
- this._mainViewUsingSourceSwap = false;
331
- }
332
- el.muted = true;
333
- this.player.currentSource = signSrc ?? '';
334
- if (typeof this.player.invalidateTrackCache === 'function') {
335
- this.player.invalidateTrackCache();
336
- }
337
- el.load();
338
-
339
- await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
340
-
341
- if (currentTime > 0) {
342
- this.player.seek(currentTime);
343
- await new Promise(r => setTimeout(r, 100));
344
- }
345
- if (wasPlaying) {
346
- await this.player.play();
347
- } else {
348
- this.player.pause();
349
- if (!shouldKeepPoster && this.player.hidePosterOverlay) {
350
- this.player.hidePosterOverlay();
351
- }
352
- }
353
-
354
- this.inMainView = true;
355
- this.player.state.signLanguageInMainView = true;
356
- if (this.player.videoWrapper) {
357
- this.player.videoWrapper.classList.add('vidply-sign-language-main-view-active');
358
- }
359
- this.player.emit('signlanguageinmainviewenabled');
360
- }
361
-
362
- /**
363
- * Disable sign language in main view: restore main video src.
364
- */
365
- async disableInMainView() {
366
- if (!this.inMainView) return;
367
- if (!this.mainViewOriginalSrc && !this.mainViewOriginalSources) {
368
- this.inMainView = false;
369
- this.player.state.signLanguageInMainView = false;
370
- if (this.player.videoWrapper) {
371
- this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
372
- }
373
- this.player.emit('signlanguageinmainviewdisabled');
374
- return;
375
- }
376
-
377
- const el = this.player.element as HTMLVideoElement;
378
- const currentTime = this.player.state.currentTime;
379
- const wasPlaying = this.player.state.playing;
380
-
381
- if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
382
- (Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
383
- const trackNode = el.querySelector('track');
384
- this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
385
- if (trackNode) {
386
- el.insertBefore(source, trackNode);
387
- } else {
388
- el.appendChild(source);
389
- }
390
- });
391
- this._mainViewUsingSourceSwap = false;
392
- } else if (this.mainViewOriginalSrc) {
393
- el.src = this.mainViewOriginalSrc;
394
- }
395
- el.muted = this._mainViewMutedBefore;
396
- this.player.currentSource = this.mainViewOriginalSrc || el.querySelector('source')?.src || '';
397
- if (typeof this.player.invalidateTrackCache === 'function') {
398
- this.player.invalidateTrackCache();
399
- }
400
- el.load();
401
-
402
- await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
403
-
404
- if (currentTime > 0) {
405
- this.player.seek(currentTime);
406
- }
407
- if (wasPlaying) {
408
- try {
409
- await this.player.play();
410
- } catch (e) {
411
- this.player.log?.('Sign language main view: play after restore failed', e, 'warn');
412
- }
413
- }
414
-
415
- this.mainViewOriginalSrc = null;
416
- this.mainViewOriginalSources = null;
417
- this.inMainView = false;
418
- this.player.state.signLanguageInMainView = false;
419
- if (this.player.videoWrapper) {
420
- this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
421
- }
422
- this.player.emit('signlanguageinmainviewdisabled');
423
- }
424
-
425
- /**
426
- * Wait for media ready (like AudioDescriptionManager).
427
- */
428
- async _waitForMediaReadyMainView(needSeek = false) {
429
- const el = this.player.element;
430
- const loadedMetaPromise = new Promise<void>((resolve) => {
431
- if (el.readyState >= 1) {
432
- resolve();
433
- return;
434
- }
435
- const onLoad = () => {
436
- el.removeEventListener('loadedmetadata', onLoad);
437
- el.removeEventListener('error', onError);
438
- resolve();
439
- };
440
- const onError = () => {
441
- el.removeEventListener('loadedmetadata', onLoad);
442
- el.removeEventListener('error', onError);
443
- resolve();
444
- };
445
- el.addEventListener('loadedmetadata', onLoad);
446
- el.addEventListener('error', onError, { once: true });
447
- });
448
- const timeoutPromise = new Promise(r => setTimeout(r, 10000));
449
- await Promise.race([loadedMetaPromise, timeoutPromise]);
450
- await new Promise(r => setTimeout(r, 300));
451
- if (needSeek) {
452
- await new Promise<void>((resolve) => {
453
- if (el.readyState >= 3) resolve();
454
- else {
455
- const onCanPlay = () => {
456
- el.removeEventListener('canplay', onCanPlay);
457
- el.removeEventListener('canplaythrough', onCanPlay);
458
- resolve();
459
- };
460
- el.addEventListener('canplay', onCanPlay, { once: true });
461
- el.addEventListener('canplaythrough', onCanPlay, { once: true });
462
- setTimeout(() => {
463
- el.removeEventListener('canplay', onCanPlay);
464
- el.removeEventListener('canplaythrough', onCanPlay);
465
- resolve();
466
- }, 3000);
467
- }
468
- });
469
- }
470
- }
471
-
472
- /**
473
- * Toggle sign language in main view (src swap, like audio description).
474
- */
475
- toggleInMainView() {
476
- if (this.inMainView) {
477
- this.disableInMainView();
478
- } else {
479
- this.enableInMainView();
480
- }
481
- }
482
-
483
- /**
484
- * Switch to a different sign language
485
- */
486
- switchLanguage(langCode: string) {
487
- if (!this.sources[langCode]) return;
488
-
489
- this.currentLanguage = langCode;
490
-
491
- if (this.video) {
492
- const currentTime = this.video.currentTime;
493
- const wasPlaying = !this.video.paused;
494
- this.video.src = this.sources[langCode];
495
- this.video.currentTime = currentTime;
496
- if (wasPlaying) {
497
- // Autoplay can be rejected by browser policy; debug-log
498
- // unknown errors instead of silently swallowing.
499
- this.video.play().catch((e: unknown) => {
500
- if (typeof console !== 'undefined' && console.debug) {
501
- console.debug('[VidPly] sign-language play() rejected:', e);
502
- }
503
- });
504
- }
505
- }
506
-
507
- if (this.inMainView && this.player.element && this.player.element.tagName === 'VIDEO') {
508
- const currentTime = this.player.state.currentTime;
509
- const wasPlaying = this.player.state.playing;
510
- if (this._mainViewUsingSourceSwap) {
511
- const signSource = this.player.element.querySelector('source');
512
- if (signSource) {
513
- signSource.setAttribute('src', this.sources[langCode]);
514
- const type = this._inferVideoType(this.sources[langCode]);
515
- if (type) {
516
- signSource.setAttribute('type', type);
517
- }
518
- }
519
- } else {
520
- this.player.element.src = this.sources[langCode];
521
- }
522
- this.player.currentSource = this.sources[langCode];
523
- if (typeof this.player.invalidateTrackCache === 'function') {
524
- this.player.invalidateTrackCache();
525
- }
526
- this.player.element.load();
527
- this._waitForMediaReadyMainView(true).then(() => {
528
- if (currentTime > 0) this.player.seek(currentTime);
529
- if (wasPlaying) this.player.play();
530
- });
531
- }
532
-
533
- this.player.emit('signlanguagelanguagechanged', langCode);
534
- }
535
-
536
- _inferVideoType(url: string): string {
537
- if (!url) return '';
538
- const cleanUrl = (url.split('?')[0] ?? '').toLowerCase();
539
- if (cleanUrl.endsWith('.mp4')) return 'video/mp4';
540
- if (cleanUrl.endsWith('.webm')) return 'video/webm';
541
- if (cleanUrl.endsWith('.ogv') || cleanUrl.endsWith('.ogg')) return 'video/ogg';
542
- return '';
543
- }
544
-
545
- /**
546
- * Get language label
547
- */
548
- getLanguageLabel(langCode: string): string {
549
- const langNames: Record<string, string> = {
550
- 'en': 'English',
551
- 'de': 'Deutsch',
552
- 'es': 'Español',
553
- 'fr': 'Français',
554
- 'it': 'Italiano',
555
- 'ja': '日本語',
556
- 'pt': 'Português',
557
- 'ar': 'العربية',
558
- 'hi': 'हिन्दी'
559
- };
560
- return langNames[langCode] || langCode.toUpperCase();
561
- }
562
-
563
- /**
564
- * Determine initial sign language
565
- */
566
- _determineInitialLanguage(): string {
567
- // Try caption language
568
- if (this.player.captionManager && this.player.captionManager.currentTrack) {
569
- const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
570
- if (captionLang && this.sources[captionLang]) {
571
- return captionLang;
572
- }
573
- }
574
-
575
- // Try player language
576
- if (this.player.options.language) {
577
- const playerLang = this.player.options.language.toLowerCase().split('-')[0];
578
- if (playerLang && this.sources[playerLang]) {
579
- return playerLang;
580
- }
581
- }
582
-
583
- // First available
584
- return Object.keys(this.sources)[0] ?? '';
585
- }
586
-
587
- /**
588
- * Create wrapper element
589
- */
590
- _createWrapper() {
591
- this.wrapper = document.createElement('div');
592
- this.wrapper.className = 'vidply-sign-language-wrapper';
593
- this.wrapper.setAttribute('tabindex', '0');
594
- this.wrapper.setAttribute('aria-label', i18n.t('player.signLanguageDragResize'));
595
- }
596
-
597
- /**
598
- * Create header element
599
- */
600
- _createHeader(hasMultipleSources: boolean, initialLang: string | null) {
601
- const classPrefix = this.player.options.classPrefix;
602
-
603
- this.header = DOMUtils.createElement('div', {
604
- className: `${classPrefix}-sign-language-header`,
605
- attributes: { 'tabindex': '0' }
606
- });
607
-
608
- const headerLeft = DOMUtils.createElement('div', {
609
- className: `${classPrefix}-sign-language-header-left`
610
- });
611
-
612
- const title = DOMUtils.createElement('h3', {
613
- textContent: i18n.t('player.signLanguageVideo')
614
- });
615
-
616
- // Settings button
617
- this._createSettingsButton(headerLeft);
618
-
619
- // Language selector
620
- if (hasMultipleSources) {
621
- this._createLanguageSelector(headerLeft, initialLang);
622
- }
623
-
624
- headerLeft.appendChild(title);
625
-
626
- // Close button
627
- const closeButton = this._createCloseButton();
628
-
629
- this.header.appendChild(headerLeft);
630
- this.header.appendChild(closeButton);
631
- }
632
-
633
- /**
634
- * Create settings button and wire it to a {@link DraggablePanel}
635
- * that owns the drag/resize settings menu and its lifecycle.
636
- */
637
- _createSettingsButton(container: HTMLElement) {
638
- const classPrefix = this.player.options.classPrefix;
639
- const ariaLabel = i18n.t('player.signLanguageSettings');
640
-
641
- this.settingsButton = DOMUtils.createElement('button', {
642
- className: `${classPrefix}-sign-language-settings`,
643
- attributes: {
644
- 'type': 'button',
645
- 'aria-label': ariaLabel,
646
- 'aria-expanded': 'false'
647
- }
648
- }) as HTMLButtonElement;
649
- this.settingsButton.appendChild(createIconElement('settings'));
650
- DOMUtils.attachTooltip(this.settingsButton, ariaLabel, classPrefix);
651
-
652
- // The panel ties the settings-menu DOM, outside-click dismissal,
653
- // keyboard navigation, and drag/resize toggle items together
654
- // with the shared lifecycle signal. The manager still owns the
655
- // side effects that happen when a mode actually toggles (badge,
656
- // announcement, focus target), so the click callbacks below run
657
- // the manager's own toggle methods.
658
- this._panel = new DraggablePanel({
659
- player: this.player,
660
- namespace: 'sign-language',
661
- settingsButton: this.settingsButton,
662
- getDraggable: () => this.draggable,
663
- i18nKeys: {
664
- enableDrag: 'player.enableSignDragMode',
665
- disableDrag: 'player.disableSignDragMode',
666
- enableDragAria: 'player.enableSignDragModeAria',
667
- disableDragAria: 'player.disableSignDragModeAria',
668
- enableResize: 'player.enableSignResizeMode',
669
- disableResize: 'player.disableSignResizeMode',
670
- enableResizeAria: 'player.enableSignResizeModeAria',
671
- disableResizeAria: 'player.disableSignResizeModeAria',
672
- closeMenu: 'transcript.closeMenu',
673
- },
674
- menuAlign: 'center',
675
- getMenuParent: () => this.wrapper,
676
- getBadgeHost: () => this.wrapper,
677
- // Existing CSS uses `.vidply-sign-mode-badge` (shorter than
678
- // the namespace default) — pin the class so the styling
679
- // keeps applying without having to duplicate the rule.
680
- badgeClass: `${this.player.options.classPrefix}-sign-mode-badge`,
681
- onDragItemClick: (panel) => {
682
- this.toggleKeyboardDragMode();
683
- // Keep focus off the settings button so arrow keys go to
684
- // the draggable overlay.
685
- panel.hide({ focusButton: false });
686
- if (this.draggable?.keyboardDragMode) {
687
- setTimeout(() => {
688
- this.wrapper?.focus?.({ preventScroll: true });
689
- }, 20);
690
- }
691
- },
692
- onResizeItemClick: (panel) => {
693
- const enabled = this.toggleResizeMode({ focus: false });
694
- if (enabled) {
695
- panel.hide({ focusButton: false });
696
- setTimeout(() => {
697
- this.wrapper?.focus?.({ preventScroll: true });
698
- }, 20);
699
- } else {
700
- panel.hide({ focusButton: true });
701
- }
702
- },
703
- });
704
-
705
- this.settingsHandlers = {
706
- click: (e: MouseEvent) => {
707
- e.preventDefault();
708
- e.stopPropagation();
709
- this._panel?.markJustOpenedForClick();
710
- this._panel?.toggle();
711
- },
712
- keydown: (e: KeyboardEvent) => {
713
- if (e.key === 'd' || e.key === 'D') {
714
- e.preventDefault();
715
- e.stopPropagation();
716
- this.toggleKeyboardDragMode();
717
- } else if (e.key === 'r' || e.key === 'R') {
718
- e.preventDefault();
719
- e.stopPropagation();
720
- this.toggleResizeMode();
721
- } else if (e.key === 'Escape' && this.settingsMenuVisible) {
722
- e.preventDefault();
723
- e.stopPropagation();
724
- this.hideSettingsMenu();
725
- }
726
- }
727
- };
728
-
729
- this.settingsButton.addEventListener('click', this.settingsHandlers.click);
730
- this.settingsButton.addEventListener('keydown', this.settingsHandlers.keydown);
731
- container.appendChild(this.settingsButton);
732
- }
733
-
734
- /**
735
- * Create language selector
736
- */
737
- _createLanguageSelector(container: HTMLElement, initialLang: string | null) {
738
- const classPrefix = this.player.options.classPrefix;
739
- const selectId = `${classPrefix}-sign-language-select-${Date.now()}`;
740
-
741
- const options = Object.keys(this.sources).map(langCode => ({
742
- value: langCode,
743
- text: this.getLanguageLabel(langCode),
744
- selected: langCode === initialLang
745
- }));
746
-
747
- const { label, select } = createLabeledSelect({
748
- classPrefix,
749
- labelClass: `${classPrefix}-sign-language-label`,
750
- selectClass: `${classPrefix}-sign-language-select`,
751
- labelText: 'settings.language',
752
- selectId,
753
- options,
754
- onChange: (e) => {
755
- e.stopPropagation();
756
- this.switchLanguage((e.target as HTMLSelectElement).value);
757
- }
758
- });
759
-
760
- this.selector = select as HTMLSelectElement;
761
-
762
- const selectorWrapper = DOMUtils.createElement('div', {
763
- className: `${classPrefix}-sign-language-selector-wrapper`
764
- });
765
- selectorWrapper.appendChild(label);
766
- selectorWrapper.appendChild(this.selector);
767
-
768
- preventDragOnElement(selectorWrapper);
769
- container.appendChild(selectorWrapper);
770
- }
771
-
772
- /**
773
- * Create close button
774
- */
775
- _createCloseButton() {
776
- const classPrefix = this.player.options.classPrefix;
777
- const ariaLabel = i18n.t('player.closeSignLanguage');
778
-
779
- const closeButton = DOMUtils.createElement('button', {
780
- className: `${classPrefix}-sign-language-close`,
781
- attributes: {
782
- 'type': 'button',
783
- 'aria-label': ariaLabel
784
- }
785
- });
786
- closeButton.appendChild(createIconElement('close'));
787
- DOMUtils.attachTooltip(closeButton, ariaLabel, classPrefix);
788
-
789
- // Bind to the player lifecycle so the listener is dropped on teardown
790
- // even if the button is detached without going through cleanup().
791
- closeButton.addEventListener('click', () => {
792
- this.disable();
793
- const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
794
- if (signLanguageButton) {
795
- this.player.setManagedTimeout(() => {
796
- signLanguageButton.focus({ preventScroll: true });
797
- }, 0);
798
- }
799
- }, { signal: this.player.lifecycleSignal });
800
-
801
- return closeButton;
802
- }
803
-
804
- /**
805
- * Create video element
806
- */
807
- _createVideo(src: string | null) {
808
- this.video = document.createElement('video');
809
- this.video.className = 'vidply-sign-language-video';
810
- this.video.src = src ?? '';
811
- this.video.setAttribute('aria-label', i18n.t('player.signLanguage'));
812
- this.video.muted = true;
813
- this.video.setAttribute('playsinline', '');
814
- }
815
-
816
- /**
817
- * Create resize handles
818
- */
819
- _createResizeHandles() {
820
- const classPrefix = this.player.options.classPrefix;
821
-
822
- this.resizeHandles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(dir => {
823
- const handle = DOMUtils.createElement('div', {
824
- className: `${classPrefix}-sign-resize-handle ${classPrefix}-sign-resize-${dir}`,
825
- attributes: {
826
- 'data-direction': dir,
827
- 'data-vidply-managed-resize': 'true',
828
- 'aria-hidden': 'true'
829
- }
830
- });
831
- handle.style.display = 'none';
832
- return handle;
833
- });
834
- }
835
-
836
- /**
837
- * Apply initial size
838
- */
839
- _applyInitialSize() {
840
- const wrapper = this.wrapper;
841
- if (!wrapper) return;
842
- const saved = this.player.storage.getSignLanguagePreferences() as { size?: { width?: string } } | null;
843
- if (saved?.size?.width) {
844
- wrapper.style.width = saved.size.width;
845
- } else {
846
- wrapper.style.width = '280px';
847
- }
848
- wrapper.style.height = 'auto';
849
- }
850
-
851
- /**
852
- * Setup interaction (drag and resize)
853
- */
854
- _setupInteraction() {
855
- const isMobile = window.innerWidth < 768;
856
- const isFullscreen = this.player.state.fullscreen;
857
-
858
- // Historically, drag/resize was disabled on mobile unless fullscreen to avoid scroll conflicts.
859
- // Now that we support touch/pointer dragging with proper `touch-action` handling, enable it
860
- // by default on iOS/Android as well. Allow opting out via option.
861
- if (isMobile && !isFullscreen && this.player?.options?.signLanguageDragOnMobile === false) {
862
- if (this.draggable) {
863
- this.draggable.destroy();
864
- this.draggable = null;
865
- }
866
- return;
867
- }
868
-
869
- if (this.draggable) return;
870
-
871
- const wrapper = this.wrapper;
872
- if (!wrapper) return;
873
-
874
- const classPrefix = this.player.options.classPrefix;
875
-
876
- this.draggable = new DraggableResizable(wrapper, {
877
- // Allow dragging from anywhere on the sign-language window (better for touch).
878
- // We still block dragging when interacting with controls via `onDragStart` below.
879
- dragHandle: this.wrapper,
880
- resizeHandles: this.resizeHandles,
881
- constrainToViewport: true,
882
- maintainAspectRatio: true,
883
- minWidth: 150,
884
- minHeight: 100,
885
- classPrefix: `${classPrefix}-sign`,
886
- keyboardDragKey: 'd',
887
- keyboardResizeKey: 'r',
888
- keyboardStep: 10,
889
- keyboardStepLarge: 50,
890
- pointerResizeIndicatorText: i18n.t('player.signLanguageResizeActive'),
891
- onPointerResizeToggle: (enabled) => {
892
- this.resizeHandles.forEach(handle => {
893
- handle.style.display = enabled ? 'block' : 'none';
894
- });
895
- // Pair the resize-mode visual state with a translatable
896
- // badge and a live-region announcement. Fired for all
897
- // entry/exit paths (menu toggle, R key, Escape, Home).
898
- if (enabled) {
899
- this._showModeBadge(i18n.t('player.signResizeModeHint'));
900
- this.player.keyboardManager?.announce(i18n.t('player.signLanguageResizeActive'));
901
- } else {
902
- this._hideModeBadge();
903
- this.player.keyboardManager?.announce(i18n.t('player.signResizeModeDisabled'));
904
- }
905
- },
906
- onDragStart: (e) => {
907
- const target = e.target as HTMLElement;
908
- if (target.closest(`.${classPrefix}-sign-language-close`) ||
909
- target.closest(`.${classPrefix}-sign-language-settings`) ||
910
- target.closest(`.${classPrefix}-sign-language-select`) ||
911
- target.closest(`.${classPrefix}-sign-language-label`) ||
912
- target.closest(`.${classPrefix}-sign-language-settings-menu`)) {
913
- return false;
914
- }
915
- return true;
916
- }
917
- });
918
-
919
- this._setupCustomKeyHandler();
920
-
921
- this.interactionHandlers = {
922
- draggable: this.draggable,
923
- customKeyHandler: this.customKeyHandler
924
- };
925
- }
926
-
927
- /**
928
- * Setup custom keyboard handler
929
- */
930
- _setupCustomKeyHandler() {
931
- this.customKeyHandler = (e: KeyboardEvent) => {
932
- const key = e.key.toLowerCase();
933
-
934
- if (this.settingsMenuVisible) return;
935
-
936
- if (key === 'home') {
937
- e.preventDefault();
938
- e.stopPropagation();
939
- if (this.draggable) {
940
- if (this.draggable.pointerResizeMode) {
941
- this.draggable.disablePointerResizeMode();
942
- }
943
- this.draggable.manuallyPositioned = false;
944
- this.constrainPosition();
945
- }
946
- return;
947
- }
948
-
949
- if (key === 'r') {
950
- e.preventDefault();
951
- e.stopPropagation();
952
- if (this.toggleResizeMode()) {
953
- this.wrapper?.focus({ preventScroll: true });
954
- }
955
- return;
956
- }
957
-
958
- if (key === 'escape') {
959
- e.preventDefault();
960
- e.stopPropagation();
961
- if (this.draggable?.pointerResizeMode) {
962
- // disablePointerResizeMode() fires onPointerResizeToggle,
963
- // which hides the badge and announces the change.
964
- this.draggable.disablePointerResizeMode();
965
- return;
966
- }
967
- if (this.draggable?.keyboardDragMode) {
968
- // disableKeyboardDragMode on the draggable has no
969
- // callback, so mirror the cleanup that
970
- // toggleKeyboardDragMode() does when it turns the
971
- // mode off.
972
- this.draggable.disableKeyboardDragMode();
973
- this._hideModeBadge();
974
- this._updateDragOptionState();
975
- this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
976
- return;
977
- }
978
- this.disable();
979
- const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
980
- if (signLanguageButton) {
981
- setTimeout(() => {
982
- signLanguageButton.focus({ preventScroll: true });
983
- }, 0);
984
- }
985
- }
986
- };
987
-
988
- this.wrapper?.addEventListener('keydown', this.customKeyHandler);
989
- }
990
-
991
- /**
992
- * Setup event handlers
993
- */
994
- _setupEventHandlers(hasMultipleSources: boolean) {
995
- this.handlers = {
996
- play: () => { if (this.video) this.video.play(); },
997
- pause: () => { if (this.video) this.video.pause(); },
998
- timeupdate: () => {
999
- if (this.video && Math.abs(this.video.currentTime - this.player.state.currentTime) > 0.5) {
1000
- this.video.currentTime = this.player.state.currentTime;
1001
- }
1002
- },
1003
- ratechange: () => {
1004
- if (this.video) this.video.playbackRate = this.player.state.playbackSpeed;
1005
- }
1006
- };
1007
-
1008
- this.player.on('play', this.handlers.play);
1009
- this.player.on('pause', this.handlers.pause);
1010
- this.player.on('timeupdate', this.handlers.timeupdate);
1011
- this.player.on('ratechange', this.handlers.ratechange);
1012
-
1013
- if (hasMultipleSources) {
1014
- this.handlers.captionChange = () => {
1015
- if (this.player.captionManager?.currentTrack && this.selector) {
1016
- const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
1017
- if (captionLang && this.sources[captionLang] && this.currentLanguage !== captionLang) {
1018
- this.switchLanguage(captionLang);
1019
- this.selector.value = captionLang;
1020
- }
1021
- }
1022
- };
1023
- this.player.on('captionsenabled', this.handlers.captionChange);
1024
- }
1025
- }
1026
-
1027
- /**
1028
- * Constrain position within video wrapper
1029
- */
1030
- constrainPosition() {
1031
- if (!this.wrapper || !this.player.videoWrapper) return;
1032
-
1033
- if (this.draggable?.manuallyPositioned) return;
1034
-
1035
- if (!this.wrapper.style.width) {
1036
- this.wrapper.style.width = '280px';
1037
- }
1038
-
1039
- const videoWrapperRect = this.player.videoWrapper.getBoundingClientRect();
1040
- const containerRect = this.player.container.getBoundingClientRect();
1041
- const wrapperRect = this.wrapper.getBoundingClientRect();
1042
-
1043
- const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1044
- const videoWrapperTop = videoWrapperRect.top - containerRect.top;
1045
- const videoWrapperWidth = videoWrapperRect.width;
1046
- const videoWrapperHeight = videoWrapperRect.height;
1047
-
1048
- const wrapperWidth = wrapperRect.width || 280;
1049
- const wrapperHeight = wrapperRect.height || ((280 * 9) / 16);
1050
-
1051
- let left, top;
1052
- const margin = 16;
1053
- const controlsHeight = 95;
1054
-
1055
- const position = this.desiredPosition || 'bottom-right';
1056
-
1057
- switch (position) {
1058
- case 'bottom-right':
1059
- left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1060
- top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1061
- break;
1062
- case 'bottom-left':
1063
- left = videoWrapperLeft + margin;
1064
- top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1065
- break;
1066
- case 'top-right':
1067
- left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1068
- top = videoWrapperTop + margin;
1069
- break;
1070
- case 'top-left':
1071
- left = videoWrapperLeft + margin;
1072
- top = videoWrapperTop + margin;
1073
- break;
1074
- default:
1075
- left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1076
- top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1077
- }
1078
-
1079
- left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
1080
- top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
1081
-
1082
- this.wrapper.style.left = `${left}px`;
1083
- this.wrapper.style.top = `${top}px`;
1084
- this.wrapper.style.right = 'auto';
1085
- this.wrapper.style.bottom = 'auto';
1086
- }
1087
-
1088
- /**
1089
- * Show the settings menu. Delegates to the shared {@link DraggablePanel},
1090
- * which owns the DOM, outside-click dismissal, keyboard navigation and
1091
- * positioning. Kept as a named method so external callers (other
1092
- * managers, tests) that referenced the legacy API keep working.
1093
- */
1094
- showSettingsMenu() {
1095
- this._panel?.show();
1096
- }
1097
-
1098
- /** @see {@link showSettingsMenu} */
1099
- hideSettingsMenu({ focusButton = true } = {}) {
1100
- this._panel?.hide({ focusButton });
1101
- }
1102
-
1103
- // Badge management moved into {@link DraggablePanel}; these
1104
- // delegates keep the legacy names so internal call sites and
1105
- // subclassers (if any) continue to work. Announcements for
1106
- // assistive tech still go through the shared KeyboardManager
1107
- // live region so AT doesn't read both the badge text and the
1108
- // live-region copy.
1109
- _showModeBadge(text: string) {
1110
- this._panel?.showBadge(text);
1111
- }
1112
-
1113
- _hideModeBadge() {
1114
- this._panel?.hideBadge();
1115
- }
1116
-
1117
- /**
1118
- * Toggle keyboard drag mode
1119
- */
1120
- toggleKeyboardDragMode() {
1121
- if (!this.draggable) return;
1122
-
1123
- const wasEnabled = this.draggable.keyboardDragMode;
1124
- this.draggable.toggleKeyboardDragMode();
1125
- const isEnabled = this.draggable.keyboardDragMode;
1126
-
1127
- if (!wasEnabled && isEnabled) {
1128
- this._enableMoveMode();
1129
- this._showModeBadge(i18n.t('player.signDragModeHint'));
1130
- this.player.keyboardManager?.announce(i18n.t('player.signLanguageDragActive'));
1131
- } else if (wasEnabled && !isEnabled) {
1132
- this._hideModeBadge();
1133
- this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
1134
- }
1135
-
1136
- this._updateDragOptionState();
1137
- }
1138
-
1139
- /**
1140
- * Enable move mode visual feedback
1141
- */
1142
- _enableMoveMode() {
1143
- this.wrapper?.classList.add(`${this.player.options.classPrefix}-sign-move-mode`);
1144
- this._updateResizeOptionState();
1145
- setTimeout(() => {
1146
- this.wrapper?.classList.remove(`${this.player.options.classPrefix}-sign-move-mode`);
1147
- }, 2000);
1148
- }
1149
-
1150
- /**
1151
- * Toggle resize mode
1152
- */
1153
- toggleResizeMode({ focus = true } = {}) {
1154
- if (!this.draggable) return false;
1155
-
1156
- if (this.draggable.pointerResizeMode) {
1157
- this.draggable.disablePointerResizeMode({ focus });
1158
- this._updateResizeOptionState();
1159
- return false;
1160
- }
1161
-
1162
- this.draggable.enablePointerResizeMode({ focus });
1163
- this._updateResizeOptionState();
1164
- return true;
1165
- }
1166
-
1167
- // Thin delegates to the panel's refreshState. Kept as named methods
1168
- // so the existing internal call sites (e.g. `toggleKeyboardDragMode`)
1169
- // read naturally without a double-dot chain to the panel.
1170
- _updateDragOptionState() {
1171
- this._panel?.refreshDragState();
1172
- }
1173
-
1174
- _updateResizeOptionState() {
1175
- this._panel?.refreshResizeState();
1176
- }
1177
-
1178
- /**
1179
- * Save preferences
1180
- */
1181
- savePreferences() {
1182
- if (!this.wrapper) return;
1183
-
1184
- this.player.storage.saveSignLanguagePreferences({
1185
- size: { width: this.wrapper.style.width }
1186
- });
1187
- }
1188
-
1189
- /**
1190
- * Update sources (called when playlist changes)
1191
- */
1192
- updateSources(signLanguageSrc?: string | null, signLanguageSources?: Record<string, string> | null) {
1193
- this.src = signLanguageSrc || null;
1194
- this.sources = signLanguageSources || {};
1195
- // Reset state for new playlist item (cleanup is called separately before this)
1196
- this.currentLanguage = null;
1197
- }
1198
-
1199
- /**
1200
- * Cleanup
1201
- */
1202
- cleanup() {
1203
- if (this.inMainView && this.player.element) {
1204
- const el = this.player.element;
1205
- if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
1206
- (Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
1207
- const trackNode = el.querySelector('track');
1208
- this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
1209
- if (trackNode) {
1210
- el.insertBefore(source, trackNode);
1211
- } else {
1212
- el.appendChild(source);
1213
- }
1214
- });
1215
- this._mainViewUsingSourceSwap = false;
1216
- } else if (this.mainViewOriginalSrc) {
1217
- el.src = this.mainViewOriginalSrc;
1218
- }
1219
- el.muted = this._mainViewMutedBefore;
1220
- if (typeof this.player.invalidateTrackCache === 'function') {
1221
- this.player.invalidateTrackCache();
1222
- }
1223
- el.load();
1224
- this.mainViewOriginalSrc = null;
1225
- this.mainViewOriginalSources = null;
1226
- this.inMainView = false;
1227
- this.player.state.signLanguageInMainView = false;
1228
- if (this.player.videoWrapper) {
1229
- this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
1230
- }
1231
- this.player.emit('signlanguageinmainviewdisabled');
1232
- }
1233
- if (this.settingsMenuVisible) {
1234
- this.hideSettingsMenu({ focusButton: false });
1235
- }
1236
-
1237
- // The panel owns its outside-click listener (bound to the
1238
- // player's lifecycleSignal) and will drop that listener
1239
- // automatically during player teardown. We still destroy the
1240
- // panel here so its DOM is removed synchronously when the
1241
- // wrapper goes away.
1242
- if (this._panel) {
1243
- this._panel.destroy();
1244
- this._panel = null;
1245
- }
1246
-
1247
- // Remove settings handlers
1248
- if (this.settingsHandlers && this.settingsButton) {
1249
- this.settingsButton.removeEventListener('click', this.settingsHandlers.click);
1250
- this.settingsButton.removeEventListener('keydown', this.settingsHandlers.keydown);
1251
- }
1252
- this.settingsHandlers = null;
1253
-
1254
- // Remove event handlers
1255
- if (this.handlers) {
1256
- this.player.off('play', this.handlers.play);
1257
- this.player.off('pause', this.handlers.pause);
1258
- this.player.off('timeupdate', this.handlers.timeupdate);
1259
- this.player.off('ratechange', this.handlers.ratechange);
1260
- if (this.handlers.captionChange) {
1261
- this.player.off('captionsenabled', this.handlers.captionChange);
1262
- }
1263
- this.handlers = null;
1264
- }
1265
-
1266
- // Remove custom key handler
1267
- if (this.wrapper && this.customKeyHandler) {
1268
- this.wrapper.removeEventListener('keydown', this.customKeyHandler);
1269
- }
1270
-
1271
- // Destroy draggable
1272
- if (this.draggable) {
1273
- if (this.draggable.pointerResizeMode) {
1274
- this.draggable.disablePointerResizeMode();
1275
- }
1276
- this.draggable.destroy();
1277
- this.draggable = null;
1278
- }
1279
-
1280
- this.interactionHandlers = null;
1281
-
1282
- // Remove the mode badge before tearing down the wrapper, so the
1283
- // reference doesn't outlive the DOM tree it belongs to.
1284
- this._hideModeBadge();
1285
-
1286
- // Remove video and wrapper
1287
- if (this.wrapper?.parentNode) {
1288
- if (this.video) {
1289
- this.video.pause();
1290
- this.video.src = '';
1291
- }
1292
- this.wrapper.parentNode.removeChild(this.wrapper);
1293
- }
1294
-
1295
- this.wrapper = null;
1296
- this.video = null;
1297
- this.settingsButton = null;
1298
- }
1299
-
1300
- /**
1301
- * Destroy
1302
- */
1303
- destroy() {
1304
- this.cleanup();
1305
- this.enabled = false;
1306
- }
1307
- }
1308
-
1
+ /**
2
+ * Sign Language Video Manager
3
+ * Handles picture-in-picture sign language video overlay
4
+ */
5
+
6
+ import { DOMUtils } from '../utils/DOMUtils.js';
7
+ import { createIconElement } from '../icons/Icons.js';
8
+ import { i18n } from '../i18n/i18n.js';
9
+ import { DraggableResizable } from '../utils/DraggableResizable.js';
10
+ import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
11
+ import { DraggablePanel } from '../utils/DraggablePanel.js';
12
+ import type { Player } from './Player.js';
13
+
14
+ /**
15
+ * Bag of player-event handlers the sign-language overlay binds to so it
16
+ * can mirror the main player's playback state.
17
+ */
18
+ interface SignLanguageHandlers {
19
+ play: () => void;
20
+ pause: () => void;
21
+ timeupdate: () => void;
22
+ ratechange: () => void;
23
+ captionChange?: () => void;
24
+ }
25
+
26
+ interface SignLanguageSettingsHandlers {
27
+ click: (e: MouseEvent) => void;
28
+ keydown: (e: KeyboardEvent) => void;
29
+ }
30
+
31
+ interface SignLanguageInteractionHandlers {
32
+ draggable: DraggableResizable | null;
33
+ customKeyHandler: ((e: KeyboardEvent) => void) | null;
34
+ }
35
+
36
+ export class SignLanguageManager {
37
+ player: Player;
38
+ _mainViewMutedBefore: boolean;
39
+ _mainViewUsingSourceSwap: boolean;
40
+ currentLanguage: string | null;
41
+ customKeyHandler: ((e: KeyboardEvent) => void) | null;
42
+ desiredPosition: string;
43
+ draggable: DraggableResizable | null;
44
+ handlers: SignLanguageHandlers | null;
45
+ header: HTMLElement | null;
46
+ inMainView: boolean;
47
+ interactionHandlers: SignLanguageInteractionHandlers | null;
48
+ mainViewOriginalSources: HTMLSourceElement[] | null;
49
+ mainViewOriginalSrc: string | null;
50
+ resizeHandles: HTMLElement[];
51
+ selector: HTMLSelectElement | null;
52
+ settingsButton: HTMLButtonElement | null;
53
+ settingsHandlers: SignLanguageSettingsHandlers | null;
54
+ sources: Record<string, string>;
55
+ src: string | null;
56
+ enabled: boolean;
57
+ video: HTMLVideoElement | null;
58
+ wrapper: HTMLElement | null;
59
+
60
+ /**
61
+ * Encapsulates the settings-menu DOM, lifecycle (show/hide/outside-
62
+ * click/keyboard nav/positioning), and the drag/resize toggle items.
63
+ * Owned here but lazily created once {@link _setupSettingsButton}
64
+ * instantiates the button it needs to anchor from.
65
+ */
66
+ private _panel: DraggablePanel | null = null;
67
+
68
+ // Back-compat getters for external callers (Player.ts exposes these
69
+ // under `signLanguageSettingsMenu` / `signLanguageSettingsMenuVisible`)
70
+ // and for internal readers of the shared menu-item state (which is now
71
+ // panel-owned). Setters are no-ops by design — the panel is the
72
+ // authoritative owner of these values.
73
+ get settingsMenu(): HTMLElement | null {
74
+ return this._panel?.settingsMenu ?? null;
75
+ }
76
+ set settingsMenu(_v: HTMLElement | null) {
77
+ // panel-owned; ignored to keep legacy API surface inert.
78
+ }
79
+
80
+ get settingsMenuVisible(): boolean {
81
+ return this._panel?.settingsMenuVisible ?? false;
82
+ }
83
+ set settingsMenuVisible(_v: boolean) {
84
+ // panel-owned.
85
+ }
86
+
87
+ get settingsMenuJustOpened(): boolean {
88
+ return this._panel?.justOpened ?? false;
89
+ }
90
+ set settingsMenuJustOpened(_v: boolean) {
91
+ // panel-owned.
92
+ }
93
+
94
+ get dragOptionButton(): HTMLElement | null {
95
+ return this._panel?.dragOptionButton ?? null;
96
+ }
97
+ get dragOptionText(): Element | null {
98
+ return this._panel?.dragOptionText ?? null;
99
+ }
100
+ get resizeOptionButton(): HTMLElement | null {
101
+ return this._panel?.resizeOptionButton ?? null;
102
+ }
103
+ get resizeOptionText(): Element | null {
104
+ return this._panel?.resizeOptionText ?? null;
105
+ }
106
+
107
+ constructor(player: Player) {
108
+ this.player = player;
109
+
110
+ // Sources
111
+ this.src = player.options.signLanguageSrc;
112
+ this.sources = player.options.signLanguageSources || {};
113
+ this.currentLanguage = null;
114
+ this.desiredPosition = player.options.signLanguagePosition || 'bottom-right';
115
+
116
+ // DOM elements
117
+ this.wrapper = null;
118
+ this.header = null;
119
+ this.video = null;
120
+ this.selector = null;
121
+ this.settingsButton = null;
122
+ this.resizeHandles = [];
123
+
124
+ // State
125
+ this.enabled = false;
126
+ this.inMainView = false; // sign language shown in main video (src swap, like audio description)
127
+ this.mainViewOriginalSrc = null;
128
+ this.mainViewOriginalSources = null;
129
+ this._mainViewUsingSourceSwap = false;
130
+ this._mainViewMutedBefore = false;
131
+
132
+ // Handlers
133
+ this.handlers = null;
134
+ this.settingsHandlers = null;
135
+ this.interactionHandlers = null;
136
+ this.draggable = null;
137
+ this.customKeyHandler = null;
138
+ }
139
+
140
+ /**
141
+ * Check if sign language is available
142
+ */
143
+ isAvailable() {
144
+ return Object.keys(this.sources).length > 0 || Boolean(this.src);
145
+ }
146
+
147
+ /**
148
+ * Enable sign language video
149
+ */
150
+ enable() {
151
+ const hasMultipleSources = Object.keys(this.sources).length > 0;
152
+ const hasSingleSource = Boolean(this.src);
153
+
154
+ if (!hasMultipleSources && !hasSingleSource) {
155
+ console.warn('No sign language video source provided');
156
+ return;
157
+ }
158
+
159
+ if (this.wrapper) {
160
+ // Already exists, just show it
161
+ this.wrapper.style.display = 'block';
162
+ this.enabled = true;
163
+ this.player.state.signLanguageEnabled = true;
164
+ this.player.emit('signlanguageenabled');
165
+
166
+ // Focus settings button
167
+ this.player.setManagedTimeout(() => {
168
+ if (this.settingsButton && document.contains(this.settingsButton)) {
169
+ this.settingsButton.focus({ preventScroll: true });
170
+ }
171
+ }, 150);
172
+ return;
173
+ }
174
+
175
+ // Determine initial language
176
+ let initialLang = null;
177
+ let initialSrc = null;
178
+
179
+ if (hasMultipleSources) {
180
+ initialLang = this._determineInitialLanguage();
181
+ initialSrc = this.sources[initialLang] ?? null;
182
+ this.currentLanguage = initialLang;
183
+ } else {
184
+ initialSrc = this.src;
185
+ }
186
+
187
+ // Create UI
188
+ this._createWrapper();
189
+ this._createHeader(hasMultipleSources, initialLang);
190
+ this._createVideo(initialSrc);
191
+ this._createResizeHandles();
192
+
193
+ // Assemble. The create methods mutate this.wrapper/this.video, but
194
+ // TypeScript still sees them as null due to the earlier
195
+ // `if (this.wrapper) return` narrowing, so re-read via a typed alias.
196
+ const wrapper = this.wrapper as HTMLElement | null;
197
+ const video = this.video as HTMLVideoElement | null;
198
+ if (!wrapper || !video) {
199
+ return;
200
+ }
201
+ wrapper.appendChild(this.header as Node);
202
+ wrapper.appendChild(video);
203
+ this.resizeHandles.forEach(handle => wrapper.appendChild(handle));
204
+
205
+ // Set initial size
206
+ this._applyInitialSize();
207
+
208
+ // Add to container
209
+ this.player.container.appendChild(wrapper);
210
+
211
+ // Position
212
+ requestAnimationFrame(() => {
213
+ this.constrainPosition();
214
+ });
215
+
216
+ // Sync with main video
217
+ video.currentTime = this.player.state.currentTime;
218
+ if (!this.player.state.paused) {
219
+ video.play();
220
+ }
221
+
222
+ // Setup interaction
223
+ this._setupInteraction();
224
+
225
+ // Setup event handlers
226
+ this._setupEventHandlers(hasMultipleSources);
227
+
228
+ this.enabled = true;
229
+ this.player.state.signLanguageEnabled = true;
230
+ this.player.emit('signlanguageenabled');
231
+
232
+ // Focus settings button
233
+ this.player.setManagedTimeout(() => {
234
+ if (this.settingsButton && document.contains(this.settingsButton)) {
235
+ this.settingsButton.focus({ preventScroll: true });
236
+ }
237
+ }, 150);
238
+ }
239
+
240
+ /**
241
+ * Disable sign language video
242
+ */
243
+ disable() {
244
+ if (this.settingsMenuVisible) {
245
+ this.hideSettingsMenu({ focusButton: false });
246
+ }
247
+
248
+ // Remove the mode badge so it doesn't linger past the overlay
249
+ // (e.g. if user hides sign language while drag/resize mode was on).
250
+ this._hideModeBadge();
251
+
252
+ if (this.wrapper) {
253
+ this.wrapper.style.display = 'none';
254
+ }
255
+ this.enabled = false;
256
+ this.player.state.signLanguageEnabled = false;
257
+ this.player.emit('signlanguagedisabled');
258
+ }
259
+
260
+ /**
261
+ * Toggle sign language video
262
+ */
263
+ toggle() {
264
+ if (this.enabled) {
265
+ this.disable();
266
+ } else {
267
+ this.enable();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Enable sign language in main view: replace main video src with sign language URL (like audio description).
273
+ * Same video element, different URL; no overlay.
274
+ */
275
+ async enableInMainView() {
276
+ const hasMultipleSources = Object.keys(this.sources).length > 0;
277
+ const hasSingleSource = Boolean(this.src);
278
+ if (!hasMultipleSources && !hasSingleSource) return;
279
+ if (!this.player.element || this.player.element.tagName !== 'VIDEO') return;
280
+ if (this.inMainView) return;
281
+
282
+ let signSrc;
283
+ if (hasMultipleSources) {
284
+ const initialLang = this._determineInitialLanguage();
285
+ this.currentLanguage = initialLang;
286
+ signSrc = this.sources[initialLang];
287
+ } else {
288
+ signSrc = this.src;
289
+ }
290
+
291
+ const el = this.player.element as HTMLVideoElement;
292
+ const currentTime = this.player.state.currentTime;
293
+ const wasPlaying = this.player.state.playing;
294
+ const posterValue = el.poster || el.getAttribute('poster') || this.player.options.poster;
295
+ const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
296
+
297
+ const sourceElements: HTMLSourceElement[] = Array.from(el.querySelectorAll('source'));
298
+ const firstSource = sourceElements[0];
299
+ this.mainViewOriginalSrc = (el.currentSrc && el.currentSrc.length > 0)
300
+ ? el.currentSrc
301
+ : (el.src && el.src.length > 0)
302
+ ? el.src
303
+ : (firstSource && firstSource.getAttribute('src'))
304
+ ? firstSource.getAttribute('src')
305
+ : '';
306
+ this._mainViewMutedBefore = this.player.state.muted;
307
+
308
+ if (posterValue && shouldKeepPoster && el.tagName === 'VIDEO') {
309
+ el.poster = posterValue;
310
+ }
311
+ if (sourceElements.length > 0) {
312
+ this.mainViewOriginalSources = sourceElements;
313
+ this.mainViewOriginalSources.forEach(source => source.remove());
314
+ const signSource = document.createElement('source');
315
+ const signSrcValue = signSrc ?? '';
316
+ signSource.setAttribute('src', signSrcValue);
317
+ const type = this._inferVideoType(signSrcValue);
318
+ if (type) {
319
+ signSource.setAttribute('type', type);
320
+ }
321
+ const trackNode = el.querySelector('track');
322
+ if (trackNode) {
323
+ el.insertBefore(signSource, trackNode);
324
+ } else {
325
+ el.appendChild(signSource);
326
+ }
327
+ this._mainViewUsingSourceSwap = true;
328
+ } else {
329
+ el.src = signSrc ?? '';
330
+ this._mainViewUsingSourceSwap = false;
331
+ }
332
+ el.muted = true;
333
+ this.player.currentSource = signSrc ?? '';
334
+ if (typeof this.player.invalidateTrackCache === 'function') {
335
+ this.player.invalidateTrackCache();
336
+ }
337
+ el.load();
338
+
339
+ await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
340
+
341
+ if (currentTime > 0) {
342
+ this.player.seek(currentTime);
343
+ await new Promise(r => setTimeout(r, 100));
344
+ }
345
+ if (wasPlaying) {
346
+ await this.player.play();
347
+ } else {
348
+ this.player.pause();
349
+ if (!shouldKeepPoster && this.player.hidePosterOverlay) {
350
+ this.player.hidePosterOverlay();
351
+ }
352
+ }
353
+
354
+ this.inMainView = true;
355
+ this.player.state.signLanguageInMainView = true;
356
+ if (this.player.videoWrapper) {
357
+ this.player.videoWrapper.classList.add('vidply-sign-language-main-view-active');
358
+ }
359
+ this.player.emit('signlanguageinmainviewenabled');
360
+ }
361
+
362
+ /**
363
+ * Disable sign language in main view: restore main video src.
364
+ */
365
+ async disableInMainView() {
366
+ if (!this.inMainView) return;
367
+ if (!this.mainViewOriginalSrc && !this.mainViewOriginalSources) {
368
+ this.inMainView = false;
369
+ this.player.state.signLanguageInMainView = false;
370
+ if (this.player.videoWrapper) {
371
+ this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
372
+ }
373
+ this.player.emit('signlanguageinmainviewdisabled');
374
+ return;
375
+ }
376
+
377
+ const el = this.player.element as HTMLVideoElement;
378
+ const currentTime = this.player.state.currentTime;
379
+ const wasPlaying = this.player.state.playing;
380
+
381
+ if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
382
+ (Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
383
+ const trackNode = el.querySelector('track');
384
+ this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
385
+ if (trackNode) {
386
+ el.insertBefore(source, trackNode);
387
+ } else {
388
+ el.appendChild(source);
389
+ }
390
+ });
391
+ this._mainViewUsingSourceSwap = false;
392
+ } else if (this.mainViewOriginalSrc) {
393
+ el.src = this.mainViewOriginalSrc;
394
+ }
395
+ el.muted = this._mainViewMutedBefore;
396
+ this.player.currentSource = this.mainViewOriginalSrc || el.querySelector('source')?.src || '';
397
+ if (typeof this.player.invalidateTrackCache === 'function') {
398
+ this.player.invalidateTrackCache();
399
+ }
400
+ el.load();
401
+
402
+ await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
403
+
404
+ if (currentTime > 0) {
405
+ this.player.seek(currentTime);
406
+ }
407
+ if (wasPlaying) {
408
+ try {
409
+ await this.player.play();
410
+ } catch (e) {
411
+ this.player.log?.('Sign language main view: play after restore failed', e, 'warn');
412
+ }
413
+ }
414
+
415
+ this.mainViewOriginalSrc = null;
416
+ this.mainViewOriginalSources = null;
417
+ this.inMainView = false;
418
+ this.player.state.signLanguageInMainView = false;
419
+ if (this.player.videoWrapper) {
420
+ this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
421
+ }
422
+ this.player.emit('signlanguageinmainviewdisabled');
423
+ }
424
+
425
+ /**
426
+ * Wait for media ready (like AudioDescriptionManager).
427
+ */
428
+ async _waitForMediaReadyMainView(needSeek = false) {
429
+ const el = this.player.element;
430
+ const loadedMetaPromise = new Promise<void>((resolve) => {
431
+ if (el.readyState >= 1) {
432
+ resolve();
433
+ return;
434
+ }
435
+ const onLoad = () => {
436
+ el.removeEventListener('loadedmetadata', onLoad);
437
+ el.removeEventListener('error', onError);
438
+ resolve();
439
+ };
440
+ const onError = () => {
441
+ el.removeEventListener('loadedmetadata', onLoad);
442
+ el.removeEventListener('error', onError);
443
+ resolve();
444
+ };
445
+ el.addEventListener('loadedmetadata', onLoad);
446
+ el.addEventListener('error', onError, { once: true });
447
+ });
448
+ const timeoutPromise = new Promise(r => setTimeout(r, 10000));
449
+ await Promise.race([loadedMetaPromise, timeoutPromise]);
450
+ await new Promise(r => setTimeout(r, 300));
451
+ if (needSeek) {
452
+ await new Promise<void>((resolve) => {
453
+ if (el.readyState >= 3) resolve();
454
+ else {
455
+ const onCanPlay = () => {
456
+ el.removeEventListener('canplay', onCanPlay);
457
+ el.removeEventListener('canplaythrough', onCanPlay);
458
+ resolve();
459
+ };
460
+ el.addEventListener('canplay', onCanPlay, { once: true });
461
+ el.addEventListener('canplaythrough', onCanPlay, { once: true });
462
+ setTimeout(() => {
463
+ el.removeEventListener('canplay', onCanPlay);
464
+ el.removeEventListener('canplaythrough', onCanPlay);
465
+ resolve();
466
+ }, 3000);
467
+ }
468
+ });
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Toggle sign language in main view (src swap, like audio description).
474
+ */
475
+ toggleInMainView() {
476
+ if (this.inMainView) {
477
+ this.disableInMainView();
478
+ } else {
479
+ this.enableInMainView();
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Switch to a different sign language
485
+ */
486
+ switchLanguage(langCode: string) {
487
+ if (!this.sources[langCode]) return;
488
+
489
+ this.currentLanguage = langCode;
490
+
491
+ if (this.video) {
492
+ const currentTime = this.video.currentTime;
493
+ const wasPlaying = !this.video.paused;
494
+ this.video.src = this.sources[langCode];
495
+ this.video.currentTime = currentTime;
496
+ if (wasPlaying) {
497
+ // Autoplay can be rejected by browser policy; debug-log
498
+ // unknown errors instead of silently swallowing.
499
+ this.video.play().catch((e: unknown) => {
500
+ if (typeof console !== 'undefined' && console.debug) {
501
+ console.debug('[VidPly] sign-language play() rejected:', e);
502
+ }
503
+ });
504
+ }
505
+ }
506
+
507
+ if (this.inMainView && this.player.element && this.player.element.tagName === 'VIDEO') {
508
+ const currentTime = this.player.state.currentTime;
509
+ const wasPlaying = this.player.state.playing;
510
+ if (this._mainViewUsingSourceSwap) {
511
+ const signSource = this.player.element.querySelector('source');
512
+ if (signSource) {
513
+ signSource.setAttribute('src', this.sources[langCode]);
514
+ const type = this._inferVideoType(this.sources[langCode]);
515
+ if (type) {
516
+ signSource.setAttribute('type', type);
517
+ }
518
+ }
519
+ } else {
520
+ this.player.element.src = this.sources[langCode];
521
+ }
522
+ this.player.currentSource = this.sources[langCode];
523
+ if (typeof this.player.invalidateTrackCache === 'function') {
524
+ this.player.invalidateTrackCache();
525
+ }
526
+ this.player.element.load();
527
+ this._waitForMediaReadyMainView(true).then(() => {
528
+ if (currentTime > 0) this.player.seek(currentTime);
529
+ if (wasPlaying) this.player.play();
530
+ });
531
+ }
532
+
533
+ this.player.emit('signlanguagelanguagechanged', langCode);
534
+ }
535
+
536
+ _inferVideoType(url: string): string {
537
+ if (!url) return '';
538
+ const cleanUrl = (url.split('?')[0] ?? '').toLowerCase();
539
+ if (cleanUrl.endsWith('.mp4')) return 'video/mp4';
540
+ if (cleanUrl.endsWith('.webm')) return 'video/webm';
541
+ if (cleanUrl.endsWith('.ogv') || cleanUrl.endsWith('.ogg')) return 'video/ogg';
542
+ return '';
543
+ }
544
+
545
+ /**
546
+ * Get language label
547
+ */
548
+ getLanguageLabel(langCode: string): string {
549
+ const langNames: Record<string, string> = {
550
+ 'en': 'English',
551
+ 'de': 'Deutsch',
552
+ 'es': 'Español',
553
+ 'fr': 'Français',
554
+ 'it': 'Italiano',
555
+ 'ja': '日本語',
556
+ 'pt': 'Português',
557
+ 'ar': 'العربية',
558
+ 'hi': 'हिन्दी'
559
+ };
560
+ return langNames[langCode] || langCode.toUpperCase();
561
+ }
562
+
563
+ /**
564
+ * Determine initial sign language
565
+ */
566
+ _determineInitialLanguage(): string {
567
+ // Try caption language
568
+ if (this.player.captionManager && this.player.captionManager.currentTrack) {
569
+ const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
570
+ if (captionLang && this.sources[captionLang]) {
571
+ return captionLang;
572
+ }
573
+ }
574
+
575
+ // Try player language
576
+ if (this.player.options.language) {
577
+ const playerLang = this.player.options.language.toLowerCase().split('-')[0];
578
+ if (playerLang && this.sources[playerLang]) {
579
+ return playerLang;
580
+ }
581
+ }
582
+
583
+ // First available
584
+ return Object.keys(this.sources)[0] ?? '';
585
+ }
586
+
587
+ /**
588
+ * Create wrapper element
589
+ */
590
+ _createWrapper() {
591
+ this.wrapper = document.createElement('div');
592
+ this.wrapper.className = 'vidply-sign-language-wrapper';
593
+ this.wrapper.setAttribute('tabindex', '0');
594
+ this.wrapper.setAttribute('aria-label', i18n.t('player.signLanguageDragResize'));
595
+ }
596
+
597
+ /**
598
+ * Create header element
599
+ */
600
+ _createHeader(hasMultipleSources: boolean, initialLang: string | null) {
601
+ const classPrefix = this.player.options.classPrefix;
602
+
603
+ this.header = DOMUtils.createElement('div', {
604
+ className: `${classPrefix}-sign-language-header`,
605
+ attributes: { 'tabindex': '0' }
606
+ });
607
+
608
+ const headerLeft = DOMUtils.createElement('div', {
609
+ className: `${classPrefix}-sign-language-header-left`
610
+ });
611
+
612
+ const title = DOMUtils.createElement('h3', {
613
+ textContent: i18n.t('player.signLanguageVideo')
614
+ });
615
+
616
+ // Settings button
617
+ this._createSettingsButton(headerLeft);
618
+
619
+ // Language selector
620
+ if (hasMultipleSources) {
621
+ this._createLanguageSelector(headerLeft, initialLang);
622
+ }
623
+
624
+ headerLeft.appendChild(title);
625
+
626
+ // Close button
627
+ const closeButton = this._createCloseButton();
628
+
629
+ this.header.appendChild(headerLeft);
630
+ this.header.appendChild(closeButton);
631
+ }
632
+
633
+ /**
634
+ * Create settings button and wire it to a {@link DraggablePanel}
635
+ * that owns the drag/resize settings menu and its lifecycle.
636
+ */
637
+ _createSettingsButton(container: HTMLElement) {
638
+ const classPrefix = this.player.options.classPrefix;
639
+ const ariaLabel = i18n.t('player.signLanguageSettings');
640
+
641
+ this.settingsButton = DOMUtils.createElement('button', {
642
+ className: `${classPrefix}-sign-language-settings`,
643
+ attributes: {
644
+ 'type': 'button',
645
+ 'aria-label': ariaLabel,
646
+ 'aria-expanded': 'false'
647
+ }
648
+ }) as HTMLButtonElement;
649
+ this.settingsButton.appendChild(createIconElement('settings'));
650
+ DOMUtils.attachTooltip(this.settingsButton, ariaLabel, classPrefix);
651
+
652
+ // The panel ties the settings-menu DOM, outside-click dismissal,
653
+ // keyboard navigation, and drag/resize toggle items together
654
+ // with the shared lifecycle signal. The manager still owns the
655
+ // side effects that happen when a mode actually toggles (badge,
656
+ // announcement, focus target), so the click callbacks below run
657
+ // the manager's own toggle methods.
658
+ this._panel = new DraggablePanel({
659
+ player: this.player,
660
+ namespace: 'sign-language',
661
+ settingsButton: this.settingsButton,
662
+ getDraggable: () => this.draggable,
663
+ i18nKeys: {
664
+ enableDrag: 'player.enableSignDragMode',
665
+ disableDrag: 'player.disableSignDragMode',
666
+ enableDragAria: 'player.enableSignDragModeAria',
667
+ disableDragAria: 'player.disableSignDragModeAria',
668
+ enableResize: 'player.enableSignResizeMode',
669
+ disableResize: 'player.disableSignResizeMode',
670
+ enableResizeAria: 'player.enableSignResizeModeAria',
671
+ disableResizeAria: 'player.disableSignResizeModeAria',
672
+ closeMenu: 'transcript.closeMenu',
673
+ },
674
+ menuAlign: 'center',
675
+ getMenuParent: () => this.wrapper,
676
+ getBadgeHost: () => this.wrapper,
677
+ // Existing CSS uses `.vidply-sign-mode-badge` (shorter than
678
+ // the namespace default) — pin the class so the styling
679
+ // keeps applying without having to duplicate the rule.
680
+ badgeClass: `${this.player.options.classPrefix}-sign-mode-badge`,
681
+ onDragItemClick: (panel) => {
682
+ this.toggleKeyboardDragMode();
683
+ // Keep focus off the settings button so arrow keys go to
684
+ // the draggable overlay.
685
+ panel.hide({ focusButton: false });
686
+ if (this.draggable?.keyboardDragMode) {
687
+ setTimeout(() => {
688
+ this.wrapper?.focus?.({ preventScroll: true });
689
+ }, 20);
690
+ }
691
+ },
692
+ onResizeItemClick: (panel) => {
693
+ const enabled = this.toggleResizeMode({ focus: false });
694
+ if (enabled) {
695
+ panel.hide({ focusButton: false });
696
+ setTimeout(() => {
697
+ this.wrapper?.focus?.({ preventScroll: true });
698
+ }, 20);
699
+ } else {
700
+ panel.hide({ focusButton: true });
701
+ }
702
+ },
703
+ });
704
+
705
+ this.settingsHandlers = {
706
+ click: (e: MouseEvent) => {
707
+ e.preventDefault();
708
+ e.stopPropagation();
709
+ this._panel?.markJustOpenedForClick();
710
+ this._panel?.toggle();
711
+ },
712
+ keydown: (e: KeyboardEvent) => {
713
+ if (e.key === 'd' || e.key === 'D') {
714
+ e.preventDefault();
715
+ e.stopPropagation();
716
+ this.toggleKeyboardDragMode();
717
+ } else if (e.key === 'r' || e.key === 'R') {
718
+ e.preventDefault();
719
+ e.stopPropagation();
720
+ this.toggleResizeMode();
721
+ } else if (e.key === 'Escape' && this.settingsMenuVisible) {
722
+ e.preventDefault();
723
+ e.stopPropagation();
724
+ this.hideSettingsMenu();
725
+ }
726
+ }
727
+ };
728
+
729
+ this.settingsButton.addEventListener('click', this.settingsHandlers.click);
730
+ this.settingsButton.addEventListener('keydown', this.settingsHandlers.keydown);
731
+ container.appendChild(this.settingsButton);
732
+ }
733
+
734
+ /**
735
+ * Create language selector
736
+ */
737
+ _createLanguageSelector(container: HTMLElement, initialLang: string | null) {
738
+ const classPrefix = this.player.options.classPrefix;
739
+ const selectId = `${classPrefix}-sign-language-select-${Date.now()}`;
740
+
741
+ const options = Object.keys(this.sources).map(langCode => ({
742
+ value: langCode,
743
+ text: this.getLanguageLabel(langCode),
744
+ selected: langCode === initialLang
745
+ }));
746
+
747
+ const { label, select } = createLabeledSelect({
748
+ classPrefix,
749
+ labelClass: `${classPrefix}-sign-language-label`,
750
+ selectClass: `${classPrefix}-sign-language-select`,
751
+ labelText: 'settings.language',
752
+ selectId,
753
+ options,
754
+ onChange: (e) => {
755
+ e.stopPropagation();
756
+ this.switchLanguage((e.target as HTMLSelectElement).value);
757
+ }
758
+ });
759
+
760
+ this.selector = select as HTMLSelectElement;
761
+
762
+ const selectorWrapper = DOMUtils.createElement('div', {
763
+ className: `${classPrefix}-sign-language-selector-wrapper`
764
+ });
765
+ selectorWrapper.appendChild(label);
766
+ selectorWrapper.appendChild(this.selector);
767
+
768
+ preventDragOnElement(selectorWrapper);
769
+ container.appendChild(selectorWrapper);
770
+ }
771
+
772
+ /**
773
+ * Create close button
774
+ */
775
+ _createCloseButton() {
776
+ const classPrefix = this.player.options.classPrefix;
777
+ const ariaLabel = i18n.t('player.closeSignLanguage');
778
+
779
+ const closeButton = DOMUtils.createElement('button', {
780
+ className: `${classPrefix}-sign-language-close`,
781
+ attributes: {
782
+ 'type': 'button',
783
+ 'aria-label': ariaLabel
784
+ }
785
+ });
786
+ closeButton.appendChild(createIconElement('close'));
787
+ DOMUtils.attachTooltip(closeButton, ariaLabel, classPrefix);
788
+
789
+ // Bind to the player lifecycle so the listener is dropped on teardown
790
+ // even if the button is detached without going through cleanup().
791
+ closeButton.addEventListener('click', () => {
792
+ this.disable();
793
+ const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
794
+ if (signLanguageButton) {
795
+ this.player.setManagedTimeout(() => {
796
+ signLanguageButton.focus({ preventScroll: true });
797
+ }, 0);
798
+ }
799
+ }, { signal: this.player.lifecycleSignal });
800
+
801
+ return closeButton;
802
+ }
803
+
804
+ /**
805
+ * Create video element
806
+ */
807
+ _createVideo(src: string | null) {
808
+ this.video = document.createElement('video');
809
+ this.video.className = 'vidply-sign-language-video';
810
+ this.video.src = src ?? '';
811
+ this.video.setAttribute('aria-label', i18n.t('player.signLanguage'));
812
+ this.video.muted = true;
813
+ this.video.setAttribute('playsinline', '');
814
+ }
815
+
816
+ /**
817
+ * Create resize handles
818
+ */
819
+ _createResizeHandles() {
820
+ const classPrefix = this.player.options.classPrefix;
821
+
822
+ this.resizeHandles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(dir => {
823
+ const handle = DOMUtils.createElement('div', {
824
+ className: `${classPrefix}-sign-resize-handle ${classPrefix}-sign-resize-${dir}`,
825
+ attributes: {
826
+ 'data-direction': dir,
827
+ 'data-vidply-managed-resize': 'true',
828
+ 'aria-hidden': 'true'
829
+ }
830
+ });
831
+ handle.style.display = 'none';
832
+ return handle;
833
+ });
834
+ }
835
+
836
+ /**
837
+ * Apply initial size
838
+ */
839
+ _applyInitialSize() {
840
+ const wrapper = this.wrapper;
841
+ if (!wrapper) return;
842
+ const saved = this.player.storage.getSignLanguagePreferences() as { size?: { width?: string } } | null;
843
+ if (saved?.size?.width) {
844
+ wrapper.style.width = saved.size.width;
845
+ } else {
846
+ wrapper.style.width = '280px';
847
+ }
848
+ wrapper.style.height = 'auto';
849
+ }
850
+
851
+ /**
852
+ * Setup interaction (drag and resize)
853
+ */
854
+ _setupInteraction() {
855
+ const isMobile = window.innerWidth < 768;
856
+ const isFullscreen = this.player.state.fullscreen;
857
+
858
+ // Historically, drag/resize was disabled on mobile unless fullscreen to avoid scroll conflicts.
859
+ // Now that we support touch/pointer dragging with proper `touch-action` handling, enable it
860
+ // by default on iOS/Android as well. Allow opting out via option.
861
+ if (isMobile && !isFullscreen && this.player?.options?.signLanguageDragOnMobile === false) {
862
+ if (this.draggable) {
863
+ this.draggable.destroy();
864
+ this.draggable = null;
865
+ }
866
+ return;
867
+ }
868
+
869
+ if (this.draggable) return;
870
+
871
+ const wrapper = this.wrapper;
872
+ if (!wrapper) return;
873
+
874
+ const classPrefix = this.player.options.classPrefix;
875
+
876
+ this.draggable = new DraggableResizable(wrapper, {
877
+ // Allow dragging from anywhere on the sign-language window (better for touch).
878
+ // We still block dragging when interacting with controls via `onDragStart` below.
879
+ dragHandle: this.wrapper,
880
+ resizeHandles: this.resizeHandles,
881
+ constrainToViewport: true,
882
+ maintainAspectRatio: true,
883
+ minWidth: 150,
884
+ minHeight: 100,
885
+ classPrefix: `${classPrefix}-sign`,
886
+ keyboardDragKey: 'd',
887
+ keyboardResizeKey: 'r',
888
+ keyboardStep: 10,
889
+ keyboardStepLarge: 50,
890
+ pointerResizeIndicatorText: i18n.t('player.signLanguageResizeActive'),
891
+ onPointerResizeToggle: (enabled) => {
892
+ this.resizeHandles.forEach(handle => {
893
+ handle.style.display = enabled ? 'block' : 'none';
894
+ });
895
+ // Pair the resize-mode visual state with a translatable
896
+ // badge and a live-region announcement. Fired for all
897
+ // entry/exit paths (menu toggle, R key, Escape, Home).
898
+ if (enabled) {
899
+ this._showModeBadge(i18n.t('player.signResizeModeHint'));
900
+ this.player.keyboardManager?.announce(i18n.t('player.signLanguageResizeActive'));
901
+ } else {
902
+ this._hideModeBadge();
903
+ this.player.keyboardManager?.announce(i18n.t('player.signResizeModeDisabled'));
904
+ }
905
+ },
906
+ onDragStart: (e) => {
907
+ const target = e.target as HTMLElement;
908
+ if (target.closest(`.${classPrefix}-sign-language-close`) ||
909
+ target.closest(`.${classPrefix}-sign-language-settings`) ||
910
+ target.closest(`.${classPrefix}-sign-language-select`) ||
911
+ target.closest(`.${classPrefix}-sign-language-label`) ||
912
+ target.closest(`.${classPrefix}-sign-language-settings-menu`)) {
913
+ return false;
914
+ }
915
+ return true;
916
+ }
917
+ });
918
+
919
+ this._setupCustomKeyHandler();
920
+
921
+ this.interactionHandlers = {
922
+ draggable: this.draggable,
923
+ customKeyHandler: this.customKeyHandler
924
+ };
925
+ }
926
+
927
+ /**
928
+ * Setup custom keyboard handler
929
+ */
930
+ _setupCustomKeyHandler() {
931
+ this.customKeyHandler = (e: KeyboardEvent) => {
932
+ const key = e.key.toLowerCase();
933
+
934
+ if (this.settingsMenuVisible) return;
935
+
936
+ if (key === 'home') {
937
+ e.preventDefault();
938
+ e.stopPropagation();
939
+ if (this.draggable) {
940
+ if (this.draggable.pointerResizeMode) {
941
+ this.draggable.disablePointerResizeMode();
942
+ }
943
+ this.draggable.manuallyPositioned = false;
944
+ this.constrainPosition();
945
+ }
946
+ return;
947
+ }
948
+
949
+ if (key === 'r') {
950
+ e.preventDefault();
951
+ e.stopPropagation();
952
+ if (this.toggleResizeMode()) {
953
+ this.wrapper?.focus({ preventScroll: true });
954
+ }
955
+ return;
956
+ }
957
+
958
+ if (key === 'escape') {
959
+ e.preventDefault();
960
+ e.stopPropagation();
961
+ if (this.draggable?.pointerResizeMode) {
962
+ // disablePointerResizeMode() fires onPointerResizeToggle,
963
+ // which hides the badge and announces the change.
964
+ this.draggable.disablePointerResizeMode();
965
+ return;
966
+ }
967
+ if (this.draggable?.keyboardDragMode) {
968
+ // disableKeyboardDragMode on the draggable has no
969
+ // callback, so mirror the cleanup that
970
+ // toggleKeyboardDragMode() does when it turns the
971
+ // mode off.
972
+ this.draggable.disableKeyboardDragMode();
973
+ this._hideModeBadge();
974
+ this._updateDragOptionState();
975
+ this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
976
+ return;
977
+ }
978
+ this.disable();
979
+ const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
980
+ if (signLanguageButton) {
981
+ setTimeout(() => {
982
+ signLanguageButton.focus({ preventScroll: true });
983
+ }, 0);
984
+ }
985
+ }
986
+ };
987
+
988
+ this.wrapper?.addEventListener('keydown', this.customKeyHandler);
989
+ }
990
+
991
+ /**
992
+ * Setup event handlers
993
+ */
994
+ _setupEventHandlers(hasMultipleSources: boolean) {
995
+ this.handlers = {
996
+ play: () => { if (this.video) this.video.play(); },
997
+ pause: () => { if (this.video) this.video.pause(); },
998
+ timeupdate: () => {
999
+ if (this.video && Math.abs(this.video.currentTime - this.player.state.currentTime) > 0.5) {
1000
+ this.video.currentTime = this.player.state.currentTime;
1001
+ }
1002
+ },
1003
+ ratechange: () => {
1004
+ if (this.video) this.video.playbackRate = this.player.state.playbackSpeed;
1005
+ }
1006
+ };
1007
+
1008
+ this.player.on('play', this.handlers.play);
1009
+ this.player.on('pause', this.handlers.pause);
1010
+ this.player.on('timeupdate', this.handlers.timeupdate);
1011
+ this.player.on('ratechange', this.handlers.ratechange);
1012
+
1013
+ if (hasMultipleSources) {
1014
+ this.handlers.captionChange = () => {
1015
+ if (this.player.captionManager?.currentTrack && this.selector) {
1016
+ const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
1017
+ if (captionLang && this.sources[captionLang] && this.currentLanguage !== captionLang) {
1018
+ this.switchLanguage(captionLang);
1019
+ this.selector.value = captionLang;
1020
+ }
1021
+ }
1022
+ };
1023
+ this.player.on('captionsenabled', this.handlers.captionChange);
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Constrain position within video wrapper
1029
+ */
1030
+ constrainPosition() {
1031
+ if (!this.wrapper || !this.player.videoWrapper) return;
1032
+
1033
+ if (this.draggable?.manuallyPositioned) return;
1034
+
1035
+ if (!this.wrapper.style.width) {
1036
+ this.wrapper.style.width = '280px';
1037
+ }
1038
+
1039
+ const videoWrapperRect = this.player.videoWrapper.getBoundingClientRect();
1040
+ const containerRect = this.player.container.getBoundingClientRect();
1041
+ const wrapperRect = this.wrapper.getBoundingClientRect();
1042
+
1043
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1044
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
1045
+ const videoWrapperWidth = videoWrapperRect.width;
1046
+ const videoWrapperHeight = videoWrapperRect.height;
1047
+
1048
+ const wrapperWidth = wrapperRect.width || 280;
1049
+ const wrapperHeight = wrapperRect.height || ((280 * 9) / 16);
1050
+
1051
+ let left, top;
1052
+ const margin = 16;
1053
+ const controlsHeight = 95;
1054
+
1055
+ const position = this.desiredPosition || 'bottom-right';
1056
+
1057
+ switch (position) {
1058
+ case 'bottom-right':
1059
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1060
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1061
+ break;
1062
+ case 'bottom-left':
1063
+ left = videoWrapperLeft + margin;
1064
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1065
+ break;
1066
+ case 'top-right':
1067
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1068
+ top = videoWrapperTop + margin;
1069
+ break;
1070
+ case 'top-left':
1071
+ left = videoWrapperLeft + margin;
1072
+ top = videoWrapperTop + margin;
1073
+ break;
1074
+ default:
1075
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1076
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1077
+ }
1078
+
1079
+ left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
1080
+ top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
1081
+
1082
+ this.wrapper.style.left = `${left}px`;
1083
+ this.wrapper.style.top = `${top}px`;
1084
+ this.wrapper.style.right = 'auto';
1085
+ this.wrapper.style.bottom = 'auto';
1086
+ }
1087
+
1088
+ /**
1089
+ * Show the settings menu. Delegates to the shared {@link DraggablePanel},
1090
+ * which owns the DOM, outside-click dismissal, keyboard navigation and
1091
+ * positioning. Kept as a named method so external callers (other
1092
+ * managers, tests) that referenced the legacy API keep working.
1093
+ */
1094
+ showSettingsMenu() {
1095
+ this._panel?.show();
1096
+ }
1097
+
1098
+ /** @see {@link showSettingsMenu} */
1099
+ hideSettingsMenu({ focusButton = true } = {}) {
1100
+ this._panel?.hide({ focusButton });
1101
+ }
1102
+
1103
+ // Badge management moved into {@link DraggablePanel}; these
1104
+ // delegates keep the legacy names so internal call sites and
1105
+ // subclassers (if any) continue to work. Announcements for
1106
+ // assistive tech still go through the shared KeyboardManager
1107
+ // live region so AT doesn't read both the badge text and the
1108
+ // live-region copy.
1109
+ _showModeBadge(text: string) {
1110
+ this._panel?.showBadge(text);
1111
+ }
1112
+
1113
+ _hideModeBadge() {
1114
+ this._panel?.hideBadge();
1115
+ }
1116
+
1117
+ /**
1118
+ * Toggle keyboard drag mode
1119
+ */
1120
+ toggleKeyboardDragMode() {
1121
+ if (!this.draggable) return;
1122
+
1123
+ const wasEnabled = this.draggable.keyboardDragMode;
1124
+ this.draggable.toggleKeyboardDragMode();
1125
+ const isEnabled = this.draggable.keyboardDragMode;
1126
+
1127
+ if (!wasEnabled && isEnabled) {
1128
+ this._enableMoveMode();
1129
+ this._showModeBadge(i18n.t('player.signDragModeHint'));
1130
+ this.player.keyboardManager?.announce(i18n.t('player.signLanguageDragActive'));
1131
+ } else if (wasEnabled && !isEnabled) {
1132
+ this._hideModeBadge();
1133
+ this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
1134
+ }
1135
+
1136
+ this._updateDragOptionState();
1137
+ }
1138
+
1139
+ /**
1140
+ * Enable move mode visual feedback
1141
+ */
1142
+ _enableMoveMode() {
1143
+ this.wrapper?.classList.add(`${this.player.options.classPrefix}-sign-move-mode`);
1144
+ this._updateResizeOptionState();
1145
+ setTimeout(() => {
1146
+ this.wrapper?.classList.remove(`${this.player.options.classPrefix}-sign-move-mode`);
1147
+ }, 2000);
1148
+ }
1149
+
1150
+ /**
1151
+ * Toggle resize mode
1152
+ */
1153
+ toggleResizeMode({ focus = true } = {}) {
1154
+ if (!this.draggable) return false;
1155
+
1156
+ if (this.draggable.pointerResizeMode) {
1157
+ this.draggable.disablePointerResizeMode({ focus });
1158
+ this._updateResizeOptionState();
1159
+ return false;
1160
+ }
1161
+
1162
+ this.draggable.enablePointerResizeMode({ focus });
1163
+ this._updateResizeOptionState();
1164
+ return true;
1165
+ }
1166
+
1167
+ // Thin delegates to the panel's refreshState. Kept as named methods
1168
+ // so the existing internal call sites (e.g. `toggleKeyboardDragMode`)
1169
+ // read naturally without a double-dot chain to the panel.
1170
+ _updateDragOptionState() {
1171
+ this._panel?.refreshDragState();
1172
+ }
1173
+
1174
+ _updateResizeOptionState() {
1175
+ this._panel?.refreshResizeState();
1176
+ }
1177
+
1178
+ /**
1179
+ * Save preferences
1180
+ */
1181
+ savePreferences() {
1182
+ if (!this.wrapper) return;
1183
+
1184
+ this.player.storage.saveSignLanguagePreferences({
1185
+ size: { width: this.wrapper.style.width }
1186
+ });
1187
+ }
1188
+
1189
+ /**
1190
+ * Update sources (called when playlist changes)
1191
+ */
1192
+ updateSources(signLanguageSrc?: string | null, signLanguageSources?: Record<string, string> | null) {
1193
+ this.src = signLanguageSrc || null;
1194
+ this.sources = signLanguageSources || {};
1195
+ // Reset state for new playlist item (cleanup is called separately before this)
1196
+ this.currentLanguage = null;
1197
+ }
1198
+
1199
+ /**
1200
+ * Cleanup
1201
+ */
1202
+ cleanup() {
1203
+ if (this.inMainView && this.player.element) {
1204
+ const el = this.player.element;
1205
+ if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
1206
+ (Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
1207
+ const trackNode = el.querySelector('track');
1208
+ this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
1209
+ if (trackNode) {
1210
+ el.insertBefore(source, trackNode);
1211
+ } else {
1212
+ el.appendChild(source);
1213
+ }
1214
+ });
1215
+ this._mainViewUsingSourceSwap = false;
1216
+ } else if (this.mainViewOriginalSrc) {
1217
+ el.src = this.mainViewOriginalSrc;
1218
+ }
1219
+ el.muted = this._mainViewMutedBefore;
1220
+ if (typeof this.player.invalidateTrackCache === 'function') {
1221
+ this.player.invalidateTrackCache();
1222
+ }
1223
+ el.load();
1224
+ this.mainViewOriginalSrc = null;
1225
+ this.mainViewOriginalSources = null;
1226
+ this.inMainView = false;
1227
+ this.player.state.signLanguageInMainView = false;
1228
+ if (this.player.videoWrapper) {
1229
+ this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
1230
+ }
1231
+ this.player.emit('signlanguageinmainviewdisabled');
1232
+ }
1233
+ if (this.settingsMenuVisible) {
1234
+ this.hideSettingsMenu({ focusButton: false });
1235
+ }
1236
+
1237
+ // The panel owns its outside-click listener (bound to the
1238
+ // player's lifecycleSignal) and will drop that listener
1239
+ // automatically during player teardown. We still destroy the
1240
+ // panel here so its DOM is removed synchronously when the
1241
+ // wrapper goes away.
1242
+ if (this._panel) {
1243
+ this._panel.destroy();
1244
+ this._panel = null;
1245
+ }
1246
+
1247
+ // Remove settings handlers
1248
+ if (this.settingsHandlers && this.settingsButton) {
1249
+ this.settingsButton.removeEventListener('click', this.settingsHandlers.click);
1250
+ this.settingsButton.removeEventListener('keydown', this.settingsHandlers.keydown);
1251
+ }
1252
+ this.settingsHandlers = null;
1253
+
1254
+ // Remove event handlers
1255
+ if (this.handlers) {
1256
+ this.player.off('play', this.handlers.play);
1257
+ this.player.off('pause', this.handlers.pause);
1258
+ this.player.off('timeupdate', this.handlers.timeupdate);
1259
+ this.player.off('ratechange', this.handlers.ratechange);
1260
+ if (this.handlers.captionChange) {
1261
+ this.player.off('captionsenabled', this.handlers.captionChange);
1262
+ }
1263
+ this.handlers = null;
1264
+ }
1265
+
1266
+ // Remove custom key handler
1267
+ if (this.wrapper && this.customKeyHandler) {
1268
+ this.wrapper.removeEventListener('keydown', this.customKeyHandler);
1269
+ }
1270
+
1271
+ // Destroy draggable
1272
+ if (this.draggable) {
1273
+ if (this.draggable.pointerResizeMode) {
1274
+ this.draggable.disablePointerResizeMode();
1275
+ }
1276
+ this.draggable.destroy();
1277
+ this.draggable = null;
1278
+ }
1279
+
1280
+ this.interactionHandlers = null;
1281
+
1282
+ // Remove the mode badge before tearing down the wrapper, so the
1283
+ // reference doesn't outlive the DOM tree it belongs to.
1284
+ this._hideModeBadge();
1285
+
1286
+ // Remove video and wrapper
1287
+ if (this.wrapper?.parentNode) {
1288
+ if (this.video) {
1289
+ this.video.pause();
1290
+ this.video.src = '';
1291
+ }
1292
+ this.wrapper.parentNode.removeChild(this.wrapper);
1293
+ }
1294
+
1295
+ this.wrapper = null;
1296
+ this.video = null;
1297
+ this.settingsButton = null;
1298
+ }
1299
+
1300
+ /**
1301
+ * Destroy
1302
+ */
1303
+ destroy() {
1304
+ this.cleanup();
1305
+ this.enabled = false;
1306
+ }
1307
+ }
1308
+