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,65 @@
1
+ /**
2
+ * ts-video-player <media-live-button> Custom Element
3
+ *
4
+ * @module elements/media-live-button
5
+ */
6
+
7
+ import { HTMLElementBase } from './base'
8
+ import { resolvePlayer } from './utils'
9
+
10
+ export class MediaLiveButton extends HTMLElementBase {
11
+ private cleanup: (() => void) | null = null
12
+
13
+ connectedCallback(): void {
14
+ if (!this.shadowRoot) {
15
+ const shadow = this.attachShadow({ mode: 'open' })
16
+ shadow.innerHTML = `
17
+ <style>
18
+ :host { display: none; }
19
+ :host([available]) { display: inline-flex; }
20
+ button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
21
+ i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
22
+ :host(:not([at-live-edge])) button { opacity: 0.72; }
23
+ </style>
24
+ <button part="button" type="button" aria-label="Go to live edge"><i aria-hidden="true"></i><span>LIVE</span></button>
25
+ `
26
+ }
27
+ queueMicrotask(() => this.attach())
28
+ }
29
+
30
+ disconnectedCallback(): void {
31
+ this.cleanup?.()
32
+ this.cleanup = 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 threshold = Math.max(0, Number(this.getAttribute('threshold') || 10))
40
+ const update = (): void => {
41
+ const state = player.state
42
+ const end = state.seekable.at(-1)?.end
43
+ const providerLive = (player.provider as { isLive?: boolean } | null)?.isLive === true
44
+ const available = end !== undefined && (providerLive || !Number.isFinite(state.duration))
45
+ const atLiveEdge = available && end - state.currentTime <= threshold
46
+ this.toggleAttribute('available', available)
47
+ this.toggleAttribute('at-live-edge', atLiveEdge)
48
+ button.setAttribute('aria-pressed', String(atLiveEdge))
49
+ }
50
+ const click = (): void => {
51
+ const end = player.state.seekable.at(-1)?.end
52
+ if (end !== undefined) {
53
+ player.seekTo(end)
54
+ void player.play()
55
+ }
56
+ }
57
+ button.addEventListener('click', click)
58
+ const unsubscribe = player.subscribe(update)
59
+ update()
60
+ this.cleanup = () => {
61
+ unsubscribe()
62
+ button.removeEventListener('click', click)
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ts-video-player <media-mute-button> Custom Element
3
+ *
4
+ * @module elements/media-mute-button
5
+ */
6
+
7
+ import { resolvePlayer } from './utils'
8
+
9
+ const ICON_MUTED = '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'
10
+ const ICON_LOW = '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'
11
+ const ICON_HIGH = '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'
12
+
13
+ function getVolumeIcon(muted: boolean, volume: number): string {
14
+ if (muted || volume === 0) return ICON_MUTED
15
+ if (volume < 0.5) return ICON_LOW
16
+ return ICON_HIGH
17
+ }
18
+
19
+ import { HTMLElementBase } from './base'
20
+
21
+ export class MediaMuteButton extends HTMLElementBase {
22
+ private _cleanup: (() => void) | null = null
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; }
30
+ button {
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:hover { opacity: 0.8; }
36
+ </style>
37
+ <button part="button" type="button" aria-label="Mute">
38
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
39
+ <path d="${ICON_HIGH}"/>
40
+ </svg>
41
+ </button>
42
+ `
43
+ }
44
+
45
+ queueMicrotask(() => this.attach())
46
+ }
47
+
48
+ disconnectedCallback(): void {
49
+ this._cleanup?.()
50
+ this._cleanup = null
51
+ }
52
+
53
+ private attach(): void {
54
+ const player = resolvePlayer(this)
55
+ if (!player) return
56
+
57
+ const btn = this.shadowRoot!.querySelector('button')!
58
+ btn.addEventListener('click', () => player.toggleMute())
59
+
60
+ const unsub = player.subscribe((state: any) => {
61
+ const icon = getVolumeIcon(state.muted, state.volume)
62
+ btn.setAttribute('aria-label', state.muted ? 'Unmute' : 'Mute')
63
+ const path = this.shadowRoot!.querySelector('path')!
64
+ path.setAttribute('d', icon)
65
+ })
66
+
67
+ this._cleanup = unsub
68
+ }
69
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ts-video-player <media-pip-button> Custom Element
3
+ *
4
+ * @module elements/media-pip-button
5
+ */
6
+
7
+ import { resolvePlayer } from './utils'
8
+
9
+ const PIP_ICON = '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'
10
+
11
+ import { HTMLElementBase } from './base'
12
+
13
+ export class MediaPipButton extends HTMLElementBase {
14
+ private _cleanup: (() => void) | null = null
15
+
16
+ connectedCallback(): void {
17
+ if (!this.shadowRoot) {
18
+ const shadow = this.attachShadow({ mode: 'open' })
19
+ shadow.innerHTML = `
20
+ <style>
21
+ :host { display: inline-flex; }
22
+ :host([hidden]) { display: none; }
23
+ button {
24
+ display: inline-flex; align-items: center; justify-content: center;
25
+ width: 40px; height: 40px; padding: 8px;
26
+ background: transparent; border: none; color: inherit; cursor: pointer;
27
+ }
28
+ button:hover { opacity: 0.8; }
29
+ button:disabled { opacity: 0.4; cursor: not-allowed; }
30
+ </style>
31
+ <button part="button" type="button" aria-label="Picture in picture">
32
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
33
+ <path d="${PIP_ICON}"/>
34
+ </svg>
35
+ </button>
36
+ `
37
+ }
38
+
39
+ queueMicrotask(() => this.attach())
40
+ }
41
+
42
+ disconnectedCallback(): void {
43
+ this._cleanup?.()
44
+ this._cleanup = null
45
+ }
46
+
47
+ private attach(): void {
48
+ const player = resolvePlayer(this)
49
+ if (!player) return
50
+
51
+ const btn = this.shadowRoot!.querySelector('button')!
52
+ btn.addEventListener('click', () => player.togglePiP())
53
+
54
+ const unsub = player.subscribe((state: any) => {
55
+ const avail = state.pipAvailability
56
+ if (avail === 'unsupported') {
57
+ this.setAttribute('hidden', '')
58
+ } else {
59
+ this.removeAttribute('hidden')
60
+ }
61
+ btn.disabled = avail !== 'available'
62
+ })
63
+
64
+ this._cleanup = unsub
65
+ }
66
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ts-video-player <media-play-button> Custom Element
3
+ *
4
+ * @module elements/media-play-button
5
+ */
6
+
7
+ import { resolvePlayer } from './utils'
8
+
9
+ const PLAY_ICON = 'M8 5v14l11-7z'
10
+ const PAUSE_ICON = 'M6 19h4V5H6v14zm8-14v14h4V5h-4z'
11
+
12
+ import { HTMLElementBase } from './base'
13
+
14
+ export class MediaPlayButton extends HTMLElementBase {
15
+ private _cleanup: (() => void) | null = null
16
+ private _onClick: (() => void) | null = null
17
+
18
+ connectedCallback(): void {
19
+ if (!this.shadowRoot) {
20
+ const shadow = this.attachShadow({ mode: 'open' })
21
+ shadow.innerHTML = `
22
+ <style>
23
+ :host { display: inline-flex; }
24
+ button {
25
+ display: inline-flex; align-items: center; justify-content: center;
26
+ width: 40px; height: 40px; padding: 8px;
27
+ background: transparent; border: none; color: inherit; cursor: pointer;
28
+ }
29
+ button:hover { opacity: 0.8; }
30
+ </style>
31
+ <button part="button" type="button" aria-label="Play">
32
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
33
+ <path d="${PLAY_ICON}"/>
34
+ </svg>
35
+ </button>
36
+ `
37
+ }
38
+
39
+ // Defer player resolution to next microtask (player may not be ready yet)
40
+ queueMicrotask(() => this.attach())
41
+ }
42
+
43
+ disconnectedCallback(): void {
44
+ const button = this.shadowRoot?.querySelector('button')
45
+ if (button && this._onClick) button.removeEventListener('click', this._onClick)
46
+ this._cleanup?.()
47
+ this._cleanup = null
48
+ this._onClick = null
49
+ }
50
+
51
+ private attach(): void {
52
+ const player = resolvePlayer(this)
53
+ if (!player) return
54
+
55
+ const btn = this.shadowRoot!.querySelector('button')!
56
+ this._onClick = () => player.togglePlay()
57
+ btn.addEventListener('click', this._onClick)
58
+
59
+ const unsub = player.subscribe('paused', (state: any) => {
60
+ const paused = state.paused
61
+ btn.setAttribute('aria-label', paused ? 'Play' : 'Pause')
62
+ btn.setAttribute('aria-pressed', String(!paused))
63
+ const path = this.shadowRoot!.querySelector('path')!
64
+ path.setAttribute('d', paused ? PLAY_ICON : PAUSE_ICON)
65
+ })
66
+
67
+ this._cleanup = unsub
68
+ }
69
+ }
@@ -0,0 +1,59 @@
1
+ import { resolvePlayer } from './utils'
2
+ import type { PlayerState } from '../types'
3
+
4
+ const defaultRates = [0.5, 0.75, 1, 1.25, 1.5, 2]
5
+
6
+ import { HTMLElementBase } from './base'
7
+
8
+ export class MediaPlaybackRateButton extends HTMLElementBase {
9
+ private cleanup: (() => void) | null = null
10
+ private onClick: (() => void) | null = null
11
+
12
+ connectedCallback(): void {
13
+ if (!this.shadowRoot) {
14
+ const shadow = this.attachShadow({ mode: 'open' })
15
+ shadow.innerHTML = `
16
+ <style>
17
+ :host { display: inline-flex; }
18
+ button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
19
+ button:hover { opacity: .8; }
20
+ button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
21
+ </style>
22
+ <button part="button" type="button" aria-label="Playback speed"></button>
23
+ `
24
+ }
25
+ queueMicrotask(() => this.attach())
26
+ }
27
+
28
+ disconnectedCallback(): void {
29
+ const button = this.shadowRoot?.querySelector('button')
30
+ if (button && this.onClick) button.removeEventListener('click', this.onClick)
31
+ this.cleanup?.()
32
+ this.cleanup = null
33
+ this.onClick = null
34
+ }
35
+
36
+ private getRates(): number[] {
37
+ const rates = (this.getAttribute('rates') ?? '')
38
+ .split(',')
39
+ .map(value => Number.parseFloat(value.trim()))
40
+ .filter(value => Number.isFinite(value) && value >= 0.25 && value <= 4)
41
+ return [...new Set(rates.length > 0 ? rates : defaultRates)].sort((a, b) => a - b)
42
+ }
43
+
44
+ private attach(): void {
45
+ const player = resolvePlayer(this)
46
+ const button = this.shadowRoot?.querySelector('button')
47
+ if (!player || !button) return
48
+ const rates = this.getRates()
49
+ this.onClick = () => {
50
+ const currentIndex = rates.findIndex(rate => rate > player.state.playbackRate + 0.001)
51
+ player.setPlaybackRate(rates[currentIndex < 0 ? 0 : currentIndex])
52
+ }
53
+ button.addEventListener('click', this.onClick)
54
+ this.cleanup = player.subscribe('playbackRate', (state: PlayerState) => {
55
+ button.textContent = `${state.playbackRate}x`
56
+ button.setAttribute('aria-label', `Playback speed ${state.playbackRate}x`)
57
+ })
58
+ }
59
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ts-video-player <media-preview-time> Custom Element
3
+ *
4
+ * Displays the time at the current pointer position on the seek bar.
5
+ * Intended to be placed inside or near a <media-progress-bar>.
6
+ * Listens for custom 'previewtime' events or pointer events on the progress bar.
7
+ *
8
+ * @module elements/media-preview-time
9
+ */
10
+
11
+ import { resolvePlayer, formatTime } from './utils'
12
+
13
+ import { HTMLElementBase } from './base'
14
+
15
+ export class MediaPreviewTime extends HTMLElementBase {
16
+ private _cleanups: Array<() => void> = []
17
+
18
+ connectedCallback(): void {
19
+ if (!this.shadowRoot) {
20
+ const shadow = this.attachShadow({ mode: 'open' })
21
+ shadow.innerHTML = `
22
+ <style>
23
+ :host {
24
+ display: inline-block;
25
+ font-variant-numeric: tabular-nums;
26
+ font-family: monospace;
27
+ font-size: 12px;
28
+ color: inherit;
29
+ pointer-events: none;
30
+ }
31
+ </style>
32
+ <span part="value">0:00</span>
33
+ `
34
+ }
35
+
36
+ queueMicrotask(() => this.attach())
37
+ }
38
+
39
+ disconnectedCallback(): void {
40
+ this._cleanups.forEach((fn) => fn())
41
+ this._cleanups = []
42
+ }
43
+
44
+ private attach(): void {
45
+ const player = resolvePlayer(this)
46
+ if (!player) return
47
+
48
+ // Find sibling or parent progress bar to listen for pointer events
49
+ const progressBar =
50
+ this.closest('media-progress-bar') ||
51
+ this.parentElement?.querySelector('media-progress-bar')
52
+
53
+ if (progressBar) {
54
+ const onPointerMove = (e: Event) => {
55
+ const pe = e as PointerEvent
56
+ const track = (progressBar.shadowRoot?.querySelector('.track') ||
57
+ progressBar) as HTMLElement
58
+ const rect = track.getBoundingClientRect()
59
+ const percent = Math.max(0, Math.min(1, (pe.clientX - rect.left) / rect.width))
60
+ const time = percent * (player.state.duration || 0)
61
+ this.updateTime(time, player.state.duration)
62
+ }
63
+
64
+ progressBar.addEventListener('pointermove', onPointerMove)
65
+ this._cleanups.push(() => progressBar.removeEventListener('pointermove', onPointerMove))
66
+ }
67
+ }
68
+
69
+ private updateTime(time: number, duration: number): void {
70
+ const span = this.shadowRoot?.querySelector('span')
71
+ if (span) {
72
+ span.textContent = formatTime(time, duration)
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * ts-video-player <media-progress-bar> Custom Element
3
+ *
4
+ * Seek bar with buffered overlay, time tooltip, pointer/keyboard interaction.
5
+ *
6
+ * @module elements/media-progress-bar
7
+ */
8
+
9
+ import { resolvePlayer, formatTime, formatTimePhrase } from './utils'
10
+
11
+ import { HTMLElementBase } from './base'
12
+
13
+ export class MediaProgressBar extends HTMLElementBase {
14
+ private _cleanups: Array<() => void> = []
15
+ private _isDragging = false
16
+
17
+ connectedCallback(): void {
18
+ if (!this.shadowRoot) {
19
+ const shadow = this.attachShadow({ mode: 'open' })
20
+ shadow.innerHTML = `
21
+ <style>
22
+ :host { display: flex; flex: 1; align-items: center; }
23
+ .container {
24
+ position: relative; width: 100%; height: 20px;
25
+ cursor: pointer; touch-action: none;
26
+ }
27
+ .track {
28
+ position: absolute; top: 50%; left: 0; right: 0;
29
+ height: 4px; background: rgba(255,255,255,0.3);
30
+ border-radius: 2px; transform: translateY(-50%);
31
+ }
32
+ :host([waveform]) .track { height: 20px; overflow: hidden; background: rgba(255,255,255,0.16); }
33
+ .waveform { position: absolute; inset: 0; background-position: center; background-repeat: no-repeat; background-size: 100% 100%; opacity: 0.65; }
34
+ .buffered {
35
+ position: absolute; top: 0; left: 0;
36
+ height: 100%; background: rgba(255,255,255,0.4);
37
+ border-radius: 2px; width: 0%;
38
+ }
39
+ .fill {
40
+ position: absolute; top: 0; left: 0;
41
+ height: 100%; background: #fff;
42
+ border-radius: 2px; width: 0%;
43
+ }
44
+ .thumb {
45
+ position: absolute; top: 50%; left: 0%;
46
+ width: 12px; height: 12px; background: #fff;
47
+ border-radius: 50%; transform: translate(-50%, -50%);
48
+ transition: transform 0.1s;
49
+ }
50
+ .container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
51
+ .tooltip {
52
+ position: absolute; bottom: 100%; left: 0;
53
+ transform: translateX(-50%);
54
+ background: rgba(0,0,0,0.85); color: #fff;
55
+ padding: 4px 8px; border-radius: 3px;
56
+ font-size: 12px; font-family: monospace;
57
+ white-space: nowrap; pointer-events: none;
58
+ opacity: 0; transition: opacity 0.15s;
59
+ margin-bottom: 8px;
60
+ }
61
+ .tooltip::after {
62
+ content: ''; position: absolute;
63
+ top: 100%; left: 50%; transform: translateX(-50%);
64
+ border: 4px solid transparent;
65
+ border-top-color: rgba(0,0,0,0.85);
66
+ }
67
+ .container:hover .tooltip { opacity: 1; }
68
+ </style>
69
+ <div class="container" part="container" role="slider"
70
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
71
+ aria-label="Seek" tabindex="0">
72
+ <div class="tooltip" part="tooltip">0:00</div>
73
+ <div class="track" part="track">
74
+ <div class="waveform" part="waveform" aria-hidden="true"></div>
75
+ <div class="buffered" part="buffered"></div>
76
+ <div class="fill" part="fill"></div>
77
+ <div class="thumb" part="thumb"></div>
78
+ </div>
79
+ </div>
80
+ `
81
+ }
82
+
83
+ queueMicrotask(() => this.attach())
84
+ }
85
+
86
+ disconnectedCallback(): void {
87
+ this._cleanups.forEach((fn) => fn())
88
+ this._cleanups = []
89
+ }
90
+
91
+ private attach(): void {
92
+ const player = resolvePlayer(this)
93
+ if (!player) return
94
+
95
+ const container = this.shadowRoot!.querySelector('.container') as HTMLElement
96
+ const track = this.shadowRoot!.querySelector('.track') as HTMLElement
97
+ const fill = this.shadowRoot!.querySelector('.fill') as HTMLElement
98
+ const thumb = this.shadowRoot!.querySelector('.thumb') as HTMLElement
99
+ const buffered = this.shadowRoot!.querySelector('.buffered') as HTMLElement
100
+ const tooltip = this.shadowRoot!.querySelector('.tooltip') as HTMLElement
101
+ const waveform = this.getAttribute('waveform')
102
+ if (waveform) {
103
+ const resolved = new URL(waveform, document.baseURI)
104
+ if (['http:', 'https:', 'blob:'].includes(resolved.protocol) || resolved.href.startsWith('data:image/')) {
105
+ const layer = this.shadowRoot!.querySelector('.waveform') as HTMLElement
106
+ layer.style.backgroundImage = `url(${JSON.stringify(resolved.href)})`
107
+ }
108
+ }
109
+
110
+ const updateTooltip = (e: PointerEvent) => {
111
+ const rect = track.getBoundingClientRect()
112
+ const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
113
+ const time = percent * (player.state.duration || 0)
114
+
115
+ tooltip.textContent = formatTime(time, player.state.duration)
116
+
117
+ // Position tooltip, clamping to container edges
118
+ const containerRect = container.getBoundingClientRect()
119
+ const tooltipWidth = tooltip.offsetWidth
120
+ const rawLeft = e.clientX - containerRect.left
121
+ const minLeft = tooltipWidth / 2
122
+ const maxLeft = containerRect.width - tooltipWidth / 2
123
+ const clampedLeft = Math.max(minLeft, Math.min(maxLeft, rawLeft))
124
+
125
+ tooltip.style.left = `${clampedLeft}px`
126
+ }
127
+
128
+ const seekFromEvent = (e: PointerEvent) => {
129
+ const rect = track.getBoundingClientRect()
130
+ const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
131
+ const time = percent * (player.state.duration || 0)
132
+ player.seekTo(time)
133
+ }
134
+
135
+ const onPointerDown = (e: PointerEvent) => {
136
+ this._isDragging = true
137
+ container.setPointerCapture(e.pointerId)
138
+ seekFromEvent(e)
139
+ }
140
+
141
+ const onPointerMove = (e: PointerEvent) => {
142
+ updateTooltip(e)
143
+ if (!this._isDragging) return
144
+ seekFromEvent(e)
145
+ }
146
+
147
+ const onPointerUp = (e: PointerEvent) => {
148
+ if (!this._isDragging) return
149
+ this._isDragging = false
150
+ container.releasePointerCapture(e.pointerId)
151
+ }
152
+
153
+ const onKeyDown = (e: KeyboardEvent) => {
154
+ const step = 5
155
+ let newTime = player.state.currentTime
156
+
157
+ switch (e.key) {
158
+ case 'ArrowRight': newTime = Math.min(player.state.duration, newTime + step); break
159
+ case 'ArrowLeft': newTime = Math.max(0, newTime - step); break
160
+ case 'Home': newTime = 0; break
161
+ case 'End': newTime = player.state.duration; break
162
+ default: return
163
+ }
164
+
165
+ e.preventDefault()
166
+ player.seekTo(newTime)
167
+ }
168
+
169
+ container.addEventListener('pointerdown', onPointerDown)
170
+ container.addEventListener('pointermove', onPointerMove)
171
+ container.addEventListener('pointerup', onPointerUp)
172
+ container.addEventListener('keydown', onKeyDown)
173
+
174
+ this._cleanups.push(
175
+ () => container.removeEventListener('pointerdown', onPointerDown),
176
+ () => container.removeEventListener('pointermove', onPointerMove),
177
+ () => container.removeEventListener('pointerup', onPointerUp),
178
+ () => container.removeEventListener('keydown', onKeyDown),
179
+ )
180
+
181
+ const unsub = player.subscribe((state: any) => {
182
+ if (this._isDragging) return
183
+
184
+ const duration = state.duration || 0
185
+ const percent = duration > 0 ? (state.currentTime / duration) * 100 : 0
186
+
187
+ fill.style.width = `${percent}%`
188
+ thumb.style.left = `${percent}%`
189
+
190
+ container.setAttribute('aria-valuenow', String(Math.round(percent)))
191
+ container.setAttribute('aria-valuetext', `${formatTimePhrase(state.currentTime)} of ${formatTimePhrase(duration)}`)
192
+
193
+ // Buffered
194
+ const bufferedPct = (state.bufferedAmount || 0) * 100
195
+ buffered.style.width = `${bufferedPct}%`
196
+ })
197
+
198
+ this._cleanups.push(unsub)
199
+ }
200
+ }