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,108 @@
1
+ /**
2
+ * ts-video-player <media-remote-playback-button> Custom Element
3
+ *
4
+ * Uses the browser Remote Playback API for native device pickers such as
5
+ * Google Cast without loading a vendor SDK.
6
+ *
7
+ * @module elements/media-remote-playback-button
8
+ */
9
+
10
+ import { HTMLElementBase } from './base'
11
+ import { resolvePlayer } from './utils'
12
+
13
+ const REMOTE_ICON = 'M1 18v3h3a3 3 0 0 0-3-3zm0-4v2a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7zm0-4v2c5 0 9 4 9 9h2c0-6.1-4.9-11-11-11zm18-7H5a2 2 0 0 0-2 2v3h2V5h14v10h-5v2h5a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z'
14
+
15
+ interface RemotePlaybackHandle extends EventTarget {
16
+ state: 'connecting' | 'connected' | 'disconnected'
17
+ prompt: () => Promise<void>
18
+ watchAvailability: (_callback: (_available: boolean) => void) => Promise<number>
19
+ cancelWatchAvailability: (_id: number) => Promise<void>
20
+ }
21
+
22
+ export class MediaRemotePlaybackButton extends HTMLElementBase {
23
+ private cleanup: (() => void) | null = null
24
+
25
+ connectedCallback(): void {
26
+ if (!this.shadowRoot) {
27
+ const shadow = this.attachShadow({ mode: 'open' })
28
+ shadow.innerHTML = `
29
+ <style>
30
+ :host { display: none; }
31
+ :host([available]) { display: inline-flex; }
32
+ button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
33
+ button:hover { opacity: 0.8; }
34
+ :host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
35
+ </style>
36
+ <button part="button" type="button" aria-label="Play on another device">
37
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
38
+ </button>
39
+ `
40
+ }
41
+ queueMicrotask(() => this.attach())
42
+ }
43
+
44
+ disconnectedCallback(): void {
45
+ this.cleanup?.()
46
+ this.cleanup = null
47
+ }
48
+
49
+ private attach(): void {
50
+ const player = resolvePlayer(this)
51
+ const button = this.shadowRoot?.querySelector('button')
52
+ if (!player || !button) return
53
+ let remote: RemotePlaybackHandle | null = null
54
+ let watchId: number | null = null
55
+ let generation = 0
56
+
57
+ const stateChanged = (): void => {
58
+ const connected = remote?.state === 'connected'
59
+ this.toggleAttribute('connected', connected)
60
+ button.setAttribute('aria-pressed', String(connected))
61
+ }
62
+ const detachRemote = (): void => {
63
+ generation++
64
+ remote?.removeEventListener('connecting', stateChanged)
65
+ remote?.removeEventListener('connect', stateChanged)
66
+ remote?.removeEventListener('disconnect', stateChanged)
67
+ if (remote && watchId !== null) void remote.cancelWatchAvailability(watchId).catch(() => {})
68
+ remote = null
69
+ watchId = null
70
+ this.removeAttribute('available')
71
+ this.removeAttribute('connected')
72
+ }
73
+ const update = (): void => {
74
+ const media = player.provider?.mediaElement
75
+ const nextRemote = media instanceof HTMLMediaElement
76
+ ? (media as HTMLMediaElement & { remote?: RemotePlaybackHandle }).remote ?? null
77
+ : null
78
+ if (nextRemote === remote) return
79
+ detachRemote()
80
+ if (!nextRemote) return
81
+ remote = nextRemote
82
+ const currentGeneration = generation
83
+ remote.addEventListener('connecting', stateChanged)
84
+ remote.addEventListener('connect', stateChanged)
85
+ remote.addEventListener('disconnect', stateChanged)
86
+ stateChanged()
87
+ void remote.watchAvailability((available) => {
88
+ if (generation === currentGeneration && remote === nextRemote) this.toggleAttribute('available', available)
89
+ })
90
+ .then((id) => {
91
+ if (generation === currentGeneration && remote === nextRemote) watchId = id
92
+ else void nextRemote.cancelWatchAvailability(id).catch(() => {})
93
+ })
94
+ .catch(() => this.removeAttribute('available'))
95
+ }
96
+ const click = (): void => {
97
+ void remote?.prompt().catch(() => {})
98
+ }
99
+ button.addEventListener('click', click)
100
+ const unsubscribe = player.subscribe(update)
101
+ update()
102
+ this.cleanup = () => {
103
+ unsubscribe()
104
+ button.removeEventListener('click', click)
105
+ detachRemote()
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,50 @@
1
+ import { resolvePlayer } from './utils'
2
+ import type { PlayerState } from '../types'
3
+
4
+ import { HTMLElementBase } from './base'
5
+
6
+ export class MediaSeekButton extends HTMLElementBase {
7
+ private cleanup: (() => void) | null = null
8
+ private onClick: (() => void) | null = null
9
+
10
+ connectedCallback(): void {
11
+ if (!this.shadowRoot) {
12
+ const shadow = this.attachShadow({ mode: 'open' })
13
+ shadow.innerHTML = `
14
+ <style>
15
+ :host { display: inline-flex; }
16
+ button { min-width: 40px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; }
17
+ button:hover { opacity: .8; }
18
+ button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
19
+ button:disabled { cursor: default; opacity: .45; }
20
+ </style>
21
+ <button part="button" type="button"></button>
22
+ `
23
+ }
24
+ queueMicrotask(() => this.attach())
25
+ }
26
+
27
+ disconnectedCallback(): void {
28
+ const button = this.shadowRoot?.querySelector('button')
29
+ if (button && this.onClick) button.removeEventListener('click', this.onClick)
30
+ this.cleanup?.()
31
+ this.cleanup = null
32
+ this.onClick = null
33
+ }
34
+
35
+ private attach(): void {
36
+ const player = resolvePlayer(this)
37
+ const button = this.shadowRoot?.querySelector('button')
38
+ if (!player || !button) return
39
+ const seconds = Number.parseFloat(this.getAttribute('seconds') ?? '10')
40
+ const offset = Number.isFinite(seconds) && seconds !== 0 ? seconds : 10
41
+ const label = offset > 0 ? `Forward ${offset} seconds` : `Back ${Math.abs(offset)} seconds`
42
+ button.textContent = offset > 0 ? `+${offset}` : String(offset)
43
+ button.setAttribute('aria-label', label)
44
+ this.onClick = () => player.seekBy(offset)
45
+ button.addEventListener('click', this.onClick)
46
+ this.cleanup = player.subscribe((state: PlayerState) => {
47
+ button.disabled = offset < 0 ? state.currentTime <= 0 : state.duration > 0 && state.currentTime >= state.duration
48
+ })
49
+ }
50
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * ts-video-player <media-settings-menu> Custom Element
3
+ *
4
+ * Settings dropdown with quality, speed, captions, and audio track options.
5
+ * Uses collision-aware popover positioning.
6
+ *
7
+ * @module elements/media-settings-menu
8
+ */
9
+
10
+ import { resolvePlayer } from './utils'
11
+ import { computePosition } from '../core/popover'
12
+
13
+ const GEAR_ICON = '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'
14
+
15
+ const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
16
+
17
+ import { HTMLElementBase } from './base'
18
+
19
+ export class MediaSettingsMenu extends HTMLElementBase {
20
+ private _cleanup: (() => void) | null = null
21
+ private _isOpen = false
22
+ private _currentPanel: 'main' | 'speed' | 'quality' | 'captions' | 'audio' = 'main'
23
+
24
+ connectedCallback(): void {
25
+ if (!this.shadowRoot) {
26
+ const shadow = this.attachShadow({ mode: 'open' })
27
+ shadow.innerHTML = `
28
+ <style>
29
+ :host { display: inline-flex; position: relative; }
30
+ button.trigger {
31
+ display: inline-flex; align-items: center; justify-content: center;
32
+ width: 40px; height: 40px; padding: 8px;
33
+ background: transparent; border: none; color: inherit; cursor: pointer;
34
+ }
35
+ button.trigger:hover { opacity: 0.8; }
36
+ .menu {
37
+ position: fixed; z-index: 9999;
38
+ min-width: 160px; max-height: 250px; overflow-y: auto;
39
+ background: rgba(28,28,28,0.95); border-radius: 4px;
40
+ padding: 4px 0; opacity: 0; visibility: hidden;
41
+ transform: translateY(4px);
42
+ transition: opacity 0.2s, transform 0.2s, visibility 0.2s;
43
+ }
44
+ :host([open]) .menu {
45
+ opacity: 1; visibility: visible; transform: translateY(0);
46
+ }
47
+ .item {
48
+ display: flex; align-items: center; gap: 8px;
49
+ width: 100%; padding: 8px 16px;
50
+ background: none; border: none; color: white;
51
+ text-align: left; cursor: pointer; font-size: 14px;
52
+ }
53
+ .item:hover { background: rgba(255,255,255,0.1); }
54
+ .item[aria-checked="true"]::before { content: '\\2713'; }
55
+ .item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
56
+ .back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
57
+ </style>
58
+ <button class="trigger" part="trigger" type="button"
59
+ aria-label="Settings" aria-haspopup="true" aria-expanded="false">
60
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
61
+ <path d="${GEAR_ICON}"/>
62
+ </svg>
63
+ </button>
64
+ <div class="menu" part="menu" role="menu" aria-hidden="true"></div>
65
+ `
66
+ }
67
+
68
+ const trigger = this.shadowRoot!.querySelector('.trigger')!
69
+ this._onTriggerClick = () => this.toggle()
70
+ trigger.addEventListener('click', this._onTriggerClick)
71
+
72
+ // Close on outside click
73
+ this._onDocClick = (e: MouseEvent) => {
74
+ if (this._isOpen && !this.contains(e.target as Node)) this.close()
75
+ }
76
+ document.addEventListener('click', this._onDocClick)
77
+
78
+ // Close on escape
79
+ this._onDocKeydown = (e: KeyboardEvent) => {
80
+ if (e.key === 'Escape' && this._isOpen) this.close()
81
+ }
82
+ document.addEventListener('keydown', this._onDocKeydown)
83
+
84
+ queueMicrotask(() => this.attach())
85
+ }
86
+
87
+ private _onTriggerClick: (() => void) | null = null
88
+ // eslint-disable-next-line pickier/no-unused-vars
89
+ private _onDocClick: ((e: MouseEvent) => void) | null = null
90
+ // eslint-disable-next-line pickier/no-unused-vars
91
+ private _onDocKeydown: ((e: KeyboardEvent) => void) | null = null
92
+
93
+ disconnectedCallback(): void {
94
+ this._cleanup?.()
95
+ this._cleanup = null
96
+ if (this._onTriggerClick) {
97
+ this.shadowRoot?.querySelector('.trigger')?.removeEventListener('click', this._onTriggerClick)
98
+ }
99
+ if (this._onDocClick) document.removeEventListener('click', this._onDocClick)
100
+ if (this._onDocKeydown) document.removeEventListener('keydown', this._onDocKeydown)
101
+ }
102
+
103
+ toggle(): void {
104
+ if (this._isOpen) this.close()
105
+ else this.open()
106
+ }
107
+
108
+ open(): void {
109
+ this._isOpen = true
110
+ this._currentPanel = 'main'
111
+ this.setAttribute('open', '')
112
+ this.shadowRoot!.querySelector('.trigger')!.setAttribute('aria-expanded', 'true')
113
+ this.shadowRoot!.querySelector('.menu')!.setAttribute('aria-hidden', 'false')
114
+ this.renderPanel()
115
+ this.positionMenu()
116
+ }
117
+
118
+ close(): void {
119
+ this._isOpen = false
120
+ this.removeAttribute('open')
121
+ this.shadowRoot!.querySelector('.trigger')!.setAttribute('aria-expanded', 'false')
122
+ this.shadowRoot!.querySelector('.menu')!.setAttribute('aria-hidden', 'true')
123
+ }
124
+
125
+ private positionMenu(): void {
126
+ const trigger = this.shadowRoot!.querySelector('.trigger') as HTMLElement
127
+ const menu = this.shadowRoot!.querySelector('.menu') as HTMLElement
128
+ if (!trigger || !menu) return
129
+
130
+ // Find the player container as boundary (for fullscreen)
131
+ const playerEl = this.closest('video-player, .ts-video-player') as HTMLElement | null
132
+
133
+ const result = computePosition(trigger, menu, {
134
+ placement: 'top-end',
135
+ offset: 4,
136
+ boundary: playerEl,
137
+ padding: 8,
138
+ })
139
+
140
+ menu.style.top = `${result.top}px`
141
+ menu.style.left = `${result.left}px`
142
+ }
143
+
144
+ private attach(): void {
145
+ const player = resolvePlayer(this)
146
+ if (!player) return
147
+
148
+ const unsub = player.subscribe(() => {
149
+ if (this._isOpen) this.renderPanel()
150
+ })
151
+
152
+ this._cleanup = unsub
153
+ }
154
+
155
+ private renderPanel(): void {
156
+ const player = resolvePlayer(this)
157
+ if (!player) return
158
+
159
+ const menu = this.shadowRoot!.querySelector('.menu')!
160
+ menu.innerHTML = ''
161
+
162
+ switch (this._currentPanel) {
163
+ case 'main': this.renderMain(menu, player); break
164
+ case 'speed': this.renderSpeed(menu, player); break
165
+ case 'quality': this.renderQuality(menu, player); break
166
+ case 'captions': this.renderCaptions(menu, player); break
167
+ case 'audio': this.renderAudio(menu, player); break
168
+ }
169
+
170
+ // Re-position after content changes
171
+ if (this._isOpen) {
172
+ queueMicrotask(() => this.positionMenu())
173
+ }
174
+ }
175
+
176
+ private renderMain(menu: Element, player: any): void {
177
+ const state = player.state
178
+
179
+ // Speed
180
+ this.addItem(menu, `Speed: ${state.playbackRate}x`, () => {
181
+ this._currentPanel = 'speed'
182
+ this.renderPanel()
183
+ })
184
+
185
+ // Quality
186
+ if (state.qualities.length > 0) {
187
+ const current = state.qualities.find((q: any) => q.selected)
188
+ const label = state.autoQuality ? 'Auto' : (current ? `${current.height}p` : 'Auto')
189
+ this.addItem(menu, `Quality: ${label}`, () => {
190
+ this._currentPanel = 'quality'
191
+ this.renderPanel()
192
+ })
193
+ }
194
+
195
+ // Captions
196
+ if (state.textTracks.length > 0) {
197
+ const showing = state.textTracks.find((t: any) => t.mode === 'showing')
198
+ this.addItem(menu, `Captions: ${showing?.label || 'Off'}`, () => {
199
+ this._currentPanel = 'captions'
200
+ this.renderPanel()
201
+ })
202
+ }
203
+
204
+ if (state.audioTracks.length > 1) {
205
+ const selected = state.audioTracks.find((track: { selected: boolean }) => track.selected)
206
+ this.addItem(menu, `Audio: ${selected?.label || 'Default'}`, () => {
207
+ this._currentPanel = 'audio'
208
+ this.renderPanel()
209
+ })
210
+ }
211
+ }
212
+
213
+ private renderSpeed(menu: Element, player: any): void {
214
+ this.addItem(menu, 'Back', () => {
215
+ this._currentPanel = 'main'
216
+ this.renderPanel()
217
+ }, false, true)
218
+
219
+ for (const speed of SPEEDS) {
220
+ const checked = player.state.playbackRate === speed
221
+ this.addItem(menu, `${speed}x`, () => {
222
+ player.setPlaybackRate(speed)
223
+ this.close()
224
+ }, checked)
225
+ }
226
+ }
227
+
228
+ private renderQuality(menu: Element, player: any): void {
229
+ this.addItem(menu, 'Back', () => {
230
+ this._currentPanel = 'main'
231
+ this.renderPanel()
232
+ }, false, true)
233
+
234
+ // Auto option
235
+ this.addItem(menu, 'Auto', () => {
236
+ player.setQuality('auto')
237
+ this.close()
238
+ }, player.state.autoQuality)
239
+
240
+ for (const q of player.state.qualities) {
241
+ this.addItem(menu, `${q.height}p`, () => {
242
+ player.setQuality(q)
243
+ this.close()
244
+ }, q.selected && !player.state.autoQuality)
245
+ }
246
+ }
247
+
248
+ private renderCaptions(menu: Element, player: any): void {
249
+ this.addItem(menu, 'Back', () => {
250
+ this._currentPanel = 'main'
251
+ this.renderPanel()
252
+ }, false, true)
253
+
254
+ // Off option
255
+ const anyShowing = player.state.textTracks.some((t: any) => t.mode === 'showing')
256
+ this.addItem(menu, 'Off', () => {
257
+ for (const t of player.state.textTracks) {
258
+ if (t.mode === 'showing') player.setTextTrack(t.id, 'disabled')
259
+ }
260
+ this.close()
261
+ }, !anyShowing)
262
+
263
+ for (const track of player.state.textTracks) {
264
+ if (track.kind === 'subtitles' || track.kind === 'captions') {
265
+ this.addItem(menu, track.label, () => {
266
+ player.setTextTrack(track.id, 'showing')
267
+ this.close()
268
+ }, track.mode === 'showing')
269
+ }
270
+ }
271
+ }
272
+
273
+ private renderAudio(menu: Element, player: any): void {
274
+ this.addItem(menu, 'Back', () => {
275
+ this._currentPanel = 'main'
276
+ this.renderPanel()
277
+ }, false, true)
278
+
279
+ for (const track of player.state.audioTracks) {
280
+ this.addItem(menu, track.label || track.language || 'Audio track', () => {
281
+ player.setAudioTrack(track.id)
282
+ this.close()
283
+ }, track.selected)
284
+ }
285
+ }
286
+
287
+ private addItem(
288
+ menu: Element,
289
+ label: string,
290
+ onClick: () => void,
291
+ checked = false,
292
+ isBack = false,
293
+ ): void {
294
+ const btn = document.createElement('button')
295
+ btn.className = `item${isBack ? ' back' : ''}`
296
+ btn.setAttribute('role', 'menuitem')
297
+ if (!isBack) btn.setAttribute('aria-checked', String(checked))
298
+ btn.textContent = label
299
+ btn.addEventListener('click', onClick)
300
+ menu.appendChild(btn)
301
+ }
302
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ts-video-player <media-time-display> Custom Element
3
+ *
4
+ * Displays current time, duration, or remaining time with semantic markup.
5
+ * Attributes: type="current|duration|remaining"
6
+ *
7
+ * @module elements/media-time-display
8
+ */
9
+
10
+ import { resolvePlayer, formatTime, formatTimePhrase, toISODuration } from './utils'
11
+
12
+ import { HTMLElementBase } from './base'
13
+
14
+ export class MediaTimeDisplay extends HTMLElementBase {
15
+ private _cleanup: (() => void) | null = null
16
+ private _showRemaining = false
17
+
18
+ static get observedAttributes(): string[] {
19
+ return ['type']
20
+ }
21
+
22
+ get type(): string {
23
+ return this.getAttribute('type') || 'current'
24
+ }
25
+
26
+ connectedCallback(): void {
27
+ if (!this.shadowRoot) {
28
+ const shadow = this.attachShadow({ mode: 'open' })
29
+ shadow.innerHTML = `
30
+ <style>
31
+ :host {
32
+ display: inline-flex;
33
+ font-size: 14px;
34
+ font-variant-numeric: tabular-nums;
35
+ user-select: none;
36
+ cursor: pointer;
37
+ color: inherit;
38
+ }
39
+ time { color: inherit; }
40
+ .separator { margin: 0 2px; }
41
+ </style>
42
+ <time part="time" datetime="PT0S" aria-valuetext="0 seconds">0:00</time>
43
+ `
44
+ }
45
+
46
+ this.addEventListener('click', this.handleClick)
47
+
48
+ queueMicrotask(() => this.attach())
49
+ }
50
+
51
+ disconnectedCallback(): void {
52
+ this._cleanup?.()
53
+ this._cleanup = null
54
+ this.removeEventListener('click', this.handleClick)
55
+ }
56
+
57
+ private handleClick = (): void => {
58
+ if (this.type === 'duration') {
59
+ this._showRemaining = !this._showRemaining
60
+ // Re-render with current state
61
+ const player = resolvePlayer(this)
62
+ if (player) this.render(player.state)
63
+ }
64
+ }
65
+
66
+ private attach(): void {
67
+ const player = resolvePlayer(this)
68
+ if (!player) return
69
+
70
+ const unsub = player.subscribe((state: any) => {
71
+ this.render(state)
72
+ })
73
+
74
+ this._cleanup = unsub
75
+ }
76
+
77
+ private render(state: any): void {
78
+ const timeEl = this.shadowRoot?.querySelector('time')
79
+ if (!timeEl) return
80
+
81
+ const type = this.type
82
+ let seconds: number
83
+
84
+ if (type === 'remaining' || (type === 'duration' && this._showRemaining)) {
85
+ seconds = Math.max(state.duration - state.currentTime, 0)
86
+ const formatted = `-${formatTime(seconds, state.duration)}`
87
+ timeEl.textContent = formatted
88
+ } else if (type === 'duration') {
89
+ seconds = state.duration
90
+ timeEl.textContent = formatTime(seconds)
91
+ } else {
92
+ seconds = state.currentTime
93
+ timeEl.textContent = formatTime(seconds, state.duration)
94
+ }
95
+
96
+ timeEl.setAttribute('datetime', toISODuration(seconds))
97
+ timeEl.setAttribute('aria-valuetext', formatTimePhrase(seconds))
98
+ }
99
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ts-video-player <media-time-group> Custom Element
3
+ *
4
+ * Container for composing time display elements (current / separator / duration).
5
+ * Provides layout and semantic grouping.
6
+ *
7
+ * @example
8
+ * ```html
9
+ * <media-time-group>
10
+ * <media-time-display type="current"></media-time-display>
11
+ * <media-time-separator></media-time-separator>
12
+ * <media-time-display type="duration"></media-time-display>
13
+ * </media-time-group>
14
+ * ```
15
+ *
16
+ * @module elements/media-time-group
17
+ */
18
+
19
+ import { HTMLElementBase } from './base'
20
+
21
+ export class MediaTimeGroup extends HTMLElementBase {
22
+ connectedCallback(): void {
23
+ if (!this.shadowRoot) {
24
+ const shadow = this.attachShadow({ mode: 'open' })
25
+ shadow.innerHTML = `
26
+ <style>
27
+ :host {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ gap: 2px;
31
+ font-variant-numeric: tabular-nums;
32
+ font-family: inherit;
33
+ font-size: inherit;
34
+ color: inherit;
35
+ }
36
+ </style>
37
+ <slot></slot>
38
+ `
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * ts-video-player <media-time-separator> Custom Element
3
+ *
4
+ * Visual separator between time displays (typically "/" or ":").
5
+ * Hidden from screen readers via aria-hidden.
6
+ *
7
+ * @module elements/media-time-separator
8
+ */
9
+
10
+ import { HTMLElementBase } from './base'
11
+
12
+ export class MediaTimeSeparator extends HTMLElementBase {
13
+ connectedCallback(): void {
14
+ if (!this.shadowRoot) {
15
+ const shadow = this.attachShadow({ mode: 'open' })
16
+ shadow.innerHTML = `
17
+ <style>
18
+ :host {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ padding: 0 2px;
22
+ font-family: inherit;
23
+ font-size: inherit;
24
+ color: inherit;
25
+ opacity: 0.7;
26
+ }
27
+ </style>
28
+ <span aria-hidden="true" part="separator"><slot>/</slot></span>
29
+ `
30
+ }
31
+
32
+ this.setAttribute('role', 'separator')
33
+ this.setAttribute('aria-hidden', 'true')
34
+ }
35
+ }