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,910 @@
1
+ /**
2
+ * ts-video-player UI Components
3
+ *
4
+ * UI components for building video player interfaces.
5
+ *
6
+ * @module ui/components
7
+ */
8
+
9
+ import type { Player, PlayerState, UIComponent, SliderOptions, MenuItem } from '../types'
10
+
11
+ // =============================================================================
12
+ // Base Component
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Base class for UI components
17
+ */
18
+ export abstract class BaseComponent implements UIComponent {
19
+ abstract name: string
20
+ protected player: Player | null = null
21
+ protected element: HTMLElement | null = null
22
+ protected unsubscribes: Array<() => void> = []
23
+
24
+ render(player: Player): HTMLElement {
25
+ this.player = player
26
+ this.element = this.createElement()
27
+ this.attachEvents()
28
+ this.subscribeToState()
29
+ return this.element
30
+ }
31
+
32
+ protected abstract createElement(): HTMLElement
33
+ protected abstract attachEvents(): void
34
+
35
+ protected subscribeToState(): void {
36
+ // Override in subclass
37
+ }
38
+
39
+ update(_state: PlayerState): void {
40
+ // Override in subclass
41
+ }
42
+
43
+ destroy(): void {
44
+ this.unsubscribes.forEach((fn) => fn())
45
+ this.unsubscribes = []
46
+ this.element?.remove()
47
+ this.element = null
48
+ this.player = null
49
+ }
50
+
51
+ protected el<T extends HTMLElement>(
52
+ tag: string,
53
+ attrs: Record<string, string> = {},
54
+ children?: (HTMLElement | string)[],
55
+ ): T {
56
+ const element = document.createElement(tag) as T
57
+ for (const [key, value] of Object.entries(attrs)) {
58
+ if (key === 'class') {
59
+ element.className = value
60
+ } else {
61
+ element.setAttribute(key, value)
62
+ }
63
+ }
64
+ if (children) {
65
+ for (const child of children) {
66
+ if (typeof child === 'string') {
67
+ element.appendChild(document.createTextNode(child))
68
+ } else {
69
+ element.appendChild(child)
70
+ }
71
+ }
72
+ }
73
+ return element
74
+ }
75
+ }
76
+
77
+ // =============================================================================
78
+ // Play Button
79
+ // =============================================================================
80
+
81
+ export class PlayButton extends BaseComponent {
82
+ name = 'play-button'
83
+
84
+ protected createElement(): HTMLElement {
85
+ return this.el('button', {
86
+ class: 'ts-video-btn ts-video-btn--play',
87
+ 'aria-label': 'Play',
88
+ type: 'button',
89
+ }, [this.getIcon()])
90
+ }
91
+
92
+ protected attachEvents(): void {
93
+ this.element?.addEventListener('click', () => {
94
+ this.player?.togglePlay()
95
+ })
96
+ }
97
+
98
+ protected subscribeToState(): void {
99
+ if (!this.player) return
100
+ const unsub = (this.player as any)._store?.subscribe('paused', (state: PlayerState) => {
101
+ this.update(state)
102
+ })
103
+ if (unsub) this.unsubscribes.push(unsub)
104
+ }
105
+
106
+ update(state: PlayerState): void {
107
+ if (!this.element) return
108
+ this.element.setAttribute('aria-label', state.paused ? 'Play' : 'Pause')
109
+ this.element.innerHTML = ''
110
+ this.element.appendChild(this.getIcon(state.paused))
111
+ }
112
+
113
+ private getIcon(paused = true): HTMLElement {
114
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
115
+ svg.setAttribute('viewBox', '0 0 24 24')
116
+ svg.setAttribute('width', '24')
117
+ svg.setAttribute('height', '24')
118
+ svg.setAttribute('fill', 'currentColor')
119
+
120
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
121
+ path.setAttribute('d', paused ? 'M8 5v14l11-7z' : 'M6 19h4V5H6v14zm8-14v14h4V5h-4z')
122
+ svg.appendChild(path)
123
+
124
+ return svg as unknown as HTMLElement
125
+ }
126
+ }
127
+
128
+ // =============================================================================
129
+ // Mute Button
130
+ // =============================================================================
131
+
132
+ export class MuteButton extends BaseComponent {
133
+ name = 'mute-button'
134
+
135
+ protected createElement(): HTMLElement {
136
+ return this.el('button', {
137
+ class: 'ts-video-btn ts-video-btn--mute',
138
+ 'aria-label': 'Mute',
139
+ type: 'button',
140
+ }, [this.getIcon(false, 1)])
141
+ }
142
+
143
+ protected attachEvents(): void {
144
+ this.element?.addEventListener('click', () => {
145
+ this.player?.toggleMute()
146
+ })
147
+ }
148
+
149
+ update(state: PlayerState): void {
150
+ if (!this.element) return
151
+ this.element.setAttribute('aria-label', state.muted ? 'Unmute' : 'Mute')
152
+ this.element.innerHTML = ''
153
+ this.element.appendChild(this.getIcon(state.muted, state.volume))
154
+ }
155
+
156
+ private getIcon(muted: boolean, volume: number): HTMLElement {
157
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
158
+ svg.setAttribute('viewBox', '0 0 24 24')
159
+ svg.setAttribute('width', '24')
160
+ svg.setAttribute('height', '24')
161
+ svg.setAttribute('fill', 'currentColor')
162
+
163
+ let d: string
164
+ if (muted || volume === 0) {
165
+ d = 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z'
166
+ } else if (volume < 0.5) {
167
+ d = 'M18.5 12A4.5 4.5 0 0 0 16 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z'
168
+ } else {
169
+ d = 'M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z'
170
+ }
171
+
172
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
173
+ path.setAttribute('d', d)
174
+ svg.appendChild(path)
175
+
176
+ return svg as unknown as HTMLElement
177
+ }
178
+ }
179
+
180
+ // =============================================================================
181
+ // Fullscreen Button
182
+ // =============================================================================
183
+
184
+ export class FullscreenButton extends BaseComponent {
185
+ name = 'fullscreen-button'
186
+
187
+ protected createElement(): HTMLElement {
188
+ return this.el('button', {
189
+ class: 'ts-video-btn ts-video-btn--fullscreen',
190
+ 'aria-label': 'Enter fullscreen',
191
+ type: 'button',
192
+ }, [this.getIcon(false)])
193
+ }
194
+
195
+ protected attachEvents(): void {
196
+ this.element?.addEventListener('click', () => {
197
+ this.player?.toggleFullscreen()
198
+ })
199
+ }
200
+
201
+ update(state: PlayerState): void {
202
+ if (!this.element) return
203
+ const btn = this.element as HTMLButtonElement
204
+ const disabled = state.fullscreenAvailability !== 'available'
205
+ btn.disabled = disabled
206
+ btn.style.display = state.fullscreenAvailability === 'unsupported' ? 'none' : ''
207
+ btn.setAttribute('aria-label', state.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen')
208
+ btn.innerHTML = ''
209
+ btn.appendChild(this.getIcon(state.fullscreen))
210
+ }
211
+
212
+ private getIcon(fullscreen: boolean): HTMLElement {
213
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
214
+ svg.setAttribute('viewBox', '0 0 24 24')
215
+ svg.setAttribute('width', '24')
216
+ svg.setAttribute('height', '24')
217
+ svg.setAttribute('fill', 'currentColor')
218
+
219
+ const d = fullscreen
220
+ ? 'M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z'
221
+ : 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'
222
+
223
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
224
+ path.setAttribute('d', d)
225
+ svg.appendChild(path)
226
+
227
+ return svg as unknown as HTMLElement
228
+ }
229
+ }
230
+
231
+ // =============================================================================
232
+ // PiP Button
233
+ // =============================================================================
234
+
235
+ export class PiPButton extends BaseComponent {
236
+ name = 'pip-button'
237
+
238
+ protected createElement(): HTMLElement {
239
+ return this.el('button', {
240
+ class: 'ts-video-btn ts-video-btn--pip',
241
+ 'aria-label': 'Picture in picture',
242
+ type: 'button',
243
+ }, [this.getIcon()])
244
+ }
245
+
246
+ protected attachEvents(): void {
247
+ this.element?.addEventListener('click', () => {
248
+ this.player?.togglePiP()
249
+ })
250
+ }
251
+
252
+ update(state: PlayerState): void {
253
+ if (!this.element) return
254
+ const btn = this.element as HTMLButtonElement
255
+ const disabled = state.pipAvailability !== 'available'
256
+ btn.disabled = disabled
257
+ btn.style.display = state.pipAvailability === 'unsupported' ? 'none' : ''
258
+ }
259
+
260
+ private getIcon(): HTMLElement {
261
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
262
+ svg.setAttribute('viewBox', '0 0 24 24')
263
+ svg.setAttribute('width', '24')
264
+ svg.setAttribute('height', '24')
265
+ svg.setAttribute('fill', 'currentColor')
266
+
267
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
268
+ path.setAttribute('d', 'M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z')
269
+ svg.appendChild(path)
270
+
271
+ return svg as unknown as HTMLElement
272
+ }
273
+ }
274
+
275
+ // =============================================================================
276
+ // Time Display
277
+ // =============================================================================
278
+
279
+ export class TimeDisplay extends BaseComponent {
280
+ name = 'time-display'
281
+ private showRemaining = false
282
+
283
+ protected createElement(): HTMLElement {
284
+ const container = this.el('div', { class: 'ts-video-time' })
285
+
286
+ const current = this.el('span', { class: 'ts-video-time__current' }, ['0:00'])
287
+ const separator = this.el('span', { class: 'ts-video-time__separator' }, [' / '])
288
+ const duration = this.el('span', { class: 'ts-video-time__duration' }, ['0:00'])
289
+
290
+ container.appendChild(current)
291
+ container.appendChild(separator)
292
+ container.appendChild(duration)
293
+
294
+ return container
295
+ }
296
+
297
+ protected attachEvents(): void {
298
+ this.element?.addEventListener('click', () => {
299
+ this.showRemaining = !this.showRemaining
300
+ if (this.player) {
301
+ this.update(this.player.state)
302
+ }
303
+ })
304
+ }
305
+
306
+ update(state: PlayerState): void {
307
+ if (!this.element) return
308
+
309
+ const current = this.element.querySelector('.ts-video-time__current')
310
+ const duration = this.element.querySelector('.ts-video-time__duration')
311
+
312
+ if (current) {
313
+ current.textContent = formatTime(state.currentTime)
314
+ }
315
+
316
+ if (duration) {
317
+ if (this.showRemaining) {
318
+ duration.textContent = `-${formatTime(state.duration - state.currentTime)}`
319
+ } else {
320
+ duration.textContent = formatTime(state.duration)
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ // =============================================================================
327
+ // Slider Component
328
+ // =============================================================================
329
+
330
+ export class Slider extends BaseComponent {
331
+ name = 'slider'
332
+ private options: SliderOptions
333
+ private track: HTMLElement | null = null
334
+ private fill: HTMLElement | null = null
335
+ private thumb: HTMLElement | null = null
336
+ private isDragging = false
337
+ private boundPointerMove: (e: PointerEvent) => void
338
+ private boundPointerUp: (e: PointerEvent) => void
339
+
340
+ constructor(options: SliderOptions) {
341
+ super()
342
+ this.options = {
343
+ orientation: 'horizontal',
344
+ disabled: false,
345
+ ...options,
346
+ }
347
+ this.boundPointerMove = this.onPointerMove.bind(this)
348
+ this.boundPointerUp = this.onPointerUp.bind(this)
349
+ }
350
+
351
+ protected createElement(): HTMLElement {
352
+ const container = this.el('div', {
353
+ class: `ts-video-slider ts-video-slider--${this.options.orientation}`,
354
+ role: 'slider',
355
+ 'aria-valuemin': String(this.options.min),
356
+ 'aria-valuemax': String(this.options.max),
357
+ 'aria-valuenow': String(this.options.value),
358
+ tabindex: '0',
359
+ })
360
+
361
+ this.track = this.el('div', { class: 'ts-video-slider__track' })
362
+ this.fill = this.el('div', { class: 'ts-video-slider__fill' })
363
+ this.thumb = this.el('div', { class: 'ts-video-slider__thumb' })
364
+
365
+ this.track.appendChild(this.fill)
366
+ this.track.appendChild(this.thumb)
367
+ container.appendChild(this.track)
368
+
369
+ this.updateVisual(this.options.value)
370
+
371
+ return container
372
+ }
373
+
374
+ protected attachEvents(): void {
375
+ if (!this.element) return
376
+
377
+ // Pointer events
378
+ this.element.addEventListener('pointerdown', this.onPointerDown.bind(this))
379
+ document.addEventListener('pointermove', this.boundPointerMove)
380
+ document.addEventListener('pointerup', this.boundPointerUp)
381
+
382
+ // Keyboard events
383
+ this.element.addEventListener('keydown', this.onKeyDown.bind(this))
384
+ }
385
+
386
+ private onPointerDown(e: PointerEvent): void {
387
+ if (this.options.disabled) return
388
+ this.isDragging = true
389
+ this.element?.setPointerCapture(e.pointerId)
390
+ this.updateFromPointer(e)
391
+ }
392
+
393
+ private onPointerMove(e: PointerEvent): void {
394
+ if (!this.isDragging) return
395
+ this.updateFromPointer(e)
396
+ }
397
+
398
+ private onPointerUp(e: PointerEvent): void {
399
+ if (!this.isDragging) return
400
+ this.isDragging = false
401
+ this.element?.releasePointerCapture(e.pointerId)
402
+ this.options.onChange?.(this.options.value)
403
+ }
404
+
405
+ private onKeyDown(e: KeyboardEvent): void {
406
+ if (this.options.disabled) return
407
+
408
+ let newValue = this.options.value
409
+ const step = this.options.step
410
+
411
+ switch (e.key) {
412
+ case 'ArrowRight':
413
+ case 'ArrowUp':
414
+ newValue = Math.min(this.options.max, newValue + step)
415
+ break
416
+ case 'ArrowLeft':
417
+ case 'ArrowDown':
418
+ newValue = Math.max(this.options.min, newValue - step)
419
+ break
420
+ case 'Home':
421
+ newValue = this.options.min
422
+ break
423
+ case 'End':
424
+ newValue = this.options.max
425
+ break
426
+ default:
427
+ return
428
+ }
429
+
430
+ e.preventDefault()
431
+ this.setValue(newValue)
432
+ this.options.onChange?.(newValue)
433
+ }
434
+
435
+ private updateFromPointer(e: PointerEvent): void {
436
+ if (!this.track) return
437
+
438
+ const rect = this.track.getBoundingClientRect()
439
+ const isHorizontal = this.options.orientation === 'horizontal'
440
+
441
+ let percent: number
442
+ if (isHorizontal) {
443
+ percent = (e.clientX - rect.left) / rect.width
444
+ } else {
445
+ percent = 1 - (e.clientY - rect.top) / rect.height
446
+ }
447
+
448
+ percent = Math.max(0, Math.min(1, percent))
449
+ const value = this.options.min + percent * (this.options.max - this.options.min)
450
+
451
+ // Snap to step
452
+ const snapped = Math.round(value / this.options.step) * this.options.step
453
+
454
+ this.setValue(snapped)
455
+ this.options.onInput?.(snapped)
456
+ }
457
+
458
+ setValue(value: number): void {
459
+ this.options.value = Math.max(this.options.min, Math.min(this.options.max, value))
460
+ this.updateVisual(this.options.value)
461
+ this.element?.setAttribute('aria-valuenow', String(this.options.value))
462
+ }
463
+
464
+ private updateVisual(value: number): void {
465
+ const percent = ((value - this.options.min) / (this.options.max - this.options.min)) * 100
466
+
467
+ if (this.fill) {
468
+ if (this.options.orientation === 'horizontal') {
469
+ this.fill.style.width = `${percent}%`
470
+ } else {
471
+ this.fill.style.height = `${percent}%`
472
+ }
473
+ }
474
+
475
+ if (this.thumb) {
476
+ if (this.options.orientation === 'horizontal') {
477
+ this.thumb.style.left = `${percent}%`
478
+ } else {
479
+ this.thumb.style.bottom = `${percent}%`
480
+ }
481
+ }
482
+ }
483
+
484
+ destroy(): void {
485
+ document.removeEventListener('pointermove', this.boundPointerMove)
486
+ document.removeEventListener('pointerup', this.boundPointerUp)
487
+ super.destroy()
488
+ }
489
+ }
490
+
491
+ // =============================================================================
492
+ // Progress Bar (Time Slider)
493
+ // =============================================================================
494
+
495
+ export class ProgressBar extends BaseComponent {
496
+ name = 'progress-bar'
497
+ private slider: Slider | null = null
498
+ private buffered: HTMLElement | null = null
499
+ private isSeeking = false
500
+
501
+ protected createElement(): HTMLElement {
502
+ const container = this.el('div', { class: 'ts-video-progress' })
503
+
504
+ // Buffered indicator
505
+ this.buffered = this.el('div', { class: 'ts-video-progress__buffered' })
506
+
507
+ // Slider
508
+ this.slider = new Slider({
509
+ min: 0,
510
+ max: 100,
511
+ step: 0.1,
512
+ value: 0,
513
+ onInput: (value) => {
514
+ this.isSeeking = true
515
+ const time = (value / 100) * (this.player?.state.duration || 0)
516
+ this.player?.seekTo(time)
517
+ },
518
+ onChange: () => {
519
+ this.isSeeking = false
520
+ },
521
+ })
522
+
523
+ const sliderEl = this.slider.render(this.player!)
524
+ sliderEl.classList.add('ts-video-progress__slider')
525
+
526
+ // Insert buffered before slider track
527
+ const track = sliderEl.querySelector('.ts-video-slider__track')
528
+ if (track) {
529
+ track.insertBefore(this.buffered, track.firstChild)
530
+ }
531
+
532
+ container.appendChild(sliderEl)
533
+
534
+ return container
535
+ }
536
+
537
+ protected attachEvents(): void {
538
+ // Events handled by slider
539
+ }
540
+
541
+ update(state: PlayerState): void {
542
+ if (!this.slider || this.isSeeking) return
543
+
544
+ // Update progress
545
+ const percent = state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0
546
+ this.slider.setValue(percent)
547
+
548
+ // Update buffered
549
+ if (this.buffered) {
550
+ const bufferedPercent = state.bufferedAmount * 100
551
+ this.buffered.style.width = `${bufferedPercent}%`
552
+ }
553
+ }
554
+
555
+ destroy(): void {
556
+ this.slider?.destroy()
557
+ super.destroy()
558
+ }
559
+ }
560
+
561
+ // =============================================================================
562
+ // Volume Slider
563
+ // =============================================================================
564
+
565
+ export class VolumeSlider extends BaseComponent {
566
+ name = 'volume-slider'
567
+ private slider: Slider | null = null
568
+
569
+ protected createElement(): HTMLElement {
570
+ const container = this.el('div', { class: 'ts-video-volume' })
571
+
572
+ this.slider = new Slider({
573
+ min: 0,
574
+ max: 1,
575
+ step: 0.01,
576
+ value: this.player?.state.volume || 1,
577
+ onInput: (value) => {
578
+ this.player?.setVolume(value)
579
+ },
580
+ })
581
+
582
+ container.appendChild(this.slider.render(this.player!))
583
+
584
+ return container
585
+ }
586
+
587
+ protected attachEvents(): void {
588
+ // Events handled by slider
589
+ }
590
+
591
+ update(state: PlayerState): void {
592
+ if (!this.slider) return
593
+ if (this.element) {
594
+ this.element.style.display = state.volumeAvailability === 'unsupported' ? 'none' : ''
595
+ }
596
+ this.slider.setValue(state.muted ? 0 : state.volume)
597
+ }
598
+
599
+ destroy(): void {
600
+ this.slider?.destroy()
601
+ super.destroy()
602
+ }
603
+ }
604
+
605
+ // =============================================================================
606
+ // Menu
607
+ // =============================================================================
608
+
609
+ export class Menu extends BaseComponent {
610
+ name = 'menu'
611
+ private items: MenuItem[]
612
+ private isOpen = false
613
+ private menuEl: HTMLElement | null = null
614
+ private onSelect?: (item: MenuItem) => void
615
+
616
+ constructor(items: MenuItem[], onSelect?: (item: MenuItem) => void) {
617
+ super()
618
+ this.items = items
619
+ this.onSelect = onSelect
620
+ }
621
+
622
+ protected createElement(): HTMLElement {
623
+ const container = this.el('div', { class: 'ts-video-menu' })
624
+
625
+ // Trigger button
626
+ const trigger = this.el('button', {
627
+ class: 'ts-video-btn ts-video-menu__trigger',
628
+ 'aria-label': 'Settings',
629
+ 'aria-haspopup': 'true',
630
+ 'aria-expanded': 'false',
631
+ type: 'button',
632
+ }, [this.getIcon()])
633
+
634
+ // Menu content
635
+ this.menuEl = this.el('div', {
636
+ class: 'ts-video-menu__content',
637
+ role: 'menu',
638
+ 'aria-hidden': 'true',
639
+ })
640
+
641
+ this.renderItems()
642
+
643
+ container.appendChild(trigger)
644
+ container.appendChild(this.menuEl)
645
+
646
+ return container
647
+ }
648
+
649
+ private renderItems(): void {
650
+ if (!this.menuEl) return
651
+ this.menuEl.innerHTML = ''
652
+
653
+ for (const item of this.items) {
654
+ const itemEl = this.el('button', {
655
+ class: `ts-video-menu__item${item.checked ? ' ts-video-menu__item--checked' : ''}${item.disabled ? ' ts-video-menu__item--disabled' : ''}`,
656
+ role: 'menuitem',
657
+ 'data-id': item.id,
658
+ type: 'button',
659
+ }, [item.label])
660
+
661
+ itemEl.addEventListener('click', () => {
662
+ if (!item.disabled) {
663
+ this.onSelect?.(item)
664
+ this.close()
665
+ }
666
+ })
667
+
668
+ this.menuEl.appendChild(itemEl)
669
+ }
670
+ }
671
+
672
+ protected attachEvents(): void {
673
+ const trigger = this.element?.querySelector('.ts-video-menu__trigger')
674
+ trigger?.addEventListener('click', () => this.toggle())
675
+
676
+ // Close on outside click
677
+ const onDocClick = (e: Event) => {
678
+ if (this.isOpen && !this.element?.contains(e.target as Node)) {
679
+ this.close()
680
+ }
681
+ }
682
+ document.addEventListener('click', onDocClick)
683
+ this.unsubscribes.push(() => document.removeEventListener('click', onDocClick))
684
+
685
+ // Close on escape
686
+ const onDocKeydown = (e: KeyboardEvent) => {
687
+ if (e.key === 'Escape' && this.isOpen) {
688
+ this.close()
689
+ }
690
+ }
691
+ document.addEventListener('keydown', onDocKeydown)
692
+ this.unsubscribes.push(() => document.removeEventListener('keydown', onDocKeydown))
693
+ }
694
+
695
+ toggle(): void {
696
+ if (this.isOpen) {
697
+ this.close()
698
+ } else {
699
+ this.open()
700
+ }
701
+ }
702
+
703
+ open(): void {
704
+ this.isOpen = true
705
+ this.menuEl?.setAttribute('aria-hidden', 'false')
706
+ this.element?.querySelector('.ts-video-menu__trigger')?.setAttribute('aria-expanded', 'true')
707
+ this.element?.classList.add('ts-video-menu--open')
708
+ }
709
+
710
+ close(): void {
711
+ this.isOpen = false
712
+ this.menuEl?.setAttribute('aria-hidden', 'true')
713
+ this.element?.querySelector('.ts-video-menu__trigger')?.setAttribute('aria-expanded', 'false')
714
+ this.element?.classList.remove('ts-video-menu--open')
715
+ }
716
+
717
+ setItems(items: MenuItem[]): void {
718
+ this.items = items
719
+ this.renderItems()
720
+ }
721
+
722
+ private getIcon(): HTMLElement {
723
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
724
+ svg.setAttribute('viewBox', '0 0 24 24')
725
+ svg.setAttribute('width', '24')
726
+ svg.setAttribute('height', '24')
727
+ svg.setAttribute('fill', 'currentColor')
728
+
729
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
730
+ path.setAttribute('d', 'M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z')
731
+ svg.appendChild(path)
732
+
733
+ return svg as unknown as HTMLElement
734
+ }
735
+ }
736
+
737
+ // =============================================================================
738
+ // Helpers
739
+ // =============================================================================
740
+
741
+ /**
742
+ * Format time in seconds to MM:SS or HH:MM:SS
743
+ */
744
+ export function formatTime(seconds: number): string {
745
+ if (!isFinite(seconds) || isNaN(seconds)) return '0:00'
746
+
747
+ const negative = seconds < 0
748
+ const abs = Math.abs(seconds)
749
+
750
+ const h = Math.floor(abs / 3600)
751
+ const m = Math.floor((abs % 3600) / 60)
752
+ const s = Math.floor(abs % 60)
753
+
754
+ const prefix = negative ? '-' : ''
755
+
756
+ if (h > 0) {
757
+ return `${prefix}${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
758
+ }
759
+
760
+ return `${prefix}${m}:${s.toString().padStart(2, '0')}`
761
+ }
762
+
763
+ // =============================================================================
764
+ // CSS Styles
765
+ // =============================================================================
766
+
767
+ export const UI_STYLES = `
768
+ .ts-video-btn {
769
+ display: inline-flex;
770
+ align-items: center;
771
+ justify-content: center;
772
+ width: 40px;
773
+ height: 40px;
774
+ padding: 8px;
775
+ background: transparent;
776
+ border: none;
777
+ color: white;
778
+ cursor: pointer;
779
+ transition: opacity 0.2s;
780
+ }
781
+
782
+ .ts-video-btn:hover {
783
+ opacity: 0.8;
784
+ }
785
+
786
+ .ts-video-btn:disabled {
787
+ opacity: 0.4;
788
+ cursor: not-allowed;
789
+ }
790
+
791
+ .ts-video-time {
792
+ font-size: 14px;
793
+ color: white;
794
+ font-variant-numeric: tabular-nums;
795
+ user-select: none;
796
+ cursor: pointer;
797
+ }
798
+
799
+ .ts-video-slider {
800
+ position: relative;
801
+ width: 100%;
802
+ height: 20px;
803
+ cursor: pointer;
804
+ }
805
+
806
+ .ts-video-slider__track {
807
+ position: absolute;
808
+ top: 50%;
809
+ left: 0;
810
+ right: 0;
811
+ height: 4px;
812
+ background: rgba(255, 255, 255, 0.3);
813
+ border-radius: 2px;
814
+ transform: translateY(-50%);
815
+ }
816
+
817
+ .ts-video-slider__fill {
818
+ position: absolute;
819
+ top: 0;
820
+ left: 0;
821
+ height: 100%;
822
+ background: #fff;
823
+ border-radius: 2px;
824
+ }
825
+
826
+ .ts-video-slider__thumb {
827
+ position: absolute;
828
+ top: 50%;
829
+ width: 12px;
830
+ height: 12px;
831
+ background: #fff;
832
+ border-radius: 50%;
833
+ transform: translate(-50%, -50%);
834
+ transition: transform 0.1s;
835
+ }
836
+
837
+ .ts-video-slider:hover .ts-video-slider__thumb {
838
+ transform: translate(-50%, -50%) scale(1.2);
839
+ }
840
+
841
+ .ts-video-progress {
842
+ flex: 1;
843
+ margin: 0 12px;
844
+ }
845
+
846
+ .ts-video-progress__buffered {
847
+ position: absolute;
848
+ top: 0;
849
+ left: 0;
850
+ height: 100%;
851
+ background: rgba(255, 255, 255, 0.4);
852
+ border-radius: 2px;
853
+ }
854
+
855
+ .ts-video-volume {
856
+ width: 80px;
857
+ }
858
+
859
+ .ts-video-menu {
860
+ position: relative;
861
+ }
862
+
863
+ .ts-video-menu__content {
864
+ position: absolute;
865
+ bottom: 100%;
866
+ right: 0;
867
+ min-width: 150px;
868
+ max-height: 200px;
869
+ overflow-y: auto;
870
+ background: rgba(28, 28, 28, 0.95);
871
+ border-radius: 4px;
872
+ padding: 4px 0;
873
+ opacity: 0;
874
+ visibility: hidden;
875
+ transform: translateY(10px);
876
+ transition: opacity 0.2s, transform 0.2s, visibility 0.2s;
877
+ }
878
+
879
+ .ts-video-menu--open .ts-video-menu__content {
880
+ opacity: 1;
881
+ visibility: visible;
882
+ transform: translateY(0);
883
+ }
884
+
885
+ .ts-video-menu__item {
886
+ display: block;
887
+ width: 100%;
888
+ padding: 8px 16px;
889
+ background: none;
890
+ border: none;
891
+ color: white;
892
+ text-align: left;
893
+ cursor: pointer;
894
+ font-size: 14px;
895
+ }
896
+
897
+ .ts-video-menu__item:hover {
898
+ background: rgba(255, 255, 255, 0.1);
899
+ }
900
+
901
+ .ts-video-menu__item--checked::before {
902
+ content: '✓';
903
+ margin-right: 8px;
904
+ }
905
+
906
+ .ts-video-menu__item--disabled {
907
+ opacity: 0.5;
908
+ cursor: not-allowed;
909
+ }
910
+ `