ts-video-player 0.1.0

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 (251) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +438 -0
  3. package/dist/cdn.d.ts +10 -0
  4. package/dist/cdn.d.ts.map +1 -0
  5. package/dist/cdn.min.js +1046 -0
  6. package/dist/cdn.min.js.map +54 -0
  7. package/dist/cdn.test.d.ts +2 -0
  8. package/dist/cdn.test.d.ts.map +1 -0
  9. package/dist/chunk-36x4yhjn.js +765 -0
  10. package/dist/chunk-36x4yhjn.js.map +12 -0
  11. package/dist/chunk-eajts2jv.js +4243 -0
  12. package/dist/chunk-eajts2jv.js.map +42 -0
  13. package/dist/chunk-z1ehbz9f.js +380 -0
  14. package/dist/chunk-z1ehbz9f.js.map +19 -0
  15. package/dist/config.d.ts +9 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/core/audio-gain.d.ts +67 -0
  18. package/dist/core/audio-gain.d.ts.map +1 -0
  19. package/dist/core/caption-styles.d.ts +121 -0
  20. package/dist/core/caption-styles.d.ts.map +1 -0
  21. package/dist/core/chapters.d.ts +150 -0
  22. package/dist/core/chapters.d.ts.map +1 -0
  23. package/dist/core/error-overlay.d.ts +71 -0
  24. package/dist/core/error-overlay.d.ts.map +1 -0
  25. package/dist/core/events.d.ts +119 -0
  26. package/dist/core/events.d.ts.map +1 -0
  27. package/dist/core/features.d.ts +64 -0
  28. package/dist/core/features.d.ts.map +1 -0
  29. package/dist/core/i18n.d.ts +132 -0
  30. package/dist/core/i18n.d.ts.map +1 -0
  31. package/dist/core/index.d.ts +37 -0
  32. package/dist/core/index.d.ts.map +1 -0
  33. package/dist/core/live.d.ts +130 -0
  34. package/dist/core/live.d.ts.map +1 -0
  35. package/dist/core/media-session.d.ts +38 -0
  36. package/dist/core/media-session.d.ts.map +1 -0
  37. package/dist/core/orientation.d.ts +22 -0
  38. package/dist/core/orientation.d.ts.map +1 -0
  39. package/dist/core/popover.d.ts +42 -0
  40. package/dist/core/popover.d.ts.map +1 -0
  41. package/dist/core/remote-playback.d.ts +195 -0
  42. package/dist/core/remote-playback.d.ts.map +1 -0
  43. package/dist/core/settings-menu.d.ts +132 -0
  44. package/dist/core/settings-menu.d.ts.map +1 -0
  45. package/dist/core/spinner.d.ts +67 -0
  46. package/dist/core/spinner.d.ts.map +1 -0
  47. package/dist/core/state.d.ts +142 -0
  48. package/dist/core/state.d.ts.map +1 -0
  49. package/dist/core/text-tracks.d.ts +162 -0
  50. package/dist/core/text-tracks.d.ts.map +1 -0
  51. package/dist/core/themes.d.ts +213 -0
  52. package/dist/core/themes.d.ts.map +1 -0
  53. package/dist/core/thumbnails.d.ts +124 -0
  54. package/dist/core/thumbnails.d.ts.map +1 -0
  55. package/dist/core/tooltips.d.ts +100 -0
  56. package/dist/core/tooltips.d.ts.map +1 -0
  57. package/dist/elements/base.d.ts +3 -0
  58. package/dist/elements/base.d.ts.map +1 -0
  59. package/dist/elements/index.d.ts +33 -0
  60. package/dist/elements/index.d.ts.map +1 -0
  61. package/dist/elements/index.js +55 -0
  62. package/dist/elements/index.js.map +9 -0
  63. package/dist/elements/media-airplay-button.d.ts +13 -0
  64. package/dist/elements/media-airplay-button.d.ts.map +1 -0
  65. package/dist/elements/media-error-display.d.ts +13 -0
  66. package/dist/elements/media-error-display.d.ts.map +1 -0
  67. package/dist/elements/media-fullscreen-button.d.ts +13 -0
  68. package/dist/elements/media-fullscreen-button.d.ts.map +1 -0
  69. package/dist/elements/media-live-button.d.ts +13 -0
  70. package/dist/elements/media-live-button.d.ts.map +1 -0
  71. package/dist/elements/media-mute-button.d.ts +13 -0
  72. package/dist/elements/media-mute-button.d.ts.map +1 -0
  73. package/dist/elements/media-pip-button.d.ts +13 -0
  74. package/dist/elements/media-pip-button.d.ts.map +1 -0
  75. package/dist/elements/media-play-button.d.ts +14 -0
  76. package/dist/elements/media-play-button.d.ts.map +1 -0
  77. package/dist/elements/media-playback-rate-button.d.ts +10 -0
  78. package/dist/elements/media-playback-rate-button.d.ts.map +1 -0
  79. package/dist/elements/media-preview-time.d.ts +18 -0
  80. package/dist/elements/media-preview-time.d.ts.map +1 -0
  81. package/dist/elements/media-progress-bar.d.ts +16 -0
  82. package/dist/elements/media-progress-bar.d.ts.map +1 -0
  83. package/dist/elements/media-remote-playback-button.d.ts +16 -0
  84. package/dist/elements/media-remote-playback-button.d.ts.map +1 -0
  85. package/dist/elements/media-seek-button.d.ts +9 -0
  86. package/dist/elements/media-seek-button.d.ts.map +1 -0
  87. package/dist/elements/media-settings-menu.d.ts +32 -0
  88. package/dist/elements/media-settings-menu.d.ts.map +1 -0
  89. package/dist/elements/media-time-display.d.ts +21 -0
  90. package/dist/elements/media-time-display.d.ts.map +1 -0
  91. package/dist/elements/media-time-group.d.ts +22 -0
  92. package/dist/elements/media-time-group.d.ts.map +1 -0
  93. package/dist/elements/media-time-separator.d.ts +13 -0
  94. package/dist/elements/media-time-separator.d.ts.map +1 -0
  95. package/dist/elements/media-volume-slider.d.ts +16 -0
  96. package/dist/elements/media-volume-slider.d.ts.map +1 -0
  97. package/dist/elements/utils.d.ts +27 -0
  98. package/dist/elements/utils.d.ts.map +1 -0
  99. package/dist/elements/video-player.d.ts +23 -0
  100. package/dist/elements/video-player.d.ts.map +1 -0
  101. package/dist/elements/video-skin.d.ts +23 -0
  102. package/dist/elements/video-skin.d.ts.map +1 -0
  103. package/dist/features/captions.d.ts +8 -0
  104. package/dist/features/captions.d.ts.map +1 -0
  105. package/dist/features/create-player.d.ts +45 -0
  106. package/dist/features/create-player.d.ts.map +1 -0
  107. package/dist/features/fullscreen.d.ts +8 -0
  108. package/dist/features/fullscreen.d.ts.map +1 -0
  109. package/dist/features/gestures.d.ts +8 -0
  110. package/dist/features/gestures.d.ts.map +1 -0
  111. package/dist/features/index.d.ts +26 -0
  112. package/dist/features/index.d.ts.map +1 -0
  113. package/dist/features/index.js +31 -0
  114. package/dist/features/index.js.map +9 -0
  115. package/dist/features/keyboard.d.ts +8 -0
  116. package/dist/features/keyboard.d.ts.map +1 -0
  117. package/dist/features/pip.d.ts +8 -0
  118. package/dist/features/pip.d.ts.map +1 -0
  119. package/dist/features/playback.d.ts +8 -0
  120. package/dist/features/playback.d.ts.map +1 -0
  121. package/dist/features/quality.d.ts +8 -0
  122. package/dist/features/quality.d.ts.map +1 -0
  123. package/dist/features/types.d.ts +26 -0
  124. package/dist/features/types.d.ts.map +1 -0
  125. package/dist/features/volume.d.ts +8 -0
  126. package/dist/features/volume.d.ts.map +1 -0
  127. package/dist/index.d.ts +84 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +8149 -0
  130. package/dist/index.js.map +31 -0
  131. package/dist/layouts/index.d.ts +58 -0
  132. package/dist/layouts/index.d.ts.map +1 -0
  133. package/dist/player.d.ts +90 -0
  134. package/dist/player.d.ts.map +1 -0
  135. package/dist/plugins/ads.d.ts +311 -0
  136. package/dist/plugins/ads.d.ts.map +1 -0
  137. package/dist/plugins/analytics.d.ts +203 -0
  138. package/dist/plugins/analytics.d.ts.map +1 -0
  139. package/dist/plugins/end-screen.d.ts +184 -0
  140. package/dist/plugins/end-screen.d.ts.map +1 -0
  141. package/dist/plugins/index.d.ts +13 -0
  142. package/dist/plugins/index.d.ts.map +1 -0
  143. package/dist/plugins/index.js +20 -0
  144. package/dist/plugins/index.js.map +9 -0
  145. package/dist/plugins/skip-segments.d.ts +159 -0
  146. package/dist/plugins/skip-segments.d.ts.map +1 -0
  147. package/dist/plugins/watermark.d.ts +157 -0
  148. package/dist/plugins/watermark.d.ts.map +1 -0
  149. package/dist/providers/base.d.ts +56 -0
  150. package/dist/providers/base.d.ts.map +1 -0
  151. package/dist/providers/dash.d.ts +94 -0
  152. package/dist/providers/dash.d.ts.map +1 -0
  153. package/dist/providers/hls.d.ts +182 -0
  154. package/dist/providers/hls.d.ts.map +1 -0
  155. package/dist/providers/html5.d.ts +64 -0
  156. package/dist/providers/html5.d.ts.map +1 -0
  157. package/dist/providers/index.d.ts +41 -0
  158. package/dist/providers/index.d.ts.map +1 -0
  159. package/dist/providers/vimeo.d.ts +157 -0
  160. package/dist/providers/vimeo.d.ts.map +1 -0
  161. package/dist/providers/youtube.d.ts +134 -0
  162. package/dist/providers/youtube.d.ts.map +1 -0
  163. package/dist/stx/index.d.ts +59 -0
  164. package/dist/stx/index.d.ts.map +1 -0
  165. package/dist/stx/index.js +480 -0
  166. package/dist/stx/index.js.map +11 -0
  167. package/dist/types.d.ts +724 -0
  168. package/dist/types.d.ts.map +1 -0
  169. package/dist/ui/components.d.ts +121 -0
  170. package/dist/ui/components.d.ts.map +1 -0
  171. package/dist/ui/index.d.ts +9 -0
  172. package/dist/ui/index.d.ts.map +1 -0
  173. package/dist/ui/index.js +16 -0
  174. package/dist/ui/index.js.map +9 -0
  175. package/package.json +109 -0
  176. package/src/cdn.test.ts +10 -0
  177. package/src/cdn.ts +60 -0
  178. package/src/config.ts +25 -0
  179. package/src/core/audio-gain.ts +171 -0
  180. package/src/core/caption-styles.ts +568 -0
  181. package/src/core/chapters.ts +551 -0
  182. package/src/core/error-overlay.ts +354 -0
  183. package/src/core/events.ts +552 -0
  184. package/src/core/features.ts +287 -0
  185. package/src/core/i18n.ts +590 -0
  186. package/src/core/index.ts +249 -0
  187. package/src/core/live.ts +472 -0
  188. package/src/core/media-session.ts +137 -0
  189. package/src/core/orientation.ts +57 -0
  190. package/src/core/popover.ts +143 -0
  191. package/src/core/remote-playback.ts +661 -0
  192. package/src/core/settings-menu.ts +630 -0
  193. package/src/core/spinner.ts +310 -0
  194. package/src/core/state.ts +476 -0
  195. package/src/core/text-tracks.ts +599 -0
  196. package/src/core/themes.ts +607 -0
  197. package/src/core/thumbnails.ts +457 -0
  198. package/src/core/tooltips.ts +370 -0
  199. package/src/elements/base.ts +2 -0
  200. package/src/elements/index.ts +82 -0
  201. package/src/elements/media-airplay-button.ts +78 -0
  202. package/src/elements/media-error-display.ts +47 -0
  203. package/src/elements/media-fullscreen-button.ts +72 -0
  204. package/src/elements/media-live-button.ts +65 -0
  205. package/src/elements/media-mute-button.ts +69 -0
  206. package/src/elements/media-pip-button.ts +66 -0
  207. package/src/elements/media-play-button.ts +69 -0
  208. package/src/elements/media-playback-rate-button.ts +59 -0
  209. package/src/elements/media-preview-time.ts +75 -0
  210. package/src/elements/media-progress-bar.ts +200 -0
  211. package/src/elements/media-remote-playback-button.ts +108 -0
  212. package/src/elements/media-seek-button.ts +50 -0
  213. package/src/elements/media-settings-menu.ts +302 -0
  214. package/src/elements/media-time-display.ts +99 -0
  215. package/src/elements/media-time-group.ts +41 -0
  216. package/src/elements/media-time-separator.ts +35 -0
  217. package/src/elements/media-volume-slider.ts +140 -0
  218. package/src/elements/utils.ts +82 -0
  219. package/src/elements/video-player.ts +175 -0
  220. package/src/elements/video-skin.ts +127 -0
  221. package/src/features/captions.ts +56 -0
  222. package/src/features/create-player.ts +91 -0
  223. package/src/features/fullscreen.ts +44 -0
  224. package/src/features/gestures.ts +44 -0
  225. package/src/features/index.ts +44 -0
  226. package/src/features/keyboard.ts +89 -0
  227. package/src/features/pip.ts +47 -0
  228. package/src/features/playback.ts +68 -0
  229. package/src/features/quality.ts +19 -0
  230. package/src/features/types.ts +28 -0
  231. package/src/features/volume.ts +46 -0
  232. package/src/index.ts +635 -0
  233. package/src/layouts/index.ts +672 -0
  234. package/src/player.ts +821 -0
  235. package/src/plugins/ads.ts +1045 -0
  236. package/src/plugins/analytics.ts +673 -0
  237. package/src/plugins/end-screen.ts +793 -0
  238. package/src/plugins/index.ts +61 -0
  239. package/src/plugins/skip-segments.ts +537 -0
  240. package/src/plugins/watermark.ts +537 -0
  241. package/src/providers/base.ts +197 -0
  242. package/src/providers/dash.ts +588 -0
  243. package/src/providers/hls.ts +624 -0
  244. package/src/providers/html5.ts +473 -0
  245. package/src/providers/index.ts +97 -0
  246. package/src/providers/vimeo.ts +533 -0
  247. package/src/providers/youtube.ts +542 -0
  248. package/src/stx/index.ts +606 -0
  249. package/src/types.ts +874 -0
  250. package/src/ui/components.ts +910 -0
  251. package/src/ui/index.ts +29 -0
