vidply 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
@@ -1,539 +1,539 @@
1
- /**
2
- * Owns the settings-menu lifecycle for a `DraggableResizable`-powered
3
- * floating panel: create, show, hide, outside-click dismissal, keyboard
4
- * navigation, viewport-aware positioning, and the drag-mode / resize-
5
- * mode toggle items.
6
- *
7
- * `SignLanguageManager` and `TranscriptManager` both present the same
8
- * pattern: a settings button that opens a menu containing a "Keyboard
9
- * drag mode" toggle, a "Resize mode" toggle, manager-specific extras,
10
- * and a close item. This class captures that pattern so each manager
11
- * only has to describe:
12
- * - how to resolve its `DraggableResizable` (for state readback),
13
- * - which i18n keys to use on the toggle items,
14
- * - where to place the menu in its own layout (via `getMenuParent`),
15
- * - how to toggle each mode (`onDragItemClick` / `onResizeItemClick`
16
- * run the manager's side effects — announcements, badges, focus),
17
- * - any extra menu items (`buildExtraItems`, e.g. style dialog,
18
- * timestamp toggle for TranscriptManager).
19
- *
20
- * The manager's own public API (`showSettingsMenu`, `toggleKeyboardDragMode`,
21
- * `hideSettingsMenu`, ...) is preserved by forwarding to this panel.
22
- * External code that reads `settingsMenuVisible` or the option-button
23
- * elements keeps working via getters on the manager that proxy the
24
- * panel's state.
25
- *
26
- * Event listeners attached to `document` are tied to the player's
27
- * `lifecycleSignal` so they are torn down automatically when the
28
- * player is destroyed.
29
- */
30
-
31
- import { DOMUtils } from './DOMUtils.js';
32
- import { i18n } from '../i18n/i18n.js';
33
- import {
34
- attachMenuKeyboardNavigation,
35
- createMenuItem,
36
- focusFirstMenuItem,
37
- } from './MenuUtils.js';
38
- import {
39
- positionSettingsMenu,
40
- positionSettingsMenuDeferred,
41
- updateToggleMenuItem,
42
- } from './DraggablePanelMenu.js';
43
-
44
- import type { Player } from '../core/Player.js';
45
- import type { DraggableResizable } from './DraggableResizable.js';
46
-
47
- /**
48
- * i18n keys used for the drag-mode and resize-mode toggle items and
49
- * the "Close" item. All values must be translation keys (passed
50
- * through `i18n.t()` by `createMenuItem`/`updateToggleMenuItem`).
51
- */
52
- export interface DraggablePanelI18nKeys {
53
- enableDrag: string;
54
- disableDrag: string;
55
- enableDragAria: string;
56
- disableDragAria: string;
57
- enableResize: string;
58
- disableResize: string;
59
- enableResizeAria: string;
60
- disableResizeAria: string;
61
- closeMenu: string;
62
- }
63
-
64
- export interface DraggablePanelExtrasContext {
65
- menu: HTMLElement;
66
- itemClass: string;
67
- classPrefix: string;
68
- /**
69
- * Strip the tooltip and duplicate `button-text` nodes from a menu
70
- * item. `createMenuItem` is shared with toolbar buttons that want a
71
- * hover tooltip; settings-menu rows already show the same text
72
- * inline, so the tooltip would cause screen readers to read the
73
- * label twice. Exposed here so manager-provided extras can reuse
74
- * the same stripping logic the panel applies to its own items.
75
- */
76
- stripInlineTooltip: (item: HTMLElement) => void;
77
- }
78
-
79
- export interface DraggablePanelOptions {
80
- player: Player;
81
- /**
82
- * Namespace for CSS classes, e.g. `'sign-language'` or `'transcript'`.
83
- * The final menu class is `{classPrefix}-{namespace}-settings-menu`
84
- * and item class is `{classPrefix}-{namespace}-settings-item`.
85
- */
86
- namespace: string;
87
- /** The button that opens the menu. */
88
- settingsButton: HTMLElement;
89
- /** Lazy draggable lookup (it may be re-created by the manager). */
90
- getDraggable: () => DraggableResizable | null;
91
- i18nKeys: DraggablePanelI18nKeys;
92
- /**
93
- * Horizontal anchoring of the menu relative to the settings button.
94
- * - `'left'` : align left edge (Transcript — header-row button).
95
- * - `'center'` : centre horizontally with viewport clamping (Sign
96
- * language — floating near viewport edges).
97
- */
98
- menuAlign: 'left' | 'center';
99
- /** Where to attach the menu node when first created. */
100
- getMenuParent: () => HTMLElement | null;
101
- /**
102
- * Called when the drag-mode item is clicked. The manager is
103
- * expected to toggle drag mode and run any side effects
104
- * (announcements, badges, focus). After this runs the panel will
105
- * refresh menu item state.
106
- */
107
- onDragItemClick: (panel: DraggablePanel) => void;
108
- /** Same for the resize-mode item. */
109
- onResizeItemClick: (panel: DraggablePanel) => void;
110
- /**
111
- * Insert manager-specific items (style dialog, timestamps, ...)
112
- * between the resize item and the close item.
113
- */
114
- buildExtraItems?: (ctx: DraggablePanelExtrasContext) => void;
115
- /** Optional: vertical gap (px) between button and menu. */
116
- menuGap?: number;
117
- /** Optional: breathing room (px) before the menu flips above. */
118
- menuSpaceReserve?: number;
119
- /**
120
- * Where to attach the mode-feedback badge while keyboard drag /
121
- * pointer resize mode is active. If omitted, {@link DraggablePanel.showBadge}
122
- * is a no-op (the manager effectively opts out of the badge).
123
- */
124
- getBadgeHost?: () => HTMLElement | null;
125
- /**
126
- * CSS class for the badge element. Defaults to
127
- * `{classPrefix}-{namespace}-mode-badge`. Sign-language passes a
128
- * shorter class (`vidply-sign-mode-badge`) to preserve existing
129
- * CSS. Transcript uses the default.
130
- */
131
- badgeClass?: string;
132
- }
133
-
134
- export class DraggablePanel {
135
- private readonly opts: DraggablePanelOptions;
136
-
137
- /** Populated lazily on first `show()`. */
138
- settingsMenu: HTMLElement | null = null;
139
- settingsMenuVisible = false;
140
- dragOptionButton: HTMLElement | null = null;
141
- dragOptionText: Element | null = null;
142
- resizeOptionButton: HTMLElement | null = null;
143
- resizeOptionText: Element | null = null;
144
-
145
- private _justOpened = false;
146
- private _justOpenedTimer: ReturnType<typeof setTimeout> | null = null;
147
- private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
148
- private _documentClick: ((e: MouseEvent) => void) | null = null;
149
- private _documentClickAdded = false;
150
- private _modeBadge: HTMLElement | null = null;
151
-
152
- constructor(opts: DraggablePanelOptions) {
153
- this.opts = opts;
154
- }
155
-
156
- /** True while the just-opened debounce window (prevents the same
157
- * click that opened the menu from also closing it via document
158
- * `mousedown` / `click`). */
159
- get justOpened(): boolean {
160
- return this._justOpened;
161
- }
162
-
163
- get classPrefix(): string {
164
- return this.opts.player.options.classPrefix;
165
- }
166
-
167
- get menuClass(): string {
168
- return `${this.classPrefix}-${this.opts.namespace}-settings-menu`;
169
- }
170
-
171
- get itemClass(): string {
172
- return `${this.classPrefix}-${this.opts.namespace}-settings-item`;
173
- }
174
-
175
- /**
176
- * Show the menu. First call creates the DOM; subsequent calls reuse
177
- * it. Refreshes menu item state from the current draggable.
178
- */
179
- show(): void {
180
- this._markJustOpened(350);
181
- this._ensureDocumentClickHandler();
182
-
183
- if (this.settingsMenu) {
184
- this.settingsMenu.style.display = 'block';
185
- this.settingsMenuVisible = true;
186
- this.opts.settingsButton.setAttribute('aria-expanded', 'true');
187
- this._attachKeyboardNavigation();
188
- this._positionImmediate();
189
- this.refreshState();
190
- focusFirstMenuItem(this.settingsMenu, `.${this.itemClass}`);
191
- return;
192
- }
193
-
194
- this._createMenu();
195
- }
196
-
197
- /**
198
- * Hide the menu. By default returns focus to the settings button;
199
- * callers can opt out when the next interaction should land
200
- * elsewhere (e.g. on the wrapper after enabling drag mode).
201
- */
202
- hide({ focusButton = true }: { focusButton?: boolean } = {}): void {
203
- if (!this.settingsMenu) return;
204
-
205
- this.settingsMenu.style.display = 'none';
206
- this.settingsMenuVisible = false;
207
- this._clearJustOpened();
208
-
209
- if (this._keyHandler) {
210
- this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
211
- this._keyHandler = null;
212
- }
213
-
214
- const items = this.settingsMenu.querySelectorAll<HTMLElement>(`.${this.itemClass}`);
215
- items.forEach((item) => item.setAttribute('tabindex', '-1'));
216
-
217
- const { settingsButton } = this.opts;
218
- settingsButton.setAttribute('aria-expanded', 'false');
219
- if (focusButton) {
220
- settingsButton.focus({ preventScroll: true });
221
- }
222
- }
223
-
224
- toggle(): void {
225
- if (this.settingsMenuVisible) {
226
- this.hide();
227
- } else {
228
- this.show();
229
- }
230
- }
231
-
232
- /** Set a short "just opened" guard so the document-click handler
233
- * attached for outside-dismissal ignores the originating click. */
234
- markJustOpenedForClick(): void {
235
- if (this._documentClick) {
236
- this._markJustOpened(100);
237
- }
238
- }
239
-
240
- /** Refresh the drag and resize toggle item state from the draggable. */
241
- refreshState(): void {
242
- this.refreshDragState();
243
- this.refreshResizeState();
244
- }
245
-
246
- refreshDragState(): void {
247
- const draggable = this.opts.getDraggable();
248
- updateToggleMenuItem(this.dragOptionButton, this.dragOptionText, {
249
- enabled: Boolean(draggable?.keyboardDragMode),
250
- enabledText: i18n.t(this.opts.i18nKeys.disableDrag),
251
- disabledText: i18n.t(this.opts.i18nKeys.enableDrag),
252
- enabledAria: i18n.t(this.opts.i18nKeys.disableDragAria),
253
- disabledAria: i18n.t(this.opts.i18nKeys.enableDragAria),
254
- });
255
- }
256
-
257
- refreshResizeState(): void {
258
- const draggable = this.opts.getDraggable();
259
- updateToggleMenuItem(this.resizeOptionButton, this.resizeOptionText, {
260
- enabled: Boolean(draggable?.pointerResizeMode),
261
- enabledText: i18n.t(this.opts.i18nKeys.disableResize),
262
- disabledText: i18n.t(this.opts.i18nKeys.enableResize),
263
- enabledAria: i18n.t(this.opts.i18nKeys.disableResizeAria),
264
- disabledAria: i18n.t(this.opts.i18nKeys.enableResizeAria),
265
- });
266
- }
267
-
268
- /**
269
- * Show a persistent mode-feedback badge (e.g. "Drag mode: arrow
270
- * keys to move, Esc to exit") anchored to the host element
271
- * returned by `getBadgeHost`. Replaces any previous badge. The
272
- * badge is a real DOM element (not a CSS pseudo-element) so its
273
- * text is translatable, selectable, visible under high-contrast
274
- * themes, and reflected in browser translation overlays.
275
- *
276
- * Marked `aria-hidden` because the accompanying live-region
277
- * announcement (the manager's responsibility) already conveys the
278
- * state change to assistive tech.
279
- */
280
- showBadge(text: string): void {
281
- const host = this.opts.getBadgeHost?.();
282
- if (!host) return;
283
- this.hideBadge();
284
-
285
- const className =
286
- this.opts.badgeClass ??
287
- `${this.classPrefix}-${this.opts.namespace}-mode-badge`;
288
- const badge = DOMUtils.createElement('span', {
289
- className,
290
- textContent: text,
291
- attributes: { 'aria-hidden': 'true' },
292
- });
293
- host.appendChild(badge);
294
- this._modeBadge = badge;
295
- }
296
-
297
- /** Remove the mode-feedback badge if one is showing. */
298
- hideBadge(): void {
299
- if (this._modeBadge && this._modeBadge.parentNode) {
300
- this._modeBadge.remove();
301
- }
302
- this._modeBadge = null;
303
- }
304
-
305
- /** RAF-deferred reposition (e.g. after a panel resize). */
306
- reposition(): void {
307
- positionSettingsMenuDeferred(this.settingsMenu, this.opts.settingsButton, {
308
- align: this.opts.menuAlign,
309
- gap: this.opts.menuGap ?? 4,
310
- spaceReserve: this.opts.menuSpaceReserve ?? 20,
311
- });
312
- }
313
-
314
- /**
315
- * Tear down any DOM/listeners owned by this panel. Safe to call
316
- * multiple times. Callers must still drop their own references.
317
- */
318
- destroy(): void {
319
- if (this._justOpenedTimer) {
320
- clearTimeout(this._justOpenedTimer);
321
- this._justOpenedTimer = null;
322
- }
323
- this._justOpened = false;
324
-
325
- this.hideBadge();
326
-
327
- if (this.settingsMenu) {
328
- if (this._keyHandler) {
329
- this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
330
- }
331
- this.settingsMenu.remove();
332
- this.settingsMenu = null;
333
- }
334
-
335
- this._keyHandler = null;
336
- this.settingsMenuVisible = false;
337
- this.dragOptionButton = null;
338
- this.dragOptionText = null;
339
- this.resizeOptionButton = null;
340
- this.resizeOptionText = null;
341
-
342
- // The document-click listener was attached with the player's
343
- // lifecycleSignal, so it is cleaned up automatically. We clear
344
- // the local reference so a later show() re-installs.
345
- this._documentClick = null;
346
- this._documentClickAdded = false;
347
- }
348
-
349
- private _createMenu(): void {
350
- const { player, settingsButton, i18nKeys } = this.opts;
351
-
352
- const menu = DOMUtils.createElement('div', {
353
- className: this.menuClass,
354
- attributes: { role: 'menu' },
355
- });
356
- this.settingsMenu = menu;
357
-
358
- const dragOption = createMenuItem({
359
- classPrefix: this.classPrefix,
360
- itemClass: this.itemClass,
361
- icon: 'move',
362
- label: i18nKeys.enableDrag,
363
- hasTextClass: true,
364
- onClick: (event) => {
365
- event.preventDefault();
366
- event.stopPropagation();
367
- this.opts.onDragItemClick(this);
368
- this.refreshState();
369
- },
370
- });
371
- dragOption.setAttribute('role', 'switch');
372
- dragOption.setAttribute('aria-checked', 'false');
373
- dragOption.setAttribute('data-setting', 'keyboard-drag');
374
- this._stripInlineTooltip(dragOption);
375
- this.dragOptionButton = dragOption;
376
- this.dragOptionText = dragOption.querySelector(`.${this.classPrefix}-settings-text`);
377
-
378
- const resizeOption = createMenuItem({
379
- classPrefix: this.classPrefix,
380
- itemClass: this.itemClass,
381
- icon: 'resize',
382
- label: i18nKeys.enableResize,
383
- hasTextClass: true,
384
- onClick: (event) => {
385
- event.preventDefault();
386
- event.stopPropagation();
387
- this.opts.onResizeItemClick(this);
388
- this.refreshState();
389
- },
390
- });
391
- resizeOption.setAttribute('role', 'switch');
392
- resizeOption.setAttribute('aria-checked', 'false');
393
- this._stripInlineTooltip(resizeOption);
394
- this.resizeOptionButton = resizeOption;
395
- this.resizeOptionText = resizeOption.querySelector(`.${this.classPrefix}-settings-text`);
396
-
397
- menu.appendChild(dragOption);
398
- menu.appendChild(resizeOption);
399
-
400
- if (this.opts.buildExtraItems) {
401
- this.opts.buildExtraItems({
402
- menu,
403
- itemClass: this.itemClass,
404
- classPrefix: this.classPrefix,
405
- stripInlineTooltip: (item) => this._stripInlineTooltip(item),
406
- });
407
- }
408
-
409
- const closeOption = createMenuItem({
410
- classPrefix: this.classPrefix,
411
- itemClass: this.itemClass,
412
- icon: 'close',
413
- label: i18nKeys.closeMenu,
414
- onClick: () => this.hide(),
415
- });
416
- this._stripInlineTooltip(closeOption);
417
- menu.appendChild(closeOption);
418
-
419
- menu.style.visibility = 'hidden';
420
- menu.style.display = 'block';
421
-
422
- const parent = this.opts.getMenuParent();
423
- if (settingsButton.parentNode) {
424
- settingsButton.insertAdjacentElement('afterend', menu);
425
- } else if (parent) {
426
- parent.appendChild(menu);
427
- }
428
-
429
- this._positionImmediate();
430
-
431
- requestAnimationFrame(() => {
432
- if (this.settingsMenu) {
433
- this.settingsMenu.style.visibility = 'visible';
434
- }
435
- });
436
-
437
- this._attachKeyboardNavigation();
438
-
439
- this.settingsMenuVisible = true;
440
- settingsButton.setAttribute('aria-expanded', 'true');
441
- this.refreshState();
442
-
443
- focusFirstMenuItem(menu, `.${this.itemClass}`);
444
-
445
- // Don't reveal the fact that we created the menu to callers that
446
- // didn't trigger show() explicitly — but this is always driven
447
- // by a show() so the `player` reference is just for
448
- // documentation clarity here.
449
- void player;
450
- }
451
-
452
- private _attachKeyboardNavigation(): void {
453
- const menu = this.settingsMenu;
454
- if (!menu) return;
455
- if (this._keyHandler) {
456
- menu.removeEventListener('keydown', this._keyHandler, true);
457
- }
458
- const handler = attachMenuKeyboardNavigation(
459
- menu,
460
- this.opts.settingsButton,
461
- `.${this.itemClass}`,
462
- () => this.hide({ focusButton: true })
463
- );
464
- this._keyHandler = handler ?? null;
465
- }
466
-
467
- private _positionImmediate(): void {
468
- positionSettingsMenu(this.settingsMenu, this.opts.settingsButton, {
469
- align: this.opts.menuAlign,
470
- gap: this.opts.menuGap ?? 4,
471
- spaceReserve: this.opts.menuSpaceReserve ?? 20,
472
- });
473
- }
474
-
475
- /**
476
- * Remove tooltip and duplicate button-text nodes from a menu item.
477
- * `createMenuItem` is used both for toolbar buttons (which want a
478
- * tooltip) and for settings-menu rows (which show the same text
479
- * inline). This strips the duplicated pieces so screen readers
480
- * don't read the label twice.
481
- */
482
- private _stripInlineTooltip(item: HTMLElement): void {
483
- const tooltip = item.querySelector(`.${this.classPrefix}-tooltip`);
484
- if (tooltip) tooltip.remove();
485
- const buttonText = item.querySelector(`.${this.classPrefix}-button-text`);
486
- if (buttonText) buttonText.remove();
487
- }
488
-
489
- private _markJustOpened(durationMs: number): void {
490
- this._justOpened = true;
491
- if (this._justOpenedTimer) {
492
- clearTimeout(this._justOpenedTimer);
493
- }
494
- this._justOpenedTimer = setTimeout(() => {
495
- this._justOpened = false;
496
- this._justOpenedTimer = null;
497
- }, durationMs);
498
- }
499
-
500
- private _clearJustOpened(): void {
501
- this._justOpened = false;
502
- if (this._justOpenedTimer) {
503
- clearTimeout(this._justOpenedTimer);
504
- this._justOpenedTimer = null;
505
- }
506
- }
507
-
508
- private _ensureDocumentClickHandler(): void {
509
- if (this._documentClickAdded) return;
510
-
511
- this._documentClick = (event: MouseEvent) => {
512
- if (this._justOpened) return;
513
- const target = event.target as Node | null;
514
- const { settingsButton } = this.opts;
515
-
516
- if (settingsButton === target || (target && settingsButton.contains(target))) {
517
- return;
518
- }
519
- if (this.settingsMenu && target && this.settingsMenu.contains(target)) {
520
- return;
521
- }
522
- if (this.settingsMenuVisible) {
523
- this.hide();
524
- }
525
- };
526
-
527
- // Delay attach so the click that opened the menu doesn't instantly
528
- // close it even if the justOpened flag somehow races.
529
- setTimeout(() => {
530
- const handler = this._documentClick;
531
- if (!handler) return;
532
- document.addEventListener('mousedown', handler, {
533
- capture: true,
534
- signal: this.opts.player.lifecycleSignal,
535
- });
536
- this._documentClickAdded = true;
537
- }, 300);
538
- }
539
- }
1
+ /**
2
+ * Owns the settings-menu lifecycle for a `DraggableResizable`-powered
3
+ * floating panel: create, show, hide, outside-click dismissal, keyboard
4
+ * navigation, viewport-aware positioning, and the drag-mode / resize-
5
+ * mode toggle items.
6
+ *
7
+ * `SignLanguageManager` and `TranscriptManager` both present the same
8
+ * pattern: a settings button that opens a menu containing a "Keyboard
9
+ * drag mode" toggle, a "Resize mode" toggle, manager-specific extras,
10
+ * and a close item. This class captures that pattern so each manager
11
+ * only has to describe:
12
+ * - how to resolve its `DraggableResizable` (for state readback),
13
+ * - which i18n keys to use on the toggle items,
14
+ * - where to place the menu in its own layout (via `getMenuParent`),
15
+ * - how to toggle each mode (`onDragItemClick` / `onResizeItemClick`
16
+ * run the manager's side effects — announcements, badges, focus),
17
+ * - any extra menu items (`buildExtraItems`, e.g. style dialog,
18
+ * timestamp toggle for TranscriptManager).
19
+ *
20
+ * The manager's own public API (`showSettingsMenu`, `toggleKeyboardDragMode`,
21
+ * `hideSettingsMenu`, ...) is preserved by forwarding to this panel.
22
+ * External code that reads `settingsMenuVisible` or the option-button
23
+ * elements keeps working via getters on the manager that proxy the
24
+ * panel's state.
25
+ *
26
+ * Event listeners attached to `document` are tied to the player's
27
+ * `lifecycleSignal` so they are torn down automatically when the
28
+ * player is destroyed.
29
+ */
30
+
31
+ import { DOMUtils } from './DOMUtils.js';
32
+ import { i18n } from '../i18n/i18n.js';
33
+ import {
34
+ attachMenuKeyboardNavigation,
35
+ createMenuItem,
36
+ focusFirstMenuItem,
37
+ } from './MenuUtils.js';
38
+ import {
39
+ positionSettingsMenu,
40
+ positionSettingsMenuDeferred,
41
+ updateToggleMenuItem,
42
+ } from './DraggablePanelMenu.js';
43
+
44
+ import type { Player } from '../core/Player.js';
45
+ import type { DraggableResizable } from './DraggableResizable.js';
46
+
47
+ /**
48
+ * i18n keys used for the drag-mode and resize-mode toggle items and
49
+ * the "Close" item. All values must be translation keys (passed
50
+ * through `i18n.t()` by `createMenuItem`/`updateToggleMenuItem`).
51
+ */
52
+ export interface DraggablePanelI18nKeys {
53
+ enableDrag: string;
54
+ disableDrag: string;
55
+ enableDragAria: string;
56
+ disableDragAria: string;
57
+ enableResize: string;
58
+ disableResize: string;
59
+ enableResizeAria: string;
60
+ disableResizeAria: string;
61
+ closeMenu: string;
62
+ }
63
+
64
+ export interface DraggablePanelExtrasContext {
65
+ menu: HTMLElement;
66
+ itemClass: string;
67
+ classPrefix: string;
68
+ /**
69
+ * Strip the tooltip and duplicate `button-text` nodes from a menu
70
+ * item. `createMenuItem` is shared with toolbar buttons that want a
71
+ * hover tooltip; settings-menu rows already show the same text
72
+ * inline, so the tooltip would cause screen readers to read the
73
+ * label twice. Exposed here so manager-provided extras can reuse
74
+ * the same stripping logic the panel applies to its own items.
75
+ */
76
+ stripInlineTooltip: (item: HTMLElement) => void;
77
+ }
78
+
79
+ export interface DraggablePanelOptions {
80
+ player: Player;
81
+ /**
82
+ * Namespace for CSS classes, e.g. `'sign-language'` or `'transcript'`.
83
+ * The final menu class is `{classPrefix}-{namespace}-settings-menu`
84
+ * and item class is `{classPrefix}-{namespace}-settings-item`.
85
+ */
86
+ namespace: string;
87
+ /** The button that opens the menu. */
88
+ settingsButton: HTMLElement;
89
+ /** Lazy draggable lookup (it may be re-created by the manager). */
90
+ getDraggable: () => DraggableResizable | null;
91
+ i18nKeys: DraggablePanelI18nKeys;
92
+ /**
93
+ * Horizontal anchoring of the menu relative to the settings button.
94
+ * - `'left'` : align left edge (Transcript — header-row button).
95
+ * - `'center'` : centre horizontally with viewport clamping (Sign
96
+ * language — floating near viewport edges).
97
+ */
98
+ menuAlign: 'left' | 'center';
99
+ /** Where to attach the menu node when first created. */
100
+ getMenuParent: () => HTMLElement | null;
101
+ /**
102
+ * Called when the drag-mode item is clicked. The manager is
103
+ * expected to toggle drag mode and run any side effects
104
+ * (announcements, badges, focus). After this runs the panel will
105
+ * refresh menu item state.
106
+ */
107
+ onDragItemClick: (panel: DraggablePanel) => void;
108
+ /** Same for the resize-mode item. */
109
+ onResizeItemClick: (panel: DraggablePanel) => void;
110
+ /**
111
+ * Insert manager-specific items (style dialog, timestamps, ...)
112
+ * between the resize item and the close item.
113
+ */
114
+ buildExtraItems?: (ctx: DraggablePanelExtrasContext) => void;
115
+ /** Optional: vertical gap (px) between button and menu. */
116
+ menuGap?: number;
117
+ /** Optional: breathing room (px) before the menu flips above. */
118
+ menuSpaceReserve?: number;
119
+ /**
120
+ * Where to attach the mode-feedback badge while keyboard drag /
121
+ * pointer resize mode is active. If omitted, {@link DraggablePanel.showBadge}
122
+ * is a no-op (the manager effectively opts out of the badge).
123
+ */
124
+ getBadgeHost?: () => HTMLElement | null;
125
+ /**
126
+ * CSS class for the badge element. Defaults to
127
+ * `{classPrefix}-{namespace}-mode-badge`. Sign-language passes a
128
+ * shorter class (`vidply-sign-mode-badge`) to preserve existing
129
+ * CSS. Transcript uses the default.
130
+ */
131
+ badgeClass?: string;
132
+ }
133
+
134
+ export class DraggablePanel {
135
+ private readonly opts: DraggablePanelOptions;
136
+
137
+ /** Populated lazily on first `show()`. */
138
+ settingsMenu: HTMLElement | null = null;
139
+ settingsMenuVisible = false;
140
+ dragOptionButton: HTMLElement | null = null;
141
+ dragOptionText: Element | null = null;
142
+ resizeOptionButton: HTMLElement | null = null;
143
+ resizeOptionText: Element | null = null;
144
+
145
+ private _justOpened = false;
146
+ private _justOpenedTimer: ReturnType<typeof setTimeout> | null = null;
147
+ private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
148
+ private _documentClick: ((e: MouseEvent) => void) | null = null;
149
+ private _documentClickAdded = false;
150
+ private _modeBadge: HTMLElement | null = null;
151
+
152
+ constructor(opts: DraggablePanelOptions) {
153
+ this.opts = opts;
154
+ }
155
+
156
+ /** True while the just-opened debounce window (prevents the same
157
+ * click that opened the menu from also closing it via document
158
+ * `mousedown` / `click`). */
159
+ get justOpened(): boolean {
160
+ return this._justOpened;
161
+ }
162
+
163
+ get classPrefix(): string {
164
+ return this.opts.player.options.classPrefix;
165
+ }
166
+
167
+ get menuClass(): string {
168
+ return `${this.classPrefix}-${this.opts.namespace}-settings-menu`;
169
+ }
170
+
171
+ get itemClass(): string {
172
+ return `${this.classPrefix}-${this.opts.namespace}-settings-item`;
173
+ }
174
+
175
+ /**
176
+ * Show the menu. First call creates the DOM; subsequent calls reuse
177
+ * it. Refreshes menu item state from the current draggable.
178
+ */
179
+ show(): void {
180
+ this._markJustOpened(350);
181
+ this._ensureDocumentClickHandler();
182
+
183
+ if (this.settingsMenu) {
184
+ this.settingsMenu.style.display = 'block';
185
+ this.settingsMenuVisible = true;
186
+ this.opts.settingsButton.setAttribute('aria-expanded', 'true');
187
+ this._attachKeyboardNavigation();
188
+ this._positionImmediate();
189
+ this.refreshState();
190
+ focusFirstMenuItem(this.settingsMenu, `.${this.itemClass}`);
191
+ return;
192
+ }
193
+
194
+ this._createMenu();
195
+ }
196
+
197
+ /**
198
+ * Hide the menu. By default returns focus to the settings button;
199
+ * callers can opt out when the next interaction should land
200
+ * elsewhere (e.g. on the wrapper after enabling drag mode).
201
+ */
202
+ hide({ focusButton = true }: { focusButton?: boolean } = {}): void {
203
+ if (!this.settingsMenu) return;
204
+
205
+ this.settingsMenu.style.display = 'none';
206
+ this.settingsMenuVisible = false;
207
+ this._clearJustOpened();
208
+
209
+ if (this._keyHandler) {
210
+ this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
211
+ this._keyHandler = null;
212
+ }
213
+
214
+ const items = this.settingsMenu.querySelectorAll<HTMLElement>(`.${this.itemClass}`);
215
+ items.forEach((item) => item.setAttribute('tabindex', '-1'));
216
+
217
+ const { settingsButton } = this.opts;
218
+ settingsButton.setAttribute('aria-expanded', 'false');
219
+ if (focusButton) {
220
+ settingsButton.focus({ preventScroll: true });
221
+ }
222
+ }
223
+
224
+ toggle(): void {
225
+ if (this.settingsMenuVisible) {
226
+ this.hide();
227
+ } else {
228
+ this.show();
229
+ }
230
+ }
231
+
232
+ /** Set a short "just opened" guard so the document-click handler
233
+ * attached for outside-dismissal ignores the originating click. */
234
+ markJustOpenedForClick(): void {
235
+ if (this._documentClick) {
236
+ this._markJustOpened(100);
237
+ }
238
+ }
239
+
240
+ /** Refresh the drag and resize toggle item state from the draggable. */
241
+ refreshState(): void {
242
+ this.refreshDragState();
243
+ this.refreshResizeState();
244
+ }
245
+
246
+ refreshDragState(): void {
247
+ const draggable = this.opts.getDraggable();
248
+ updateToggleMenuItem(this.dragOptionButton, this.dragOptionText, {
249
+ enabled: Boolean(draggable?.keyboardDragMode),
250
+ enabledText: i18n.t(this.opts.i18nKeys.disableDrag),
251
+ disabledText: i18n.t(this.opts.i18nKeys.enableDrag),
252
+ enabledAria: i18n.t(this.opts.i18nKeys.disableDragAria),
253
+ disabledAria: i18n.t(this.opts.i18nKeys.enableDragAria),
254
+ });
255
+ }
256
+
257
+ refreshResizeState(): void {
258
+ const draggable = this.opts.getDraggable();
259
+ updateToggleMenuItem(this.resizeOptionButton, this.resizeOptionText, {
260
+ enabled: Boolean(draggable?.pointerResizeMode),
261
+ enabledText: i18n.t(this.opts.i18nKeys.disableResize),
262
+ disabledText: i18n.t(this.opts.i18nKeys.enableResize),
263
+ enabledAria: i18n.t(this.opts.i18nKeys.disableResizeAria),
264
+ disabledAria: i18n.t(this.opts.i18nKeys.enableResizeAria),
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Show a persistent mode-feedback badge (e.g. "Drag mode: arrow
270
+ * keys to move, Esc to exit") anchored to the host element
271
+ * returned by `getBadgeHost`. Replaces any previous badge. The
272
+ * badge is a real DOM element (not a CSS pseudo-element) so its
273
+ * text is translatable, selectable, visible under high-contrast
274
+ * themes, and reflected in browser translation overlays.
275
+ *
276
+ * Marked `aria-hidden` because the accompanying live-region
277
+ * announcement (the manager's responsibility) already conveys the
278
+ * state change to assistive tech.
279
+ */
280
+ showBadge(text: string): void {
281
+ const host = this.opts.getBadgeHost?.();
282
+ if (!host) return;
283
+ this.hideBadge();
284
+
285
+ const className =
286
+ this.opts.badgeClass ??
287
+ `${this.classPrefix}-${this.opts.namespace}-mode-badge`;
288
+ const badge = DOMUtils.createElement('span', {
289
+ className,
290
+ textContent: text,
291
+ attributes: { 'aria-hidden': 'true' },
292
+ });
293
+ host.appendChild(badge);
294
+ this._modeBadge = badge;
295
+ }
296
+
297
+ /** Remove the mode-feedback badge if one is showing. */
298
+ hideBadge(): void {
299
+ if (this._modeBadge && this._modeBadge.parentNode) {
300
+ this._modeBadge.remove();
301
+ }
302
+ this._modeBadge = null;
303
+ }
304
+
305
+ /** RAF-deferred reposition (e.g. after a panel resize). */
306
+ reposition(): void {
307
+ positionSettingsMenuDeferred(this.settingsMenu, this.opts.settingsButton, {
308
+ align: this.opts.menuAlign,
309
+ gap: this.opts.menuGap ?? 4,
310
+ spaceReserve: this.opts.menuSpaceReserve ?? 20,
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Tear down any DOM/listeners owned by this panel. Safe to call
316
+ * multiple times. Callers must still drop their own references.
317
+ */
318
+ destroy(): void {
319
+ if (this._justOpenedTimer) {
320
+ clearTimeout(this._justOpenedTimer);
321
+ this._justOpenedTimer = null;
322
+ }
323
+ this._justOpened = false;
324
+
325
+ this.hideBadge();
326
+
327
+ if (this.settingsMenu) {
328
+ if (this._keyHandler) {
329
+ this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
330
+ }
331
+ this.settingsMenu.remove();
332
+ this.settingsMenu = null;
333
+ }
334
+
335
+ this._keyHandler = null;
336
+ this.settingsMenuVisible = false;
337
+ this.dragOptionButton = null;
338
+ this.dragOptionText = null;
339
+ this.resizeOptionButton = null;
340
+ this.resizeOptionText = null;
341
+
342
+ // The document-click listener was attached with the player's
343
+ // lifecycleSignal, so it is cleaned up automatically. We clear
344
+ // the local reference so a later show() re-installs.
345
+ this._documentClick = null;
346
+ this._documentClickAdded = false;
347
+ }
348
+
349
+ private _createMenu(): void {
350
+ const { player, settingsButton, i18nKeys } = this.opts;
351
+
352
+ const menu = DOMUtils.createElement('div', {
353
+ className: this.menuClass,
354
+ attributes: { role: 'menu' },
355
+ });
356
+ this.settingsMenu = menu;
357
+
358
+ const dragOption = createMenuItem({
359
+ classPrefix: this.classPrefix,
360
+ itemClass: this.itemClass,
361
+ icon: 'move',
362
+ label: i18nKeys.enableDrag,
363
+ hasTextClass: true,
364
+ onClick: (event) => {
365
+ event.preventDefault();
366
+ event.stopPropagation();
367
+ this.opts.onDragItemClick(this);
368
+ this.refreshState();
369
+ },
370
+ });
371
+ dragOption.setAttribute('role', 'switch');
372
+ dragOption.setAttribute('aria-checked', 'false');
373
+ dragOption.setAttribute('data-setting', 'keyboard-drag');
374
+ this._stripInlineTooltip(dragOption);
375
+ this.dragOptionButton = dragOption;
376
+ this.dragOptionText = dragOption.querySelector(`.${this.classPrefix}-settings-text`);
377
+
378
+ const resizeOption = createMenuItem({
379
+ classPrefix: this.classPrefix,
380
+ itemClass: this.itemClass,
381
+ icon: 'resize',
382
+ label: i18nKeys.enableResize,
383
+ hasTextClass: true,
384
+ onClick: (event) => {
385
+ event.preventDefault();
386
+ event.stopPropagation();
387
+ this.opts.onResizeItemClick(this);
388
+ this.refreshState();
389
+ },
390
+ });
391
+ resizeOption.setAttribute('role', 'switch');
392
+ resizeOption.setAttribute('aria-checked', 'false');
393
+ this._stripInlineTooltip(resizeOption);
394
+ this.resizeOptionButton = resizeOption;
395
+ this.resizeOptionText = resizeOption.querySelector(`.${this.classPrefix}-settings-text`);
396
+
397
+ menu.appendChild(dragOption);
398
+ menu.appendChild(resizeOption);
399
+
400
+ if (this.opts.buildExtraItems) {
401
+ this.opts.buildExtraItems({
402
+ menu,
403
+ itemClass: this.itemClass,
404
+ classPrefix: this.classPrefix,
405
+ stripInlineTooltip: (item) => this._stripInlineTooltip(item),
406
+ });
407
+ }
408
+
409
+ const closeOption = createMenuItem({
410
+ classPrefix: this.classPrefix,
411
+ itemClass: this.itemClass,
412
+ icon: 'close',
413
+ label: i18nKeys.closeMenu,
414
+ onClick: () => this.hide(),
415
+ });
416
+ this._stripInlineTooltip(closeOption);
417
+ menu.appendChild(closeOption);
418
+
419
+ menu.style.visibility = 'hidden';
420
+ menu.style.display = 'block';
421
+
422
+ const parent = this.opts.getMenuParent();
423
+ if (settingsButton.parentNode) {
424
+ settingsButton.insertAdjacentElement('afterend', menu);
425
+ } else if (parent) {
426
+ parent.appendChild(menu);
427
+ }
428
+
429
+ this._positionImmediate();
430
+
431
+ requestAnimationFrame(() => {
432
+ if (this.settingsMenu) {
433
+ this.settingsMenu.style.visibility = 'visible';
434
+ }
435
+ });
436
+
437
+ this._attachKeyboardNavigation();
438
+
439
+ this.settingsMenuVisible = true;
440
+ settingsButton.setAttribute('aria-expanded', 'true');
441
+ this.refreshState();
442
+
443
+ focusFirstMenuItem(menu, `.${this.itemClass}`);
444
+
445
+ // Don't reveal the fact that we created the menu to callers that
446
+ // didn't trigger show() explicitly — but this is always driven
447
+ // by a show() so the `player` reference is just for
448
+ // documentation clarity here.
449
+ void player;
450
+ }
451
+
452
+ private _attachKeyboardNavigation(): void {
453
+ const menu = this.settingsMenu;
454
+ if (!menu) return;
455
+ if (this._keyHandler) {
456
+ menu.removeEventListener('keydown', this._keyHandler, true);
457
+ }
458
+ const handler = attachMenuKeyboardNavigation(
459
+ menu,
460
+ this.opts.settingsButton,
461
+ `.${this.itemClass}`,
462
+ () => this.hide({ focusButton: true })
463
+ );
464
+ this._keyHandler = handler ?? null;
465
+ }
466
+
467
+ private _positionImmediate(): void {
468
+ positionSettingsMenu(this.settingsMenu, this.opts.settingsButton, {
469
+ align: this.opts.menuAlign,
470
+ gap: this.opts.menuGap ?? 4,
471
+ spaceReserve: this.opts.menuSpaceReserve ?? 20,
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Remove tooltip and duplicate button-text nodes from a menu item.
477
+ * `createMenuItem` is used both for toolbar buttons (which want a
478
+ * tooltip) and for settings-menu rows (which show the same text
479
+ * inline). This strips the duplicated pieces so screen readers
480
+ * don't read the label twice.
481
+ */
482
+ private _stripInlineTooltip(item: HTMLElement): void {
483
+ const tooltip = item.querySelector(`.${this.classPrefix}-tooltip`);
484
+ if (tooltip) tooltip.remove();
485
+ const buttonText = item.querySelector(`.${this.classPrefix}-button-text`);
486
+ if (buttonText) buttonText.remove();
487
+ }
488
+
489
+ private _markJustOpened(durationMs: number): void {
490
+ this._justOpened = true;
491
+ if (this._justOpenedTimer) {
492
+ clearTimeout(this._justOpenedTimer);
493
+ }
494
+ this._justOpenedTimer = setTimeout(() => {
495
+ this._justOpened = false;
496
+ this._justOpenedTimer = null;
497
+ }, durationMs);
498
+ }
499
+
500
+ private _clearJustOpened(): void {
501
+ this._justOpened = false;
502
+ if (this._justOpenedTimer) {
503
+ clearTimeout(this._justOpenedTimer);
504
+ this._justOpenedTimer = null;
505
+ }
506
+ }
507
+
508
+ private _ensureDocumentClickHandler(): void {
509
+ if (this._documentClickAdded) return;
510
+
511
+ this._documentClick = (event: MouseEvent) => {
512
+ if (this._justOpened) return;
513
+ const target = event.target as Node | null;
514
+ const { settingsButton } = this.opts;
515
+
516
+ if (settingsButton === target || (target && settingsButton.contains(target))) {
517
+ return;
518
+ }
519
+ if (this.settingsMenu && target && this.settingsMenu.contains(target)) {
520
+ return;
521
+ }
522
+ if (this.settingsMenuVisible) {
523
+ this.hide();
524
+ }
525
+ };
526
+
527
+ // Delay attach so the click that opened the menu doesn't instantly
528
+ // close it even if the justOpened flag somehow races.
529
+ setTimeout(() => {
530
+ const handler = this._documentClick;
531
+ if (!handler) return;
532
+ document.addEventListener('mousedown', handler, {
533
+ capture: true,
534
+ signal: this.opts.player.lifecycleSignal,
535
+ });
536
+ this._documentClickAdded = true;
537
+ }, 300);
538
+ }
539
+ }