vidply 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  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 +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.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
+