@@ -0,0 +1,630 @@
1
+ /**
2
+ * Settings Menu
3
+ *
4
+ * Unified settings menu for quality, speed, captions, and audio.
5
+ *
6
+ * @module core/settings-menu
7
+ */
8
+
9
+ import type { VideoQuality, AudioTrack, TextTrack } from '../types'
10
+
11
+ // Helper to generate quality label
12
+ function getQualityLabel(quality: VideoQuality): string {
13
+ if (quality.height >= 2160) return '4K'
14
+ if (quality.height >= 1440) return '1440p'
15
+ if (quality.height >= 1080) return '1080p'
16
+ if (quality.height >= 720) return '720p'
17
+ if (quality.height >= 480) return '480p'
18
+ if (quality.height >= 360) return '360p'
19
+ return `${quality.height}p`
20
+ }
21
+
22
+ // =============================================================================
23
+ // Types
24
+ // =============================================================================
25
+
26
+ export interface SettingsMenuConfig {
27
+ /** Show quality options */
28
+ quality?: boolean
29
+ /** Show playback speed options */
30
+ speed?: boolean
31
+ /** Show captions options */
32
+ captions?: boolean
33
+ /** Show audio track options */
34
+ audioTracks?: boolean
35
+ /** Show audio boost options */
36
+ audioBoost?: boolean
37
+ /** Custom speed values */
38
+ speedValues?: number[]
39
+ /** Custom boost values */
40
+ boostValues?: number[]
41
+ }
42
+
43
+ export interface SettingsMenuState {
44
+ qualities: VideoQuality[]
45
+ currentQuality: VideoQuality | null
46
+ isAutoQuality: boolean
47
+ speeds: number[]
48
+ currentSpeed: number
49
+ textTracks: TextTrack[]
50
+ currentTextTrack: TextTrack | null
51
+ audioTracks: AudioTrack[]
52
+ currentAudioTrack: AudioTrack | null
53
+ audioBoost: number
54
+ }
55
+
56
+ type MenuView = 'main' | 'quality' | 'speed' | 'captions' | 'audio' | 'boost'
57
+
58
+ // =============================================================================
59
+ // Settings Menu Component
60
+ // =============================================================================
61
+
62
+ export class SettingsMenu {
63
+ private container: HTMLElement
64
+ private config: Required<SettingsMenuConfig>
65
+ private state: SettingsMenuState
66
+ private currentView: MenuView = 'main'
67
+ private visible = false
68
+
69
+ // Event handlers
70
+ private onQualityChange: ((_quality: VideoQuality | 'auto') => void) | null = null
71
+ private onSpeedChange: ((_speed: number) => void) | null = null
72
+ private onTextTrackChange: ((_track: TextTrack | null) => void) | null = null
73
+ private onAudioTrackChange: ((_track: AudioTrack) => void) | null = null
74
+ private onBoostChange: ((_boost: number) => void) | null = null
75
+
76
+ constructor(config: SettingsMenuConfig = {}) {
77
+ this.config = {
78
+ quality: config.quality ?? true,
79
+ speed: config.speed ?? true,
80
+ captions: config.captions ?? true,
81
+ audioTracks: config.audioTracks ?? true,
82
+ audioBoost: config.audioBoost ?? true,
83
+ speedValues: config.speedValues || [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
84
+ boostValues: config.boostValues || [1, 1.25, 1.5, 1.75, 2, 2.5, 3],
85
+ }
86
+
87
+ this.state = {
88
+ qualities: [],
89
+ currentQuality: null,
90
+ isAutoQuality: true,
91
+ speeds: this.config.speedValues,
92
+ currentSpeed: 1,
93
+ textTracks: [],
94
+ currentTextTrack: null,
95
+ audioTracks: [],
96
+ currentAudioTrack: null,
97
+ audioBoost: 1,
98
+ }
99
+
100
+ this.container = document.createElement('div')
101
+ this.container.className = 'tsvp-settings-menu'
102
+ this.container.setAttribute('role', 'menu')
103
+
104
+ this.applyStyles()
105
+ this.hide()
106
+ }
107
+
108
+ private applyStyles(): void {
109
+ this.container.style.cssText = `
110
+ position: absolute;
111
+ bottom: 100%;
112
+ right: 0;
113
+ margin-bottom: 8px;
114
+ min-width: 200px;
115
+ max-height: 300px;
116
+ overflow-y: auto;
117
+ background: var(--tsvp-menu-bg, rgba(20, 20, 20, 0.95));
118
+ border-radius: var(--tsvp-menu-radius, 8px);
119
+ box-shadow: var(--tsvp-menu-shadow, 0 4px 20px rgba(0, 0, 0, 0.5));
120
+ font-family: var(--tsvp-font-family, sans-serif);
121
+ font-size: var(--tsvp-font-size, 14px);
122
+ color: var(--tsvp-color-text, #ffffff);
123
+ z-index: 100;
124
+ `
125
+ }
126
+
127
+ /**
128
+ * Update state
129
+ */
130
+ updateState(state: Partial<SettingsMenuState>): void {
131
+ Object.assign(this.state, state)
132
+ if (this.visible) {
133
+ this.render()
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Render menu
139
+ */
140
+ private render(): void {
141
+ this.container.innerHTML = ''
142
+
143
+ switch (this.currentView) {
144
+ case 'main':
145
+ this.renderMainMenu()
146
+ break
147
+ case 'quality':
148
+ this.renderQualityMenu()
149
+ break
150
+ case 'speed':
151
+ this.renderSpeedMenu()
152
+ break
153
+ case 'captions':
154
+ this.renderCaptionsMenu()
155
+ break
156
+ case 'audio':
157
+ this.renderAudioMenu()
158
+ break
159
+ case 'boost':
160
+ this.renderBoostMenu()
161
+ break
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Render main menu
167
+ */
168
+ private renderMainMenu(): void {
169
+ // Quality
170
+ if (this.config.quality && this.state.qualities.length > 0) {
171
+ let currentLabel = 'Auto'
172
+ if (this.state.isAutoQuality) {
173
+ currentLabel = this.state.currentQuality
174
+ ? `Auto (${getQualityLabel(this.state.currentQuality)})`
175
+ : 'Auto'
176
+ }
177
+ else if (this.state.currentQuality) {
178
+ currentLabel = getQualityLabel(this.state.currentQuality)
179
+ }
180
+
181
+ this.addMenuItem('Quality', currentLabel, () => {
182
+ this.currentView = 'quality'
183
+ this.render()
184
+ })
185
+ }
186
+
187
+ // Speed
188
+ if (this.config.speed) {
189
+ const speedLabel = this.state.currentSpeed === 1 ? 'Normal' : `${this.state.currentSpeed}x`
190
+ this.addMenuItem('Speed', speedLabel, () => {
191
+ this.currentView = 'speed'
192
+ this.render()
193
+ })
194
+ }
195
+
196
+ // Captions
197
+ if (this.config.captions && this.state.textTracks.length > 0) {
198
+ const captionLabel = this.state.currentTextTrack?.label || 'Off'
199
+ this.addMenuItem('Captions', captionLabel, () => {
200
+ this.currentView = 'captions'
201
+ this.render()
202
+ })
203
+ }
204
+
205
+ // Audio tracks
206
+ if (this.config.audioTracks && this.state.audioTracks.length > 1) {
207
+ const audioLabel = this.state.currentAudioTrack?.label || 'Default'
208
+ this.addMenuItem('Audio', audioLabel, () => {
209
+ this.currentView = 'audio'
210
+ this.render()
211
+ })
212
+ }
213
+
214
+ // Audio boost
215
+ if (this.config.audioBoost) {
216
+ const boostLabel = this.state.audioBoost === 1 ? 'Off' : `${Math.round(this.state.audioBoost * 100)}%`
217
+ this.addMenuItem('Audio Boost', boostLabel, () => {
218
+ this.currentView = 'boost'
219
+ this.render()
220
+ })
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Render quality submenu
226
+ */
227
+ private renderQualityMenu(): void {
228
+ this.addBackButton('Quality')
229
+
230
+ // Auto option
231
+ const autoLabel = this.state.isAutoQuality && this.state.currentQuality
232
+ ? `Auto (${getQualityLabel(this.state.currentQuality)})`
233
+ : 'Auto'
234
+
235
+ this.addRadioItem(
236
+ autoLabel,
237
+ this.state.isAutoQuality,
238
+ () => {
239
+ if (this.onQualityChange) {
240
+ this.onQualityChange('auto')
241
+ }
242
+ this.goBack()
243
+ }
244
+ )
245
+
246
+ // Quality options
247
+ for (const quality of this.state.qualities) {
248
+ const isSelected = !this.state.isAutoQuality
249
+ && this.state.currentQuality !== null
250
+ && this.state.currentQuality.id === quality.id
251
+
252
+ this.addRadioItem(
253
+ getQualityLabel(quality),
254
+ isSelected,
255
+ () => {
256
+ if (this.onQualityChange) {
257
+ this.onQualityChange(quality)
258
+ }
259
+ this.goBack()
260
+ }
261
+ )
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Render speed submenu
267
+ */
268
+ private renderSpeedMenu(): void {
269
+ this.addBackButton('Speed')
270
+
271
+ for (const speed of this.state.speeds) {
272
+ const label = speed === 1 ? 'Normal' : `${speed}x`
273
+ this.addRadioItem(
274
+ label,
275
+ this.state.currentSpeed === speed,
276
+ () => {
277
+ if (this.onSpeedChange) {
278
+ this.onSpeedChange(speed)
279
+ }
280
+ this.goBack()
281
+ }
282
+ )
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Render captions submenu
288
+ */
289
+ private renderCaptionsMenu(): void {
290
+ this.addBackButton('Captions')
291
+
292
+ // Off option
293
+ this.addRadioItem(
294
+ 'Off',
295
+ this.state.currentTextTrack === null,
296
+ () => {
297
+ if (this.onTextTrackChange) {
298
+ this.onTextTrackChange(null)
299
+ }
300
+ this.goBack()
301
+ }
302
+ )
303
+
304
+ // Track options
305
+ for (const track of this.state.textTracks) {
306
+ this.addRadioItem(
307
+ track.label || track.language || 'Unknown',
308
+ this.state.currentTextTrack?.id === track.id,
309
+ () => {
310
+ if (this.onTextTrackChange) {
311
+ this.onTextTrackChange(track)
312
+ }
313
+ this.goBack()
314
+ }
315
+ )
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Render audio submenu
321
+ */
322
+ private renderAudioMenu(): void {
323
+ this.addBackButton('Audio')
324
+
325
+ for (const track of this.state.audioTracks) {
326
+ this.addRadioItem(
327
+ track.label || track.language || 'Unknown',
328
+ this.state.currentAudioTrack?.id === track.id,
329
+ () => {
330
+ if (this.onAudioTrackChange) {
331
+ this.onAudioTrackChange(track)
332
+ }
333
+ this.goBack()
334
+ }
335
+ )
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Render audio boost submenu
341
+ */
342
+ private renderBoostMenu(): void {
343
+ this.addBackButton('Audio Boost')
344
+
345
+ for (const boost of this.config.boostValues) {
346
+ const label = boost === 1 ? 'Off' : `${Math.round(boost * 100)}%`
347
+ this.addRadioItem(
348
+ label,
349
+ this.state.audioBoost === boost,
350
+ () => {
351
+ if (this.onBoostChange) {
352
+ this.onBoostChange(boost)
353
+ }
354
+ this.goBack()
355
+ }
356
+ )
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Add menu item with value
362
+ */
363
+ private addMenuItem(label: string, value: string, onClick: () => void): void {
364
+ const item = document.createElement('button')
365
+ item.type = 'button'
366
+ item.className = 'tsvp-settings-item'
367
+ item.setAttribute('role', 'menuitem')
368
+
369
+ item.style.cssText = `
370
+ display: flex;
371
+ align-items: center;
372
+ justify-content: space-between;
373
+ width: 100%;
374
+ padding: 10px 16px;
375
+ border: none;
376
+ background: transparent;
377
+ color: inherit;
378
+ font-size: inherit;
379
+ font-family: inherit;
380
+ text-align: left;
381
+ cursor: pointer;
382
+ transition: background var(--tsvp-transition-fast, 100ms);
383
+ `
384
+
385
+ const labelSpan = document.createElement('span')
386
+ labelSpan.textContent = label
387
+
388
+ const valueSpan = document.createElement('span')
389
+ valueSpan.textContent = value
390
+ valueSpan.style.cssText = `
391
+ display: flex;
392
+ align-items: center;
393
+ gap: 4px;
394
+ opacity: 0.7;
395
+ `
396
+
397
+ // Arrow icon
398
+ const arrow = document.createElement('span')
399
+ arrow.innerHTML = `
400
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
401
+ <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
402
+ </svg>
403
+ `
404
+ valueSpan.appendChild(arrow)
405
+
406
+ item.appendChild(labelSpan)
407
+ item.appendChild(valueSpan)
408
+
409
+ item.addEventListener('mouseenter', () => {
410
+ item.style.background = 'var(--tsvp-menu-item-hover-bg, rgba(255, 255, 255, 0.1))'
411
+ })
412
+ item.addEventListener('mouseleave', () => {
413
+ item.style.background = 'transparent'
414
+ })
415
+ item.addEventListener('click', onClick)
416
+
417
+ this.container.appendChild(item)
418
+ }
419
+
420
+ /**
421
+ * Add radio item
422
+ */
423
+ private addRadioItem(label: string, selected: boolean, onClick: () => void): void {
424
+ const item = document.createElement('button')
425
+ item.type = 'button'
426
+ item.className = 'tsvp-settings-radio'
427
+ item.setAttribute('role', 'menuitemradio')
428
+ item.setAttribute('aria-checked', String(selected))
429
+
430
+ item.style.cssText = `
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 12px;
434
+ width: 100%;
435
+ padding: 10px 16px;
436
+ border: none;
437
+ background: ${selected ? 'var(--tsvp-menu-item-active-bg, rgba(0, 168, 255, 0.2))' : 'transparent'};
438
+ color: inherit;
439
+ font-size: inherit;
440
+ font-family: inherit;
441
+ text-align: left;
442
+ cursor: pointer;
443
+ transition: background var(--tsvp-transition-fast, 100ms);
444
+ `
445
+
446
+ // Check icon
447
+ const check = document.createElement('span')
448
+ check.style.cssText = `
449
+ width: 16px;
450
+ height: 16px;
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: center;
454
+ `
455
+ check.innerHTML = selected
456
+ ? `<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--tsvp-color-primary, #00a8ff)">
457
+ <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
458
+ </svg>`
459
+ : ''
460
+
461
+ const labelSpan = document.createElement('span')
462
+ labelSpan.textContent = label
463
+
464
+ item.appendChild(check)
465
+ item.appendChild(labelSpan)
466
+
467
+ item.addEventListener('mouseenter', () => {
468
+ if (!selected) {
469
+ item.style.background = 'var(--tsvp-menu-item-hover-bg, rgba(255, 255, 255, 0.1))'
470
+ }
471
+ })
472
+ item.addEventListener('mouseleave', () => {
473
+ item.style.background = selected
474
+ ? 'var(--tsvp-menu-item-active-bg, rgba(0, 168, 255, 0.2))'
475
+ : 'transparent'
476
+ })
477
+ item.addEventListener('click', onClick)
478
+
479
+ this.container.appendChild(item)
480
+ }
481
+
482
+ /**
483
+ * Add back button
484
+ */
485
+ private addBackButton(title: string): void {
486
+ const header = document.createElement('button')
487
+ header.type = 'button'
488
+ header.className = 'tsvp-settings-back'
489
+
490
+ header.style.cssText = `
491
+ display: flex;
492
+ align-items: center;
493
+ gap: 8px;
494
+ width: 100%;
495
+ padding: 10px 16px;
496
+ border: none;
497
+ border-bottom: 1px solid var(--tsvp-color-border, rgba(255, 255, 255, 0.1));
498
+ background: transparent;
499
+ color: inherit;
500
+ font-size: inherit;
501
+ font-weight: var(--tsvp-font-weight-bold, 600);
502
+ font-family: inherit;
503
+ text-align: left;
504
+ cursor: pointer;
505
+ `
506
+
507
+ const arrow = document.createElement('span')
508
+ arrow.innerHTML = `
509
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
510
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
511
+ </svg>
512
+ `
513
+
514
+ const titleSpan = document.createElement('span')
515
+ titleSpan.textContent = title
516
+
517
+ header.appendChild(arrow)
518
+ header.appendChild(titleSpan)
519
+
520
+ header.addEventListener('click', () => this.goBack())
521
+
522
+ this.container.appendChild(header)
523
+ }
524
+
525
+ /**
526
+ * Go back to main menu
527
+ */
528
+ private goBack(): void {
529
+ this.currentView = 'main'
530
+ this.render()
531
+ }
532
+
533
+ /**
534
+ * Show menu
535
+ */
536
+ show(): void {
537
+ this.visible = true
538
+ this.currentView = 'main'
539
+ this.render()
540
+ this.container.style.display = 'block'
541
+
542
+ // Fade in
543
+ this.container.style.opacity = '0'
544
+ this.container.style.transform = 'translateY(8px)'
545
+ requestAnimationFrame(() => {
546
+ this.container.style.transition = 'opacity 150ms ease, transform 150ms ease'
547
+ this.container.style.opacity = '1'
548
+ this.container.style.transform = 'translateY(0)'
549
+ })
550
+ }
551
+
552
+ /**
553
+ * Hide menu
554
+ */
555
+ hide(): void {
556
+ this.visible = false
557
+ this.container.style.opacity = '0'
558
+ this.container.style.transform = 'translateY(8px)'
559
+
560
+ setTimeout(() => {
561
+ if (!this.visible) {
562
+ this.container.style.display = 'none'
563
+ }
564
+ }, 150)
565
+ }
566
+
567
+ /**
568
+ * Toggle menu
569
+ */
570
+ toggle(): void {
571
+ if (this.visible) {
572
+ this.hide()
573
+ }
574
+ else {
575
+ this.show()
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Check if visible
581
+ */
582
+ isVisible(): boolean {
583
+ return this.visible
584
+ }
585
+
586
+ /**
587
+ * Set event handlers
588
+ */
589
+ onQuality(handler: (quality: VideoQuality | 'auto') => void): void {
590
+ this.onQualityChange = handler
591
+ }
592
+
593
+ onSpeed(handler: (speed: number) => void): void {
594
+ this.onSpeedChange = handler
595
+ }
596
+
597
+ onTextTrack(handler: (track: TextTrack | null) => void): void {
598
+ this.onTextTrackChange = handler
599
+ }
600
+
601
+ onAudioTrack(handler: (track: AudioTrack) => void): void {
602
+ this.onAudioTrackChange = handler
603
+ }
604
+
605
+ onBoost(handler: (boost: number) => void): void {
606
+ this.onBoostChange = handler
607
+ }
608
+
609
+ /**
610
+ * Get element
611
+ */
612
+ getElement(): HTMLElement {
613
+ return this.container
614
+ }
615
+
616
+ /**
617
+ * Destroy
618
+ */
619
+ destroy(): void {
620
+ this.container.remove()
621
+ }
622
+ }
623
+
624
+ // =============================================================================
625
+ // Factory
626
+ // =============================================================================
627
+
628
+ export function createSettingsMenu(config?: SettingsMenuConfig): SettingsMenu {
629
+ return new SettingsMenu(config)
630
+ }