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,483 +1,483 @@
1
- /**
2
- * Menu Factory - Centralized menu creation and management
3
- * Reduces code duplication across ControlBar menu methods
4
- */
5
-
6
- import { DOMUtils } from './DOMUtils.js';
7
- import { createIconElement } from '../icons/Icons.js';
8
- import { i18n } from '../i18n/i18n.js';
9
- import { attachMenuKeyboardNavigation } from './MenuUtils.js';
10
- import type { Player } from '../core/Player.js';
11
-
12
- interface DividerMenuItem {
13
- type: 'divider';
14
- }
15
-
16
- interface HeaderMenuItem {
17
- type: 'header';
18
- text: string;
19
- }
20
-
21
- interface ActionMenuItem {
22
- type?: 'action';
23
- text?: string;
24
- value?: unknown;
25
- active?: boolean;
26
- disabled?: boolean;
27
- icon?: string;
28
- textClass?: string;
29
- timeLabel?: string;
30
- timeAriaLabel?: string;
31
- onClick?: (value: unknown, index: number) => void;
32
- }
33
-
34
- type MenuItem = DividerMenuItem | HeaderMenuItem | ActionMenuItem;
35
-
36
- interface CreateMenuOptions {
37
- player: Player;
38
- button: HTMLElement;
39
- menuClass: string;
40
- ariaLabel: string;
41
- items?: MenuItem[];
42
- activeIndex?: number;
43
- onClose?: (() => void) | null;
44
- // When true, actionable items represent a single-select group and use
45
- // role="menuitemradio" + aria-checked so assistive tech announces the
46
- // current selection, not just a visual check icon (WCAG 4.1.2).
47
- radioGroup?: boolean;
48
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
49
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
50
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
51
- }
52
-
53
- interface SpeedMenuOptions {
54
- player: Player;
55
- button: HTMLElement;
56
- currentSpeed: number;
57
- speeds?: number[];
58
- onSpeedChange: (value: number) => void;
59
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
60
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
61
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
62
- }
63
-
64
- interface CaptionsMenuOptions {
65
- player: Player;
66
- button: HTMLElement;
67
- tracks: Array<{ label?: string; language?: string }>;
68
- currentTrackIndex: number;
69
- captionsEnabled: boolean;
70
- onTrackSelect: (index: number) => void;
71
- onDisable: () => void;
72
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
73
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
74
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
75
- }
76
-
77
- interface ChaptersMenuOptions {
78
- player: Player;
79
- button: HTMLElement;
80
- chapters: Array<{ startTime: number; text: string }> | null | undefined;
81
- onChapterSelect: (value: number) => void;
82
- formatTime: (seconds: number) => string;
83
- formatDuration: (seconds: number) => string;
84
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
85
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
86
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
87
- }
88
-
89
- interface QualityMenuOptions {
90
- player: Player;
91
- button: HTMLElement;
92
- qualities: Array<{ name?: string; height?: number; index: number }>;
93
- currentQuality: number;
94
- isHLS: boolean;
95
- onQualitySelect: (index: number) => void;
96
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
97
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
98
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
99
- }
100
-
101
- /**
102
- * Create and show a menu with standard positioning and behavior
103
- * @param {Object} options - Menu configuration
104
- * @returns {HTMLElement} The created menu element
105
- */
106
- export function createMenu({
107
- player,
108
- button,
109
- menuClass,
110
- ariaLabel,
111
- items = [],
112
- activeIndex = -1,
113
- onClose = null,
114
- radioGroup = false,
115
- insertIntoDOM = null,
116
- positionMenu = null,
117
- attachCloseHandler = null
118
- }: CreateMenuOptions): HTMLElement | null {
119
- const classPrefix = player.options.classPrefix;
120
-
121
- // Remove existing menu (toggle behavior)
122
- const existingMenu = document.querySelector(`.${classPrefix}-${menuClass}`);
123
- if (existingMenu) {
124
- existingMenu.remove();
125
- button.setAttribute('aria-expanded', 'false');
126
- return null;
127
- }
128
-
129
- // Create menu container
130
- const menu = DOMUtils.createElement('div', {
131
- className: `${classPrefix}-${menuClass} ${classPrefix}-menu`,
132
- attributes: {
133
- 'role': 'menu',
134
- 'aria-label': ariaLabel
135
- }
136
- });
137
-
138
- let activeItem: HTMLElement | null = null;
139
-
140
- // Create menu items
141
- items.forEach((itemConfig, index) => {
142
- if (itemConfig.type === 'divider') {
143
- const divider = DOMUtils.createElement('div', {
144
- className: `${classPrefix}-menu-divider`,
145
- attributes: { 'role': 'separator' }
146
- });
147
- menu.appendChild(divider);
148
- return;
149
- }
150
-
151
- if (itemConfig.type === 'header') {
152
- const header = DOMUtils.createElement('div', {
153
- className: `${classPrefix}-menu-header`,
154
- textContent: itemConfig.text
155
- });
156
- menu.appendChild(header);
157
- return;
158
- }
159
-
160
- if ('disabled' in itemConfig && itemConfig.disabled) {
161
- // Empty-state text ("No chapters", "Auto quality"). It is not
162
- // actionable, so expose it as aria-disabled and keep it out of the
163
- // roving keyboard navigation (see attachMenuKeyboardNavigation),
164
- // which only steps through enabled menuitems (WCAG 2.1.1, 4.1.2).
165
- const disabledItem = DOMUtils.createElement('div', {
166
- className: `${classPrefix}-menu-item ${classPrefix}-menu-item-disabled`,
167
- textContent: itemConfig.text,
168
- attributes: {
169
- 'role': 'menuitem',
170
- 'aria-disabled': 'true',
171
- 'tabindex': '-1'
172
- },
173
- style: { opacity: '0.5', cursor: 'default' }
174
- });
175
- menu.appendChild(disabledItem);
176
- return;
177
- }
178
-
179
- // Add content based on item type
180
- const actionItem = itemConfig as ActionMenuItem;
181
- const isActive = actionItem.active || index === activeIndex;
182
-
183
- const itemAttributes: Record<string, string> = {
184
- 'type': 'button',
185
- 'role': radioGroup ? 'menuitemradio' : 'menuitem',
186
- 'tabindex': '-1'
187
- };
188
- if (radioGroup) {
189
- itemAttributes['aria-checked'] = isActive ? 'true' : 'false';
190
- }
191
-
192
- const item = DOMUtils.createElement('button', {
193
- className: `${classPrefix}-menu-item`,
194
- attributes: itemAttributes
195
- });
196
-
197
- if (actionItem.icon) {
198
- item.appendChild(createIconElement(actionItem.icon));
199
- }
200
-
201
- if (actionItem.timeLabel) {
202
- const timeSpan = DOMUtils.createElement('span', {
203
- className: `${classPrefix}-chapter-time`,
204
- textContent: actionItem.timeLabel,
205
- attributes: actionItem.timeAriaLabel
206
- ? { 'aria-label': actionItem.timeAriaLabel }
207
- : {}
208
- });
209
- item.appendChild(timeSpan);
210
- item.appendChild(document.createTextNode(' '));
211
- }
212
-
213
- if (actionItem.text) {
214
- const textSpan = DOMUtils.createElement('span', {
215
- className: actionItem.textClass || `${classPrefix}-menu-item-text`,
216
- textContent: actionItem.text
217
- });
218
- item.appendChild(textSpan);
219
- }
220
-
221
- // Mark as active
222
- if (isActive) {
223
- item.classList.add(`${classPrefix}-menu-item-active`);
224
- item.appendChild(createIconElement('check'));
225
- activeItem = item;
226
- }
227
-
228
- // Click handler
229
- const onClick = actionItem.onClick;
230
- if (onClick) {
231
- item.addEventListener('click', () => {
232
- onClick(actionItem.value, index);
233
- closeMenuAndReturnFocus(menu, button, onClose);
234
- });
235
- }
236
-
237
- menu.appendChild(item);
238
- });
239
-
240
- // Position menu (hide first to prevent jumping)
241
- menu.style.visibility = 'hidden';
242
- menu.style.display = 'block';
243
-
244
- // Insert into DOM
245
- if (insertIntoDOM) {
246
- insertIntoDOM(menu, button);
247
- } else {
248
- button.insertAdjacentElement('afterend', menu);
249
- }
250
-
251
- // Position
252
- if (positionMenu) {
253
- positionMenu(menu, button, true);
254
- }
255
-
256
- // Show menu
257
- requestAnimationFrame(() => {
258
- menu.style.visibility = 'visible';
259
- });
260
-
261
- // Add keyboard navigation
262
- attachMenuKeyboardNavigation(menu, button, `.${classPrefix}-menu-item`, () => {
263
- closeMenuAndReturnFocus(menu, button, onClose);
264
- });
265
-
266
- // Focus active or first enabled item (never an aria-disabled empty state).
267
- setTimeout(() => {
268
- const focusTarget = activeItem
269
- || menu.querySelector(`.${classPrefix}-menu-item:not([aria-disabled="true"])`);
270
- if (focusTarget) {
271
- (focusTarget as HTMLElement).focus({ preventScroll: true });
272
- }
273
- }, 0);
274
-
275
- // Attach close handler
276
- if (attachCloseHandler) {
277
- attachCloseHandler(menu, button);
278
- }
279
-
280
- button.setAttribute('aria-expanded', 'true');
281
-
282
- return menu;
283
- }
284
-
285
- /**
286
- * Close menu and return focus to button
287
- */
288
- function closeMenuAndReturnFocus(menu: HTMLElement | null, button: HTMLElement, onClose: (() => void) | null = null): void {
289
- if (menu) {
290
- menu.remove();
291
- }
292
- button.setAttribute('aria-expanded', 'false');
293
- button.focus({ preventScroll: true });
294
- if (onClose) {
295
- onClose();
296
- }
297
- }
298
-
299
- /**
300
- * Create a speed menu
301
- */
302
- export function createSpeedMenu({
303
- player,
304
- button,
305
- currentSpeed,
306
- speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
307
- onSpeedChange,
308
- insertIntoDOM,
309
- positionMenu,
310
- attachCloseHandler
311
- }: SpeedMenuOptions): HTMLElement | null {
312
- const items = speeds.map(speed => ({
313
- text: speed === 1 ? i18n.t('player.normalSpeed') : `${speed}x`,
314
- value: speed,
315
- active: Math.abs(currentSpeed - speed) < 0.01,
316
- onClick: (value: unknown) => onSpeedChange(value as number)
317
- }));
318
-
319
- return createMenu({
320
- player,
321
- button,
322
- menuClass: 'speed-menu',
323
- ariaLabel: i18n.t('player.speed'),
324
- items,
325
- radioGroup: true,
326
- insertIntoDOM,
327
- positionMenu,
328
- attachCloseHandler
329
- });
330
- }
331
-
332
- /**
333
- * Create a captions menu
334
- */
335
- export function createCaptionsMenu({
336
- player,
337
- button,
338
- tracks,
339
- currentTrackIndex,
340
- captionsEnabled,
341
- onTrackSelect,
342
- onDisable,
343
- insertIntoDOM,
344
- positionMenu,
345
- attachCloseHandler
346
- }: CaptionsMenuOptions): HTMLElement | null {
347
- const items: Array<{text: string; value?: number; active: boolean; onClick: () => void}> = [
348
- {
349
- text: i18n.t('player.captionsOff'),
350
- active: !captionsEnabled,
351
- onClick: () => onDisable()
352
- }
353
- ];
354
-
355
- tracks.forEach((track, index) => {
356
- items.push({
357
- text: track.label || track.language || '',
358
- value: index,
359
- active: captionsEnabled && currentTrackIndex === index,
360
- onClick: () => onTrackSelect(index)
361
- });
362
- });
363
-
364
- return createMenu({
365
- player,
366
- button,
367
- menuClass: 'captions-menu',
368
- ariaLabel: i18n.t('player.captions'),
369
- items,
370
- radioGroup: true,
371
- insertIntoDOM,
372
- positionMenu,
373
- attachCloseHandler
374
- });
375
- }
376
-
377
- /**
378
- * Create a chapters menu
379
- */
380
- export function createChaptersMenu({
381
- player,
382
- button,
383
- chapters,
384
- onChapterSelect,
385
- formatTime,
386
- formatDuration,
387
- insertIntoDOM,
388
- positionMenu,
389
- attachCloseHandler
390
- }: ChaptersMenuOptions): HTMLElement | null {
391
- if (!chapters || chapters.length === 0) {
392
- return createMenu({
393
- player,
394
- button,
395
- menuClass: 'chapters-menu',
396
- ariaLabel: i18n.t('player.chapters'),
397
- items: [{
398
- text: i18n.t('player.noChapters'),
399
- disabled: true
400
- }],
401
- insertIntoDOM,
402
- positionMenu,
403
- attachCloseHandler
404
- });
405
- }
406
-
407
- const items = chapters.map(chapter => ({
408
- timeLabel: formatTime(chapter.startTime),
409
- timeAriaLabel: formatDuration(chapter.startTime),
410
- text: chapter.text,
411
- textClass: `${player.options.classPrefix}-chapter-title`,
412
- value: chapter.startTime,
413
- onClick: (value: unknown) => onChapterSelect(value as number)
414
- }));
415
-
416
- return createMenu({
417
- player,
418
- button,
419
- menuClass: 'chapters-menu',
420
- ariaLabel: i18n.t('player.chapters'),
421
- items,
422
- insertIntoDOM,
423
- positionMenu,
424
- attachCloseHandler
425
- });
426
- }
427
-
428
- /**
429
- * Create a quality menu
430
- */
431
- export function createQualityMenu({
432
- player,
433
- button,
434
- qualities,
435
- currentQuality,
436
- isHLS,
437
- onQualitySelect,
438
- insertIntoDOM,
439
- positionMenu,
440
- attachCloseHandler
441
- }: QualityMenuOptions): HTMLElement | null {
442
- const items: ActionMenuItem[] = [];
443
-
444
- // Auto option for HLS
445
- if (isHLS) {
446
- items.push({
447
- text: i18n.t('player.auto'),
448
- value: -1,
449
- active: currentQuality === -1,
450
- onClick: () => onQualitySelect(-1)
451
- });
452
- }
453
-
454
- // Quality options
455
- qualities.forEach(quality => {
456
- items.push({
457
- text: quality.name || `${quality.height}p`,
458
- value: quality.index,
459
- active: quality.index === currentQuality,
460
- onClick: () => onQualitySelect(quality.index)
461
- });
462
- });
463
-
464
- if (items.length === 0) {
465
- items.push({
466
- text: i18n.t('player.autoQuality'),
467
- disabled: true
468
- });
469
- }
470
-
471
- return createMenu({
472
- player,
473
- button,
474
- menuClass: 'quality-menu',
475
- ariaLabel: i18n.t('player.quality'),
476
- items,
477
- radioGroup: true,
478
- insertIntoDOM,
479
- positionMenu,
480
- attachCloseHandler
481
- });
482
- }
483
-
1
+ /**
2
+ * Menu Factory - Centralized menu creation and management
3
+ * Reduces code duplication across ControlBar menu methods
4
+ */
5
+
6
+ import { DOMUtils } from './DOMUtils.js';
7
+ import { createIconElement } from '../icons/Icons.js';
8
+ import { i18n } from '../i18n/i18n.js';
9
+ import { attachMenuKeyboardNavigation } from './MenuUtils.js';
10
+ import type { Player } from '../core/Player.js';
11
+
12
+ interface DividerMenuItem {
13
+ type: 'divider';
14
+ }
15
+
16
+ interface HeaderMenuItem {
17
+ type: 'header';
18
+ text: string;
19
+ }
20
+
21
+ interface ActionMenuItem {
22
+ type?: 'action';
23
+ text?: string;
24
+ value?: unknown;
25
+ active?: boolean;
26
+ disabled?: boolean;
27
+ icon?: string;
28
+ textClass?: string;
29
+ timeLabel?: string;
30
+ timeAriaLabel?: string;
31
+ onClick?: (value: unknown, index: number) => void;
32
+ }
33
+
34
+ type MenuItem = DividerMenuItem | HeaderMenuItem | ActionMenuItem;
35
+
36
+ interface CreateMenuOptions {
37
+ player: Player;
38
+ button: HTMLElement;
39
+ menuClass: string;
40
+ ariaLabel: string;
41
+ items?: MenuItem[];
42
+ activeIndex?: number;
43
+ onClose?: (() => void) | null;
44
+ // When true, actionable items represent a single-select group and use
45
+ // role="menuitemradio" + aria-checked so assistive tech announces the
46
+ // current selection, not just a visual check icon (WCAG 4.1.2).
47
+ radioGroup?: boolean;
48
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
49
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
50
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
51
+ }
52
+
53
+ interface SpeedMenuOptions {
54
+ player: Player;
55
+ button: HTMLElement;
56
+ currentSpeed: number;
57
+ speeds?: number[];
58
+ onSpeedChange: (value: number) => void;
59
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
60
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
61
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
62
+ }
63
+
64
+ interface CaptionsMenuOptions {
65
+ player: Player;
66
+ button: HTMLElement;
67
+ tracks: Array<{ label?: string; language?: string }>;
68
+ currentTrackIndex: number;
69
+ captionsEnabled: boolean;
70
+ onTrackSelect: (index: number) => void;
71
+ onDisable: () => void;
72
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
73
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
74
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
75
+ }
76
+
77
+ interface ChaptersMenuOptions {
78
+ player: Player;
79
+ button: HTMLElement;
80
+ chapters: Array<{ startTime: number; text: string }> | null | undefined;
81
+ onChapterSelect: (value: number) => void;
82
+ formatTime: (seconds: number) => string;
83
+ formatDuration: (seconds: number) => string;
84
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
85
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
86
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
87
+ }
88
+
89
+ interface QualityMenuOptions {
90
+ player: Player;
91
+ button: HTMLElement;
92
+ qualities: Array<{ name?: string; height?: number; index: number }>;
93
+ currentQuality: number;
94
+ isHLS: boolean;
95
+ onQualitySelect: (index: number) => void;
96
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
97
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
98
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
99
+ }
100
+
101
+ /**
102
+ * Create and show a menu with standard positioning and behavior
103
+ * @param {Object} options - Menu configuration
104
+ * @returns {HTMLElement} The created menu element
105
+ */
106
+ export function createMenu({
107
+ player,
108
+ button,
109
+ menuClass,
110
+ ariaLabel,
111
+ items = [],
112
+ activeIndex = -1,
113
+ onClose = null,
114
+ radioGroup = false,
115
+ insertIntoDOM = null,
116
+ positionMenu = null,
117
+ attachCloseHandler = null
118
+ }: CreateMenuOptions): HTMLElement | null {
119
+ const classPrefix = player.options.classPrefix;
120
+
121
+ // Remove existing menu (toggle behavior)
122
+ const existingMenu = document.querySelector(`.${classPrefix}-${menuClass}`);
123
+ if (existingMenu) {
124
+ existingMenu.remove();
125
+ button.setAttribute('aria-expanded', 'false');
126
+ return null;
127
+ }
128
+
129
+ // Create menu container
130
+ const menu = DOMUtils.createElement('div', {
131
+ className: `${classPrefix}-${menuClass} ${classPrefix}-menu`,
132
+ attributes: {
133
+ 'role': 'menu',
134
+ 'aria-label': ariaLabel
135
+ }
136
+ });
137
+
138
+ let activeItem: HTMLElement | null = null;
139
+
140
+ // Create menu items
141
+ items.forEach((itemConfig, index) => {
142
+ if (itemConfig.type === 'divider') {
143
+ const divider = DOMUtils.createElement('div', {
144
+ className: `${classPrefix}-menu-divider`,
145
+ attributes: { 'role': 'separator' }
146
+ });
147
+ menu.appendChild(divider);
148
+ return;
149
+ }
150
+
151
+ if (itemConfig.type === 'header') {
152
+ const header = DOMUtils.createElement('div', {
153
+ className: `${classPrefix}-menu-header`,
154
+ textContent: itemConfig.text
155
+ });
156
+ menu.appendChild(header);
157
+ return;
158
+ }
159
+
160
+ if ('disabled' in itemConfig && itemConfig.disabled) {
161
+ // Empty-state text ("No chapters", "Auto quality"). It is not
162
+ // actionable, so expose it as aria-disabled and keep it out of the
163
+ // roving keyboard navigation (see attachMenuKeyboardNavigation),
164
+ // which only steps through enabled menuitems (WCAG 2.1.1, 4.1.2).
165
+ const disabledItem = DOMUtils.createElement('div', {
166
+ className: `${classPrefix}-menu-item ${classPrefix}-menu-item-disabled`,
167
+ textContent: itemConfig.text,
168
+ attributes: {
169
+ 'role': 'menuitem',
170
+ 'aria-disabled': 'true',
171
+ 'tabindex': '-1'
172
+ },
173
+ style: { opacity: '0.5', cursor: 'default' }
174
+ });
175
+ menu.appendChild(disabledItem);
176
+ return;
177
+ }
178
+
179
+ // Add content based on item type
180
+ const actionItem = itemConfig as ActionMenuItem;
181
+ const isActive = actionItem.active || index === activeIndex;
182
+
183
+ const itemAttributes: Record<string, string> = {
184
+ 'type': 'button',
185
+ 'role': radioGroup ? 'menuitemradio' : 'menuitem',
186
+ 'tabindex': '-1'
187
+ };
188
+ if (radioGroup) {
189
+ itemAttributes['aria-checked'] = isActive ? 'true' : 'false';
190
+ }
191
+
192
+ const item = DOMUtils.createElement('button', {
193
+ className: `${classPrefix}-menu-item`,
194
+ attributes: itemAttributes
195
+ });
196
+
197
+ if (actionItem.icon) {
198
+ item.appendChild(createIconElement(actionItem.icon));
199
+ }
200
+
201
+ if (actionItem.timeLabel) {
202
+ const timeSpan = DOMUtils.createElement('span', {
203
+ className: `${classPrefix}-chapter-time`,
204
+ textContent: actionItem.timeLabel,
205
+ attributes: actionItem.timeAriaLabel
206
+ ? { 'aria-label': actionItem.timeAriaLabel }
207
+ : {}
208
+ });
209
+ item.appendChild(timeSpan);
210
+ item.appendChild(document.createTextNode(' '));
211
+ }
212
+
213
+ if (actionItem.text) {
214
+ const textSpan = DOMUtils.createElement('span', {
215
+ className: actionItem.textClass || `${classPrefix}-menu-item-text`,
216
+ textContent: actionItem.text
217
+ });
218
+ item.appendChild(textSpan);
219
+ }
220
+
221
+ // Mark as active
222
+ if (isActive) {
223
+ item.classList.add(`${classPrefix}-menu-item-active`);
224
+ item.appendChild(createIconElement('check'));
225
+ activeItem = item;
226
+ }
227
+
228
+ // Click handler
229
+ const onClick = actionItem.onClick;
230
+ if (onClick) {
231
+ item.addEventListener('click', () => {
232
+ onClick(actionItem.value, index);
233
+ closeMenuAndReturnFocus(menu, button, onClose);
234
+ });
235
+ }
236
+
237
+ menu.appendChild(item);
238
+ });
239
+
240
+ // Position menu (hide first to prevent jumping)
241
+ menu.style.visibility = 'hidden';
242
+ menu.style.display = 'block';
243
+
244
+ // Insert into DOM
245
+ if (insertIntoDOM) {
246
+ insertIntoDOM(menu, button);
247
+ } else {
248
+ button.insertAdjacentElement('afterend', menu);
249
+ }
250
+
251
+ // Position
252
+ if (positionMenu) {
253
+ positionMenu(menu, button, true);
254
+ }
255
+
256
+ // Show menu
257
+ requestAnimationFrame(() => {
258
+ menu.style.visibility = 'visible';
259
+ });
260
+
261
+ // Add keyboard navigation
262
+ attachMenuKeyboardNavigation(menu, button, `.${classPrefix}-menu-item`, () => {
263
+ closeMenuAndReturnFocus(menu, button, onClose);
264
+ });
265
+
266
+ // Focus active or first enabled item (never an aria-disabled empty state).
267
+ setTimeout(() => {
268
+ const focusTarget = activeItem
269
+ || menu.querySelector(`.${classPrefix}-menu-item:not([aria-disabled="true"])`);
270
+ if (focusTarget) {
271
+ (focusTarget as HTMLElement).focus({ preventScroll: true });
272
+ }
273
+ }, 0);
274
+
275
+ // Attach close handler
276
+ if (attachCloseHandler) {
277
+ attachCloseHandler(menu, button);
278
+ }
279
+
280
+ button.setAttribute('aria-expanded', 'true');
281
+
282
+ return menu;
283
+ }
284
+
285
+ /**
286
+ * Close menu and return focus to button
287
+ */
288
+ function closeMenuAndReturnFocus(menu: HTMLElement | null, button: HTMLElement, onClose: (() => void) | null = null): void {
289
+ if (menu) {
290
+ menu.remove();
291
+ }
292
+ button.setAttribute('aria-expanded', 'false');
293
+ button.focus({ preventScroll: true });
294
+ if (onClose) {
295
+ onClose();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Create a speed menu
301
+ */
302
+ export function createSpeedMenu({
303
+ player,
304
+ button,
305
+ currentSpeed,
306
+ speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
307
+ onSpeedChange,
308
+ insertIntoDOM,
309
+ positionMenu,
310
+ attachCloseHandler
311
+ }: SpeedMenuOptions): HTMLElement | null {
312
+ const items = speeds.map(speed => ({
313
+ text: speed === 1 ? i18n.t('player.normalSpeed') : `${speed}x`,
314
+ value: speed,
315
+ active: Math.abs(currentSpeed - speed) < 0.01,
316
+ onClick: (value: unknown) => onSpeedChange(value as number)
317
+ }));
318
+
319
+ return createMenu({
320
+ player,
321
+ button,
322
+ menuClass: 'speed-menu',
323
+ ariaLabel: i18n.t('player.speed'),
324
+ items,
325
+ radioGroup: true,
326
+ insertIntoDOM,
327
+ positionMenu,
328
+ attachCloseHandler
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Create a captions menu
334
+ */
335
+ export function createCaptionsMenu({
336
+ player,
337
+ button,
338
+ tracks,
339
+ currentTrackIndex,
340
+ captionsEnabled,
341
+ onTrackSelect,
342
+ onDisable,
343
+ insertIntoDOM,
344
+ positionMenu,
345
+ attachCloseHandler
346
+ }: CaptionsMenuOptions): HTMLElement | null {
347
+ const items: Array<{text: string; value?: number; active: boolean; onClick: () => void}> = [
348
+ {
349
+ text: i18n.t('player.captionsOff'),
350
+ active: !captionsEnabled,
351
+ onClick: () => onDisable()
352
+ }
353
+ ];
354
+
355
+ tracks.forEach((track, index) => {
356
+ items.push({
357
+ text: track.label || track.language || '',
358
+ value: index,
359
+ active: captionsEnabled && currentTrackIndex === index,
360
+ onClick: () => onTrackSelect(index)
361
+ });
362
+ });
363
+
364
+ return createMenu({
365
+ player,
366
+ button,
367
+ menuClass: 'captions-menu',
368
+ ariaLabel: i18n.t('player.captions'),
369
+ items,
370
+ radioGroup: true,
371
+ insertIntoDOM,
372
+ positionMenu,
373
+ attachCloseHandler
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Create a chapters menu
379
+ */
380
+ export function createChaptersMenu({
381
+ player,
382
+ button,
383
+ chapters,
384
+ onChapterSelect,
385
+ formatTime,
386
+ formatDuration,
387
+ insertIntoDOM,
388
+ positionMenu,
389
+ attachCloseHandler
390
+ }: ChaptersMenuOptions): HTMLElement | null {
391
+ if (!chapters || chapters.length === 0) {
392
+ return createMenu({
393
+ player,
394
+ button,
395
+ menuClass: 'chapters-menu',
396
+ ariaLabel: i18n.t('player.chapters'),
397
+ items: [{
398
+ text: i18n.t('player.noChapters'),
399
+ disabled: true
400
+ }],
401
+ insertIntoDOM,
402
+ positionMenu,
403
+ attachCloseHandler
404
+ });
405
+ }
406
+
407
+ const items = chapters.map(chapter => ({
408
+ timeLabel: formatTime(chapter.startTime),
409
+ timeAriaLabel: formatDuration(chapter.startTime),
410
+ text: chapter.text,
411
+ textClass: `${player.options.classPrefix}-chapter-title`,
412
+ value: chapter.startTime,
413
+ onClick: (value: unknown) => onChapterSelect(value as number)
414
+ }));
415
+
416
+ return createMenu({
417
+ player,
418
+ button,
419
+ menuClass: 'chapters-menu',
420
+ ariaLabel: i18n.t('player.chapters'),
421
+ items,
422
+ insertIntoDOM,
423
+ positionMenu,
424
+ attachCloseHandler
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Create a quality menu
430
+ */
431
+ export function createQualityMenu({
432
+ player,
433
+ button,
434
+ qualities,
435
+ currentQuality,
436
+ isHLS,
437
+ onQualitySelect,
438
+ insertIntoDOM,
439
+ positionMenu,
440
+ attachCloseHandler
441
+ }: QualityMenuOptions): HTMLElement | null {
442
+ const items: ActionMenuItem[] = [];
443
+
444
+ // Auto option for HLS
445
+ if (isHLS) {
446
+ items.push({
447
+ text: i18n.t('player.auto'),
448
+ value: -1,
449
+ active: currentQuality === -1,
450
+ onClick: () => onQualitySelect(-1)
451
+ });
452
+ }
453
+
454
+ // Quality options
455
+ qualities.forEach(quality => {
456
+ items.push({
457
+ text: quality.name || `${quality.height}p`,
458
+ value: quality.index,
459
+ active: quality.index === currentQuality,
460
+ onClick: () => onQualitySelect(quality.index)
461
+ });
462
+ });
463
+
464
+ if (items.length === 0) {
465
+ items.push({
466
+ text: i18n.t('player.autoQuality'),
467
+ disabled: true
468
+ });
469
+ }
470
+
471
+ return createMenu({
472
+ player,
473
+ button,
474
+ menuClass: 'quality-menu',
475
+ ariaLabel: i18n.t('player.quality'),
476
+ items,
477
+ radioGroup: true,
478
+ insertIntoDOM,
479
+ positionMenu,
480
+ attachCloseHandler
481
+ });
482
+ }
483
+