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,140 @@
1
+ /**
2
+ * ts-video-player <media-volume-slider> Custom Element
3
+ *
4
+ * Volume control slider. Hidden when volume is unsupported (iOS Safari).
5
+ *
6
+ * @module elements/media-volume-slider
7
+ */
8
+
9
+ import { resolvePlayer } from './utils'
10
+
11
+ import { HTMLElementBase } from './base'
12
+
13
+ export class MediaVolumeSlider 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: inline-flex; align-items: center; width: 80px; }
23
+ :host([hidden]) { display: none; }
24
+ .container {
25
+ position: relative; width: 100%; height: 20px;
26
+ cursor: pointer; touch-action: none;
27
+ }
28
+ .track {
29
+ position: absolute; top: 50%; left: 0; right: 0;
30
+ height: 4px; background: rgba(255,255,255,0.3);
31
+ border-radius: 2px; transform: translateY(-50%);
32
+ }
33
+ .fill {
34
+ position: absolute; top: 0; left: 0;
35
+ height: 100%; background: #fff;
36
+ border-radius: 2px; width: 100%;
37
+ }
38
+ .thumb {
39
+ position: absolute; top: 50%; left: 100%;
40
+ width: 12px; height: 12px; background: #fff;
41
+ border-radius: 50%; transform: translate(-50%, -50%);
42
+ transition: transform 0.1s;
43
+ }
44
+ .container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
45
+ </style>
46
+ <div class="container" part="container" role="slider"
47
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
48
+ aria-label="Volume" tabindex="0">
49
+ <div class="track" part="track">
50
+ <div class="fill" part="fill"></div>
51
+ <div class="thumb" part="thumb"></div>
52
+ </div>
53
+ </div>
54
+ `
55
+ }
56
+
57
+ queueMicrotask(() => this.attach())
58
+ }
59
+
60
+ disconnectedCallback(): void {
61
+ this._cleanups.forEach((fn) => fn())
62
+ this._cleanups = []
63
+ }
64
+
65
+ private attach(): void {
66
+ const player = resolvePlayer(this)
67
+ if (!player) return
68
+
69
+ const container = this.shadowRoot!.querySelector('.container') as HTMLElement
70
+ const track = this.shadowRoot!.querySelector('.track') as HTMLElement
71
+ const fill = this.shadowRoot!.querySelector('.fill') as HTMLElement
72
+ const thumb = this.shadowRoot!.querySelector('.thumb') as HTMLElement
73
+
74
+ const setFromEvent = (e: PointerEvent) => {
75
+ const rect = track.getBoundingClientRect()
76
+ const volume = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
77
+ player.setVolume(volume)
78
+ if (volume > 0 && player.state.muted) player.setMuted(false)
79
+ }
80
+
81
+ const onPointerDown = (e: PointerEvent) => {
82
+ this._isDragging = true
83
+ container.setPointerCapture(e.pointerId)
84
+ setFromEvent(e)
85
+ }
86
+ const onPointerMove = (e: PointerEvent) => {
87
+ if (!this._isDragging) return
88
+ setFromEvent(e)
89
+ }
90
+ const onPointerUp = (e: PointerEvent) => {
91
+ if (!this._isDragging) return
92
+ this._isDragging = false
93
+ container.releasePointerCapture(e.pointerId)
94
+ }
95
+ const onKeyDown = (e: KeyboardEvent) => {
96
+ let volume = player.state.volume
97
+ const step = 0.05
98
+
99
+ switch (e.key) {
100
+ case 'ArrowRight':
101
+ case 'ArrowUp': volume = Math.min(1, volume + step); break
102
+ case 'ArrowLeft':
103
+ case 'ArrowDown': volume = Math.max(0, volume - step); break
104
+ default: return
105
+ }
106
+
107
+ e.preventDefault()
108
+ player.setVolume(volume)
109
+ }
110
+
111
+ container.addEventListener('pointerdown', onPointerDown)
112
+ container.addEventListener('pointermove', onPointerMove)
113
+ container.addEventListener('pointerup', onPointerUp)
114
+ container.addEventListener('keydown', onKeyDown)
115
+
116
+ this._cleanups.push(
117
+ () => container.removeEventListener('pointerdown', onPointerDown),
118
+ () => container.removeEventListener('pointermove', onPointerMove),
119
+ () => container.removeEventListener('pointerup', onPointerUp),
120
+ () => container.removeEventListener('keydown', onKeyDown),
121
+ )
122
+
123
+ const unsub = player.subscribe((state: any) => {
124
+ if (state.volumeAvailability === 'unsupported') {
125
+ this.setAttribute('hidden', '')
126
+ return
127
+ }
128
+ this.removeAttribute('hidden')
129
+
130
+ const vol = state.muted ? 0 : state.volume
131
+ const percent = vol * 100
132
+
133
+ fill.style.width = `${percent}%`
134
+ thumb.style.left = `${percent}%`
135
+ container.setAttribute('aria-valuenow', String(Math.round(percent)))
136
+ })
137
+
138
+ this._cleanups.push(unsub)
139
+ }
140
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ts-video-player Element Utilities
3
+ *
4
+ * Shared helpers for custom elements.
5
+ *
6
+ * @module elements/utils
7
+ */
8
+
9
+ /**
10
+ * Format time in seconds to MM:SS or HH:MM:SS
11
+ */
12
+ export function formatTime(seconds: number, guide = 0): string {
13
+ if (!isFinite(seconds) || isNaN(seconds)) return '0:00'
14
+
15
+ const s = Math.floor(seconds)
16
+ const h = Math.floor(s / 3600)
17
+ const m = Math.floor((s % 3600) / 60)
18
+ const sec = s % 60
19
+
20
+ const showHours = h > 0 || guide >= 3600
21
+
22
+ if (showHours) {
23
+ return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
24
+ }
25
+
26
+ return `${m}:${sec.toString().padStart(2, '0')}`
27
+ }
28
+
29
+ /**
30
+ * Format time as a human-readable phrase for screen readers.
31
+ * e.g., "1 minute, 30 seconds"
32
+ */
33
+ export function formatTimePhrase(seconds: number): string {
34
+ if (!isFinite(seconds) || isNaN(seconds)) return '0 seconds'
35
+
36
+ const s = Math.floor(seconds)
37
+ const h = Math.floor(s / 3600)
38
+ const m = Math.floor((s % 3600) / 60)
39
+ const sec = s % 60
40
+
41
+ const parts: string[] = []
42
+ if (h > 0) parts.push(`${h} ${h === 1 ? 'hour' : 'hours'}`)
43
+ if (m > 0) parts.push(`${m} ${m === 1 ? 'minute' : 'minutes'}`)
44
+ if (sec > 0 || parts.length === 0) parts.push(`${sec} ${sec === 1 ? 'second' : 'seconds'}`)
45
+
46
+ return parts.join(', ')
47
+ }
48
+
49
+ /**
50
+ * Convert seconds to ISO 8601 duration string.
51
+ * e.g., 90 -> "PT1M30S"
52
+ */
53
+ export function toISODuration(seconds: number): string {
54
+ if (!isFinite(seconds) || isNaN(seconds)) return 'PT0S'
55
+
56
+ const s = Math.floor(seconds)
57
+ const h = Math.floor(s / 3600)
58
+ const m = Math.floor((s % 3600) / 60)
59
+ const sec = s % 60
60
+
61
+ let result = 'PT'
62
+ if (h > 0) result += `${h}H`
63
+ if (m > 0) result += `${m}M`
64
+ if (sec > 0 || result === 'PT') result += `${sec}S`
65
+
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * Resolve the player instance from an element.
71
+ * Uses `for` attribute to find by ID, or walks up the DOM to find `<video-player>`.
72
+ */
73
+ export function resolvePlayer(el: HTMLElement): any | null {
74
+ const forId = el.getAttribute('for')
75
+ if (forId) {
76
+ const target = document.getElementById(forId)
77
+ return (target as any)?.player ?? null
78
+ }
79
+
80
+ const host = el.closest('video-player')
81
+ return (host as any)?.player ?? null
82
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * ts-video-player <video-player> Custom Element
3
+ *
4
+ * The root element that wraps the Player class.
5
+ *
6
+ * @module elements/video-player
7
+ */
8
+
9
+ import { Player } from '../player'
10
+ import type { DRMConfig, PlayerOptions, PlayerEventMap, Src, TextTrackSource } from '../types'
11
+
12
+ const OBSERVED_ATTRS = ['src', 'poster', 'autoplay', 'loop', 'muted', 'controls', 'volume', 'playback-rate', 'preload', 'playsinline', 'crossorigin', 'controlslist', 'disable-picture-in-picture'] as const
13
+
14
+ import { HTMLElementBase } from './base'
15
+
16
+ export class VideoPlayerElement extends HTMLElementBase {
17
+ private _player: Player | null = null
18
+ private _container: HTMLElement | null = null
19
+ private _disconnectId = 0
20
+
21
+ static get observedAttributes(): string[] {
22
+ return [...OBSERVED_ATTRS]
23
+ }
24
+
25
+ get player(): Player | null {
26
+ return this._player
27
+ }
28
+
29
+ connectedCallback(): void {
30
+ this._disconnectId++
31
+ if (this._player) return
32
+
33
+ // Create shadow DOM
34
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
35
+
36
+ // Container
37
+ this._container = document.createElement('div')
38
+ this._container.setAttribute('part', 'container')
39
+ this._container.style.cssText = 'position:relative;width:100%;height:100%;'
40
+
41
+ // Slot for child media elements (buttons, etc.)
42
+ const slot = document.createElement('slot')
43
+ this._container.appendChild(slot)
44
+
45
+ shadow.appendChild(this._container)
46
+
47
+ // Build options from attributes
48
+ const options = this.buildOptions()
49
+
50
+ // Create player
51
+ this._player = new Player(this._container, options)
52
+
53
+ // Forward player events as DOM events
54
+ this.forwardEvents()
55
+ }
56
+
57
+ disconnectedCallback(): void {
58
+ if (this.hasAttribute('keep-alive')) return
59
+ const disconnectId = ++this._disconnectId
60
+ queueMicrotask(() => {
61
+ if (this.isConnected || disconnectId !== this._disconnectId) return
62
+ this._player?.destroy()
63
+ this._player = null
64
+ this._container = null
65
+ this.shadowRoot?.replaceChildren()
66
+ })
67
+ }
68
+
69
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
70
+ if (!this._player) return
71
+
72
+ switch (name) {
73
+ case 'src':
74
+ if (value) this._player.setSrc(value)
75
+ break
76
+ case 'muted':
77
+ this._player.setMuted(value !== null)
78
+ break
79
+ case 'volume':
80
+ if (value !== null) this._player.setVolume(parseFloat(value))
81
+ break
82
+ case 'playback-rate':
83
+ if (value !== null) this._player.setPlaybackRate(parseFloat(value))
84
+ break
85
+ }
86
+ }
87
+
88
+ private buildOptions(): PlayerOptions {
89
+ const options: PlayerOptions = {}
90
+
91
+ const config = this.readSourceConfig()
92
+ if (config.sources?.length) options.src = config.sources
93
+ if (config.tracks?.length) options.tracks = config.tracks
94
+
95
+ const src = this.getAttribute('src')
96
+ if (src && !options.src) options.src = config.drm ? { src, type: 'application/dash+xml', drm: config.drm } : src
97
+
98
+ const poster = this.getAttribute('poster')
99
+ if (poster) options.poster = poster
100
+
101
+ const title = this.getAttribute('title')
102
+ if (title) options.title = title
103
+
104
+ if (this.hasAttribute('autoplay')) options.autoplay = true
105
+ if (this.hasAttribute('loop')) options.loop = true
106
+ if (this.hasAttribute('muted')) options.muted = true
107
+
108
+ const volume = this.getAttribute('volume')
109
+ if (volume) options.volume = parseFloat(volume)
110
+
111
+ const rate = this.getAttribute('playback-rate')
112
+ if (rate) options.playbackRate = parseFloat(rate)
113
+
114
+ const preload = this.getAttribute('preload')
115
+ if (preload) options.preload = preload as PlayerOptions['preload']
116
+
117
+ if (this.hasAttribute('controls')) options.controls = true
118
+ options.playsinline = this.getAttribute('playsinline') !== 'false'
119
+
120
+ const crossorigin = this.getAttribute('crossorigin')
121
+ if (crossorigin !== null) options.crossorigin = crossorigin as PlayerOptions['crossorigin']
122
+
123
+ const controlsList = this.getAttribute('controlslist')
124
+ if (controlsList) options.controlsList = controlsList.split(/\s+/) as PlayerOptions['controlsList']
125
+ options.disablePictureInPicture = this.hasAttribute('disable-picture-in-picture')
126
+
127
+ return options
128
+ }
129
+
130
+ private readSourceConfig(): { sources?: Src[], drm?: DRMConfig, tracks?: TextTrackSource[] } {
131
+ const script = this.querySelector('script[type="application/json"][data-media-config]')
132
+ const value = this.getAttribute('data-media-config')?.trim() || script?.textContent?.trim()
133
+ if (!value) return {}
134
+ if (value.length > 65_536) throw new TypeError('Media player configuration is too large')
135
+ try {
136
+ const parsed = JSON.parse(value) as { sources?: unknown, drm?: unknown, tracks?: unknown }
137
+ if (parsed.sources !== undefined && (!Array.isArray(parsed.sources) || parsed.sources.some(source => !source || typeof source !== 'object' || typeof (source as { src?: unknown }).src !== 'string'))) {
138
+ throw new TypeError('Media player sources are invalid')
139
+ }
140
+ const trackKinds = new Set(['subtitles', 'captions', 'descriptions', 'chapters', 'metadata'])
141
+ if (parsed.tracks !== undefined && (!Array.isArray(parsed.tracks) || parsed.tracks.some(track => !track || typeof track !== 'object' || typeof (track as { src?: unknown }).src !== 'string' || !trackKinds.has(String((track as { kind?: unknown }).kind))))) {
142
+ throw new TypeError('Media player tracks are invalid')
143
+ }
144
+ return { sources: parsed.sources as Src[] | undefined, drm: parsed.drm as DRMConfig | undefined, tracks: parsed.tracks as TextTrackSource[] | undefined }
145
+ }
146
+ catch (error) {
147
+ if (error instanceof TypeError) throw error
148
+ throw new TypeError('Media player configuration is invalid JSON')
149
+ }
150
+ }
151
+
152
+ private forwardEvents(): void {
153
+ if (!this._player) return
154
+
155
+ const events: (keyof PlayerEventMap)[] = [
156
+ 'play', 'pause', 'playing', 'waiting', 'ended',
157
+ 'timeupdate', 'durationchange', 'volumechange',
158
+ 'fullscreenchange', 'pipchange', 'error',
159
+ 'loadstart', 'loadeddata', 'loadedmetadata',
160
+ 'canplay', 'canplaythrough', 'seeking', 'seeked',
161
+ 'ratechange', 'qualitychange', 'statechange',
162
+ 'availabilitychange',
163
+ ]
164
+
165
+ for (const event of events) {
166
+ this._player.on(event, ((...args: unknown[]) => {
167
+ this.dispatchEvent(new CustomEvent(event, {
168
+ detail: args.length === 1 ? args[0] : args,
169
+ bubbles: true,
170
+ composed: true,
171
+ }))
172
+ }) as any)
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ts-video-player <video-skin> Custom Element
3
+ *
4
+ * Adaptive skin wrapper that provides a default layout for player controls.
5
+ * Adjusts visibility of controls based on available features.
6
+ *
7
+ * Follows the v10 layered model:
8
+ * <video-player> ← Behavior
9
+ * <video-skin> ← Appearance (this element)
10
+ * <media-*> ← UI controls
11
+ * </video-skin>
12
+ * </video-player>
13
+ *
14
+ * @module elements/video-skin
15
+ */
16
+
17
+ import { resolvePlayer } from './utils'
18
+
19
+ import { HTMLElementBase } from './base'
20
+
21
+ export class VideoSkin extends HTMLElementBase {
22
+ private _cleanups: Array<() => void> = []
23
+
24
+ connectedCallback(): void {
25
+ if (!this.shadowRoot) {
26
+ const shadow = this.attachShadow({ mode: 'open' })
27
+ shadow.innerHTML = `
28
+ <style>
29
+ :host {
30
+ display: flex;
31
+ flex-direction: column;
32
+ position: absolute;
33
+ inset: 0;
34
+ z-index: 1;
35
+ pointer-events: none;
36
+ color: white;
37
+ font-family: system-ui, -apple-system, sans-serif;
38
+ font-size: 14px;
39
+ }
40
+
41
+ /* Click-to-play overlay */
42
+ .overlay {
43
+ flex: 1;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ pointer-events: auto;
48
+ }
49
+
50
+ /* Bottom controls bar */
51
+ .controls {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 4px;
55
+ padding: 8px 12px;
56
+ background: linear-gradient(transparent, rgba(0,0,0,0.7));
57
+ pointer-events: auto;
58
+ transition: opacity 0.3s, visibility 0.3s;
59
+ }
60
+
61
+ :host([controls-hidden]) .controls {
62
+ opacity: 0;
63
+ visibility: hidden;
64
+ }
65
+
66
+ /* Spacer pushes right-aligned controls */
67
+ .spacer {
68
+ flex: 1;
69
+ }
70
+
71
+ /* Slots for left/center/right control groups */
72
+ ::slotted(*) {
73
+ pointer-events: auto;
74
+ }
75
+ </style>
76
+ <div class="overlay" part="overlay">
77
+ <slot name="overlay"></slot>
78
+ </div>
79
+ <div class="controls" part="controls">
80
+ <slot name="left"></slot>
81
+ <slot name="center"></slot>
82
+ <div class="spacer"></div>
83
+ <slot name="right"></slot>
84
+ <slot></slot>
85
+ </div>
86
+ `
87
+ }
88
+
89
+ queueMicrotask(() => this.attach())
90
+ }
91
+
92
+ disconnectedCallback(): void {
93
+ this._cleanups.forEach((fn) => fn())
94
+ this._cleanups = []
95
+ }
96
+
97
+ private attach(): void {
98
+ const player = resolvePlayer(this)
99
+ if (!player) return
100
+
101
+ // Sync controls visibility
102
+ const unsub = player.subscribe((state: any) => {
103
+ if (state.controlsVisible) {
104
+ this.removeAttribute('controls-hidden')
105
+ } else {
106
+ this.setAttribute('controls-hidden', '')
107
+ }
108
+
109
+ // Propagate feature availability as data attributes for CSS adaptation
110
+ this.dataset.fullscreen = state.fullscreenAvailability
111
+ this.dataset.pip = state.pipAvailability
112
+ this.dataset.volume = state.volumeAvailability
113
+ this.dataset.captions = String(state.textTracks.length > 0)
114
+ this.dataset.qualities = String(state.qualities.length > 0)
115
+ })
116
+
117
+ // Click overlay to toggle play
118
+ const overlay = this.shadowRoot!.querySelector('.overlay')!
119
+ const onOverlayClick = () => player.togglePlay()
120
+ overlay.addEventListener('click', onOverlayClick)
121
+
122
+ this._cleanups.push(
123
+ unsub,
124
+ () => overlay.removeEventListener('click', onOverlayClick),
125
+ )
126
+ }
127
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Captions feature — text track management.
3
+ *
4
+ * @module features/captions
5
+ */
6
+
7
+ import type { Feature, FeatureContext } from './types'
8
+ import type { TextTrack } from '../types'
9
+
10
+ export const captions: Feature = {
11
+ name: 'captions',
12
+ stateKeys: ['textTracks'],
13
+
14
+ setup(ctx: FeatureContext) {
15
+ const { store, getMediaElement } = ctx
16
+
17
+ const syncTracks = () => {
18
+ const media = getMediaElement()
19
+ if (!media) return
20
+
21
+ const tracks: TextTrack[] = []
22
+ const textTracks = media.textTracks
23
+
24
+ for (let i = 0; i < textTracks.length; i++) {
25
+ const track = textTracks[i]
26
+ tracks.push({
27
+ id: String(i),
28
+ kind: track.kind as TextTrack['kind'],
29
+ label: track.label || `Track ${i + 1}`,
30
+ language: track.language || '',
31
+ mode: track.mode as TextTrack['mode'],
32
+ cues: [],
33
+ })
34
+ }
35
+
36
+ store.set('textTracks', tracks)
37
+ }
38
+
39
+ const media = getMediaElement()
40
+ if (media) {
41
+ media.textTracks.addEventListener('change', syncTracks)
42
+ media.textTracks.addEventListener('addtrack', syncTracks)
43
+ media.textTracks.addEventListener('removetrack', syncTracks)
44
+ }
45
+
46
+ syncTracks()
47
+
48
+ return () => {
49
+ const m = getMediaElement()
50
+ if (!m) return
51
+ m.textTracks.removeEventListener('change', syncTracks)
52
+ m.textTracks.removeEventListener('addtrack', syncTracks)
53
+ m.textTracks.removeEventListener('removetrack', syncTracks)
54
+ }
55
+ },
56
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Composable Player Factory
3
+ *
4
+ * Creates a lightweight player with only the features you need.
5
+ *
6
+ * @module features/create-player
7
+ */
8
+
9
+ import type { Feature, FeatureContext } from './types'
10
+ import type { PlayerState } from '../types'
11
+ import { StateStore } from '../core/state'
12
+
13
+ export interface ComposablePlayer {
14
+ /** The container element */
15
+ el: HTMLElement
16
+ /** Current state */
17
+ readonly state: PlayerState
18
+ /** Subscribe to state changes */
19
+ subscribe: StateStore['subscribe']
20
+ /** Get the media element */
21
+ getMediaElement: () => HTMLMediaElement | null
22
+ /** Destroy the player and all features */
23
+ destroy: () => void
24
+ }
25
+
26
+ export interface ComposablePlayerOptions {
27
+ /** Features to enable */
28
+ features: Feature[]
29
+ /** Initial state overrides */
30
+ state?: Partial<PlayerState>
31
+ /** Player options */
32
+ options?: Record<string, unknown>
33
+ }
34
+
35
+ /**
36
+ * Create a composable player with only the specified features.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { createComposablePlayer } from 'ts-video-player/features'
41
+ * import { playback, volume, fullscreen } from 'ts-video-player/features'
42
+ *
43
+ * const player = createComposablePlayer(container, {
44
+ * features: [playback, volume, fullscreen],
45
+ * })
46
+ * ```
47
+ */
48
+ export function createComposablePlayer(
49
+ container: HTMLElement,
50
+ opts: ComposablePlayerOptions,
51
+ ): ComposablePlayer {
52
+ const store = new StateStore(opts.state)
53
+ const cleanups: Array<() => void> = []
54
+
55
+ let mediaElement: HTMLMediaElement | null = null
56
+
57
+ const getMediaElement = (): HTMLMediaElement | null => {
58
+ if (!mediaElement) {
59
+ mediaElement = container.querySelector('video, audio')
60
+ }
61
+ return mediaElement
62
+ }
63
+
64
+ const ctx: FeatureContext = {
65
+ container,
66
+ store,
67
+ options: opts.options || {},
68
+ getMediaElement,
69
+ }
70
+
71
+ // Setup all features
72
+ for (const feature of opts.features) {
73
+ const cleanup = feature.setup(ctx)
74
+ if (cleanup) cleanups.push(cleanup)
75
+ }
76
+
77
+ return {
78
+ el: container,
79
+ get state() {
80
+ return store.getState()
81
+ },
82
+ subscribe: store.subscribe.bind(store),
83
+ getMediaElement,
84
+ destroy() {
85
+ for (const cleanup of cleanups) cleanup()
86
+ cleanups.length = 0
87
+ store.reset()
88
+ mediaElement = null
89
+ },
90
+ }
91
+ }