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
package/src/player.ts ADDED
@@ -0,0 +1,821 @@
1
+ /**
2
+ * ts-video-player Player
3
+ *
4
+ * Main player class that orchestrates providers, state, and UI.
5
+ *
6
+ * @module player
7
+ */
8
+
9
+ import type {
10
+ Player as IPlayer,
11
+ PlayerOptions,
12
+ PlayerState,
13
+ PlayerEventMap,
14
+ Provider,
15
+ ProviderLoader,
16
+ Src,
17
+ VideoQuality,
18
+ } from './types'
19
+ import { StateStore, createStorageSync, selectBufferedAmount } from './core/state'
20
+ import { EventEmitter, createKeyboardHandler, createActivityDetector, type KeyboardActions } from './core/events'
21
+ import { createMediaSession, type MediaSessionOptions } from './core/media-session'
22
+ import { lockOrientation, unlockOrientation } from './core/orientation'
23
+ import { defaultLoaders, findSourceCandidates } from './providers'
24
+
25
+ // =============================================================================
26
+ // Player Class
27
+ // =============================================================================
28
+
29
+ /**
30
+ * ts-video-player main class
31
+ */
32
+ export class Player implements IPlayer {
33
+ private _el: HTMLElement
34
+ private _options: PlayerOptions
35
+ private _store: StateStore
36
+ private _events = new EventEmitter<PlayerEventMap>()
37
+ private _provider: Provider | null = null
38
+ private _loaders: ProviderLoader[]
39
+ private _cleanupFns: Array<() => void> = []
40
+ private _destroyed = false
41
+ private _sourceLoadId = 0
42
+ private _announcer: HTMLElement | null = null
43
+
44
+ /**
45
+ * Create a new player instance
46
+ */
47
+ constructor(container: HTMLElement | string, options: PlayerOptions = {}) {
48
+ // Resolve container
49
+ if (typeof container === 'string') {
50
+ const el = document.querySelector(container) as HTMLElement
51
+ if (!el) throw new Error(`Container element not found: ${container}`)
52
+ this._el = el
53
+ // eslint-disable-next-line style/brace-style
54
+ } else {
55
+ this._el = container
56
+ }
57
+
58
+ this._options = { ...defaultOptions, ...options }
59
+ this._loaders = defaultLoaders
60
+ this._store = new StateStore()
61
+
62
+ // Setup
63
+ this.setupElement()
64
+ this.setupState()
65
+ this.setupKeyboard()
66
+ this.setupActivity()
67
+ this.setupStorage()
68
+ this.setupMediaSession()
69
+ this.setupEvents()
70
+
71
+ // Load initial source
72
+ if (this._options.src) {
73
+ this.setSrc(this._options.src)
74
+ }
75
+
76
+ // Emit init event
77
+ this._events.emit('init')
78
+ }
79
+
80
+ // === Public Properties ===
81
+
82
+ get el(): HTMLElement {
83
+ return this._el
84
+ }
85
+
86
+ get state(): PlayerState {
87
+ return this._store.getState()
88
+ }
89
+
90
+ get provider(): Provider | null {
91
+ return this._provider
92
+ }
93
+
94
+ get ready(): boolean {
95
+ return this._provider?.ready || false
96
+ }
97
+
98
+ // === Setup ===
99
+
100
+ private setupElement(): void {
101
+ this._el.classList.add('ts-video-player')
102
+ this._el.setAttribute('tabindex', '0')
103
+ this._el.setAttribute('role', 'region')
104
+ this._el.setAttribute('aria-roledescription', 'video player')
105
+ this._el.setAttribute('aria-label', this._options.title || 'Video player')
106
+ this._el.setAttribute('aria-keyshortcuts', 'Space K M F I C ArrowLeft ArrowRight J L ArrowUp ArrowDown Home End 0 1 2 3 4 5 6 7 8 9')
107
+
108
+ // Create inner structure
109
+ const mediaContainer = document.createElement('div')
110
+ mediaContainer.className = 'ts-video-player__container'
111
+ this._el.appendChild(mediaContainer)
112
+
113
+ // Create ARIA live region for screen reader announcements
114
+ if (this._options.announcements !== false) {
115
+ const liveRegion = document.createElement('div')
116
+ liveRegion.className = 'ts-video-player__announcer'
117
+ liveRegion.setAttribute('role', 'status')
118
+ liveRegion.setAttribute('aria-live', 'polite')
119
+ liveRegion.setAttribute('aria-atomic', 'true')
120
+ // Visually hidden but accessible to screen readers
121
+ liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'
122
+ this._el.appendChild(liveRegion)
123
+ this._announcer = liveRegion
124
+ }
125
+
126
+ // Store reference
127
+ const _el = this._el as any
128
+ _el.__videoPlayer = this
129
+ }
130
+
131
+ private setupState(): void {
132
+ // Initial state from options
133
+ this._store.batch({
134
+ volume: this._options.volume ?? 1,
135
+ muted: this._options.muted ?? false,
136
+ loop: this._options.loop ?? false,
137
+ autoplay: this._options.autoplay ?? false,
138
+ playsinline: this._options.playsinline ?? true,
139
+ playbackRate: this._options.playbackRate ?? 1,
140
+ poster: this._options.poster || '',
141
+ title: this._options.title || '',
142
+ preload: this._options.preload || 'metadata',
143
+ })
144
+
145
+ // Subscribe to state changes
146
+ const unsubscribe = this._store.subscribe((state) => {
147
+ // Update DOM attributes
148
+ this.updateAttributes(state)
149
+ })
150
+
151
+ this._cleanupFns.push(unsubscribe)
152
+ }
153
+
154
+ private setupKeyboard(): void {
155
+ if (this._options.keyboard === false) return
156
+
157
+ const actions: KeyboardActions = {
158
+ togglePlay: () => this.togglePlay(),
159
+ toggleMute: () => this.toggleMute(),
160
+ toggleFullscreen: () => this.toggleFullscreen(),
161
+ togglePiP: () => this.togglePiP(),
162
+ toggleCaptions: () => this.toggleCaptions(),
163
+ seekBackward: () => this.seekBy(-5),
164
+ seekForward: () => this.seekBy(5),
165
+ volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
166
+ volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
167
+ speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
168
+ speedDown: () => this.setPlaybackRate(Math.max(0.25, this.state.playbackRate - 0.25)),
169
+ seekStart: () => this.seekTo(0),
170
+ seekEnd: () => this.seekTo(this.state.duration),
171
+ seekPercent: (percent) => this.seekTo((percent / 100) * this.state.duration),
172
+ }
173
+
174
+ const config = typeof this._options.keyboard === 'object' ? this._options.keyboard : {}
175
+ const handler = createKeyboardHandler(actions, {
176
+ seekStep: config.seekStep,
177
+ volumeStep: config.volumeStep,
178
+ })
179
+
180
+ const target = config.global ? document : this._el
181
+ target.addEventListener('keydown', handler as EventListener)
182
+
183
+ this._cleanupFns.push(() => target.removeEventListener('keydown', handler as EventListener))
184
+ }
185
+
186
+ private setupActivity(): void {
187
+ const cleanup = createActivityDetector(this._el, {
188
+ timeout: this._options.controlsTimeout || 3000,
189
+ onActive: () => {
190
+ this._store.batch({ userActive: true, controlsVisible: true })
191
+ this._events.emit('useractivitychange', true)
192
+ this._events.emit('controlschange', true)
193
+ },
194
+ onInactive: () => {
195
+ // Only hide controls if playing
196
+ if (this.state.playing) {
197
+ this._store.batch({ userActive: false, controlsVisible: false })
198
+ this._events.emit('useractivitychange', false)
199
+ this._events.emit('controlschange', false)
200
+ }
201
+ },
202
+ })
203
+
204
+ this._cleanupFns.push(cleanup)
205
+
206
+ // Pointer over tracking
207
+ const onMouseEnter = () => this._store.set('pointerOver', true)
208
+ const onMouseLeave = () => this._store.set('pointerOver', false)
209
+ this._el.addEventListener('mouseenter', onMouseEnter)
210
+ this._el.addEventListener('mouseleave', onMouseLeave)
211
+ this._cleanupFns.push(
212
+ () => this._el.removeEventListener('mouseenter', onMouseEnter),
213
+ () => this._el.removeEventListener('mouseleave', onMouseLeave),
214
+ )
215
+ }
216
+
217
+ private setupStorage(): void {
218
+ if (!this._options.storage) return
219
+
220
+ const config = typeof this._options.storage === 'object' ? this._options.storage : {}
221
+ const storage = config.type === 'session' ? sessionStorage : localStorage
222
+
223
+ const cleanup = createStorageSync(this._store, config.persist || ['volume', 'muted', 'playbackRate'], storage)
224
+
225
+ this._cleanupFns.push(cleanup)
226
+ }
227
+
228
+ private setupMediaSession(): void {
229
+ if (this._options.mediaSession === false) return
230
+
231
+ const sessionOpts: MediaSessionOptions | undefined =
232
+ typeof this._options.mediaSession === 'object'
233
+ ? this._options.mediaSession
234
+ : this._options.title
235
+ ? { title: this._options.title }
236
+ : undefined
237
+
238
+ const cleanup = createMediaSession(
239
+ this._store,
240
+ {
241
+ play: () => this.play(),
242
+ pause: () => this.pause(),
243
+ seekTo: (time) => this.seekTo(time),
244
+ seekBy: (offset) => this.seekBy(offset),
245
+ stop: () => this.stop(),
246
+ },
247
+ sessionOpts,
248
+ )
249
+
250
+ this._cleanupFns.push(cleanup)
251
+ }
252
+
253
+ private setupEvents(): void {
254
+ // Attach option event listeners
255
+ if (this._options.on) {
256
+ for (const [event, handler] of Object.entries(this._options.on)) {
257
+ if (handler) {
258
+ this.on(event as keyof PlayerEventMap, handler as PlayerEventMap[keyof PlayerEventMap])
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ private updateAttributes(state: PlayerState): void {
265
+ const el = this._el
266
+
267
+ // Playback state
268
+ el.dataset.paused = String(state.paused)
269
+ el.dataset.playing = String(state.playing)
270
+ el.dataset.started = String(state.started)
271
+ el.dataset.ended = String(state.ended)
272
+ el.dataset.seeking = String(state.seeking)
273
+ el.dataset.waiting = String(state.waiting)
274
+ el.dataset.loop = String(state.loop)
275
+ el.dataset.autoplay = String(state.autoplay)
276
+
277
+ // Loading state
278
+ el.dataset.loading = String(state.waiting || state.loadingState === 'loading')
279
+ el.dataset.canPlay = String(state.canPlay)
280
+ el.dataset.loadingState = state.loadingState
281
+ el.setAttribute('aria-busy', String(state.waiting || state.loadingState === 'loading'))
282
+
283
+ // Volume
284
+ el.dataset.muted = String(state.muted)
285
+ el.dataset.volumeLevel = state.muted || state.volume === 0
286
+ ? 'silent'
287
+ : state.volume < 0.5
288
+ ? 'low'
289
+ : 'high'
290
+
291
+ // Fullscreen / PiP
292
+ el.dataset.fullscreen = String(state.fullscreen)
293
+ el.dataset.pip = String(state.pictureInPicture)
294
+
295
+ // UI
296
+ el.dataset.controlsVisible = String(state.controlsVisible)
297
+ el.dataset.userActive = String(state.userActive)
298
+
299
+ // Media type
300
+ el.dataset.mediaType = state.mediaType
301
+ el.dataset.streamType = state.streamType
302
+
303
+ // Captions
304
+ const hasActiveCaptions = state.textTracks.some((t) => t.mode === 'showing')
305
+ el.dataset.captions = String(hasActiveCaptions)
306
+
307
+ // Error
308
+ el.dataset.error = String(!!state.error)
309
+ }
310
+
311
+ /**
312
+ * Announce a message to screen readers via the ARIA live region
313
+ */
314
+ private announce(message: string): void {
315
+ if (!this._announcer) return
316
+ // Clear and set to force re-announcement of same message
317
+ this._announcer.textContent = ''
318
+ requestAnimationFrame(() => {
319
+ if (this._announcer) this._announcer.textContent = message
320
+ })
321
+ }
322
+
323
+ // === Lifecycle ===
324
+
325
+ destroy(): void {
326
+ if (this._destroyed) return
327
+ this._destroyed = true
328
+ this._sourceLoadId++
329
+
330
+ // Emit destroy event
331
+ this._events.emit('destroy')
332
+
333
+ // Cleanup
334
+ this._cleanupFns.forEach((fn) => fn())
335
+ this._cleanupFns = []
336
+
337
+ // Destroy provider
338
+ this._provider?.destroy()
339
+ this._provider = null
340
+
341
+ // Remove events
342
+ this._events.removeAllListeners()
343
+
344
+ // Reset store
345
+ this._store.reset()
346
+
347
+ // Clear element
348
+ this._el.classList.remove('ts-video-player')
349
+ this._el.innerHTML = ''
350
+ delete (this._el as any).__videoPlayer
351
+ }
352
+
353
+ // === Source ===
354
+
355
+ async setSrc(src: Src | Src[]): Promise<void> {
356
+ if (this._destroyed) return
357
+
358
+ // Invalidate any older asynchronous provider load.
359
+ const loadId = ++this._sourceLoadId
360
+ const sources = Array.isArray(src) ? src : [src]
361
+ if (sources.length === 0) {
362
+ return
363
+ }
364
+
365
+ // Update state
366
+ this._store.batch({
367
+ sources,
368
+ src: sources[0],
369
+ loadingState: 'loading',
370
+ error: null,
371
+ })
372
+
373
+ this._events.emit('sourceschange', sources)
374
+
375
+ const candidates = findSourceCandidates(sources, this._loaders)
376
+ if (candidates.length === 0) {
377
+ this._store.batch({
378
+ loadingState: 'error',
379
+ error: { code: 4, message: 'No compatible provider found for source' },
380
+ })
381
+ return
382
+ }
383
+
384
+ let lastError: unknown
385
+ for (const candidate of candidates) {
386
+ if (this._destroyed || loadId !== this._sourceLoadId) return
387
+ const { loader } = candidate
388
+
389
+ if (this._provider && this._provider.type !== loader.type) {
390
+ this._provider.destroy()
391
+ this._provider = null
392
+ this._events.emit('providerchange', null)
393
+ }
394
+
395
+ if (!this._provider) {
396
+ const mediaContainer = this._el.querySelector('.ts-video-player__container') as HTMLElement
397
+ try {
398
+ this._provider = await loader.load(mediaContainer, {
399
+ ...this._options,
400
+ mediaType: loader.mediaType(candidate.src),
401
+ })
402
+ if (this._destroyed || loadId !== this._sourceLoadId) {
403
+ this._provider.destroy()
404
+ this._provider = null
405
+ return
406
+ }
407
+ this._events.emit('providerchange', this._provider)
408
+ this.attachProviderEvents(this._provider)
409
+ }
410
+ catch (error) {
411
+ lastError = error
412
+ this._provider = null
413
+ continue
414
+ }
415
+ }
416
+
417
+ try {
418
+ // Native media elements should receive all same-provider sources so
419
+ // the browser can choose by MIME and codec support.
420
+ const compatibleSources = loader.type === 'video'
421
+ ? candidates.filter(item => item.loader.type === loader.type).map(item => item.src)
422
+ : [candidate.src]
423
+ await this._provider.load(compatibleSources.length > 1 ? compatibleSources : candidate.src)
424
+ if (this._destroyed || loadId !== this._sourceLoadId) return
425
+ this._store.batch({
426
+ src: candidate.src,
427
+ currentSourceIndex: candidate.index,
428
+ mediaType: loader.mediaType(candidate.src),
429
+ providerType: loader.type,
430
+ })
431
+ return
432
+ }
433
+ catch (error) {
434
+ lastError = error
435
+ if (this._provider) {
436
+ this._provider.destroy()
437
+ this._provider = null
438
+ }
439
+ this._events.emit('providerchange', null)
440
+ }
441
+ }
442
+
443
+ if (this._destroyed || loadId !== this._sourceLoadId) return
444
+ this._store.batch({
445
+ loadingState: 'error',
446
+ error: { code: 4, message: 'Failed to load all compatible sources', details: lastError },
447
+ })
448
+ }
449
+
450
+ getSrc(): Src | null {
451
+ return this.state.src
452
+ }
453
+
454
+ private attachProviderEvents(provider: Provider): void {
455
+ // Forward provider events and update state
456
+ provider.on('loadstart', () => {
457
+ this._store.set('loadingState', 'loading')
458
+ this._events.emit('loadstart')
459
+ })
460
+
461
+ provider.on('loadedmetadata', () => {
462
+ this._events.emit('loadedmetadata')
463
+ })
464
+
465
+ provider.on('loadeddata', () => {
466
+ this._events.emit('loadeddata')
467
+ })
468
+
469
+ provider.on('canplay', () => {
470
+ this._store.batch({ canPlay: true, loadingState: 'loaded' })
471
+ this._events.emit('canplay')
472
+ })
473
+
474
+ provider.on('canplaythrough', () => {
475
+ this._store.set('canPlayThrough', true)
476
+ this._events.emit('canplaythrough')
477
+ })
478
+
479
+ provider.on('play', () => {
480
+ this._store.batch({ paused: false, ended: false })
481
+ this._events.emit('play')
482
+ this.announce('Playing')
483
+ })
484
+
485
+ provider.on('pause', () => {
486
+ this._store.set('paused', true)
487
+ this._events.emit('pause')
488
+ this.announce('Paused')
489
+ })
490
+
491
+ provider.on('playing', () => {
492
+ this._store.batch({ playing: true, started: true, waiting: false, playbackState: 'playing' })
493
+ this._events.emit('playing')
494
+ })
495
+
496
+ provider.on('waiting', () => {
497
+ this._store.batch({ waiting: true, playbackState: 'buffering' })
498
+ this._events.emit('waiting')
499
+ })
500
+
501
+ provider.on('seeking', () => {
502
+ this._store.set('seeking', true)
503
+ this._events.emit('seeking')
504
+ })
505
+
506
+ provider.on('seeked', () => {
507
+ this._store.set('seeking', false)
508
+ this._events.emit('seeked')
509
+ })
510
+
511
+ provider.on('timeupdate', (time) => {
512
+ this._store.set('currentTime', time)
513
+ this._events.emit('timeupdate', time)
514
+ })
515
+
516
+ provider.on('durationchange', (duration) => {
517
+ this._store.set('duration', duration)
518
+ this._events.emit('durationchange', duration)
519
+ })
520
+
521
+ provider.on('volumechange', (volume, muted) => {
522
+ this._store.batch({ volume, muted })
523
+ this._events.emit('volumechange', volume, muted)
524
+ if (muted) {
525
+ this.announce('Muted')
526
+ // eslint-disable-next-line style/brace-style
527
+ } else {
528
+ this.announce(`Volume ${Math.round(volume * 100)}%`)
529
+ }
530
+ })
531
+
532
+ provider.on('ratechange', (rate) => {
533
+ this._store.set('playbackRate', rate)
534
+ this._events.emit('ratechange', rate)
535
+ })
536
+
537
+ provider.on('progress', (buffered) => {
538
+ this._store.batch({
539
+ buffered,
540
+ bufferedAmount: selectBufferedAmount({ ...this.state, buffered }),
541
+ })
542
+ this._events.emit('progress', buffered)
543
+ })
544
+
545
+ provider.on('ended', () => {
546
+ this._store.batch({ ended: true, playing: false, playbackState: 'ended' })
547
+ this._events.emit('ended')
548
+ this.announce('Playback ended')
549
+
550
+ // Handle loop
551
+ if (this.state.loop) {
552
+ this.seekTo(0)
553
+ this.play()
554
+ }
555
+ })
556
+
557
+ provider.on('error', (error) => {
558
+ this._store.batch({ loadingState: 'error', error })
559
+ this._events.emit('error', error)
560
+ this.announce(`Error: ${error.message}`)
561
+ })
562
+
563
+ provider.on('statechange', (state) => {
564
+ this._store.batch(state)
565
+ this._events.emit('statechange', state)
566
+ })
567
+
568
+ provider.on('qualitychange', (quality) => {
569
+ this._store.set('qualities', provider.getQualities())
570
+ this._events.emit('qualitychange', quality)
571
+ })
572
+
573
+ provider.on('fullscreenchange', (fullscreen) => {
574
+ this._store.set('fullscreen', fullscreen)
575
+ this._events.emit('fullscreenchange', fullscreen)
576
+ this.announce(fullscreen ? 'Entered fullscreen' : 'Exited fullscreen')
577
+ })
578
+
579
+ provider.on('pipchange', (pip) => {
580
+ this._store.set('pictureInPicture', pip)
581
+ this._events.emit('pipchange', pip)
582
+ })
583
+
584
+ provider.on('availabilitychange', (feature, availability) => {
585
+ if (feature === 'volume') this._store.set('volumeAvailability', availability)
586
+ else if (feature === 'fullscreen') this._store.set('fullscreenAvailability', availability)
587
+ else if (feature === 'pip') this._store.set('pipAvailability', availability)
588
+ this._events.emit('availabilitychange', feature, availability)
589
+ })
590
+ }
591
+
592
+ // === Playback ===
593
+
594
+ async play(): Promise<void> {
595
+ if (!this._provider) return
596
+ await this._provider.play()
597
+ }
598
+
599
+ pause(): void {
600
+ this._provider?.pause()
601
+ }
602
+
603
+ togglePlay(): void {
604
+ if (this.state.paused) {
605
+ this.play().catch(() => {
606
+ // Play rejection is already handled and emitted as error by provider
607
+ })
608
+ // eslint-disable-next-line style/brace-style
609
+ } else {
610
+ this.pause()
611
+ }
612
+ }
613
+
614
+ stop(): void {
615
+ this._provider?.stop()
616
+ this._store.batch({ playing: false, paused: true, currentTime: 0, playbackState: 'idle' })
617
+ }
618
+
619
+ // === Seeking ===
620
+
621
+ seekTo(time: number): void {
622
+ this._provider?.seekTo(time)
623
+ }
624
+
625
+ seekBy(offset: number): void {
626
+ const newTime = Math.max(0, Math.min(this.state.currentTime + offset, this.state.duration))
627
+ this.seekTo(newTime)
628
+ }
629
+
630
+ // === Volume ===
631
+
632
+ setVolume(volume: number): void {
633
+ if (this.state.volumeAvailability === 'unsupported') return
634
+ const clamped = Math.max(0, Math.min(1, volume))
635
+ this._provider?.setVolume(clamped)
636
+ this._store.set('volume', clamped)
637
+ }
638
+
639
+ setMuted(muted: boolean): void {
640
+ this._provider?.setMuted(muted)
641
+ this._store.set('muted', muted)
642
+ }
643
+
644
+ toggleMute(): void {
645
+ this.setMuted(!this.state.muted)
646
+ }
647
+
648
+ // === Playback Rate ===
649
+
650
+ setPlaybackRate(rate: number): void {
651
+ const clamped = Math.max(0.25, Math.min(4, rate))
652
+ this._provider?.setPlaybackRate(clamped)
653
+ this._store.set('playbackRate', clamped)
654
+ }
655
+
656
+ // === Fullscreen ===
657
+
658
+ async enterFullscreen(): Promise<void> {
659
+ if (this.state.fullscreenAvailability === 'unsupported') return
660
+ // Exit PiP before entering fullscreen (mutual exclusion)
661
+ if (this.state.pictureInPicture) {
662
+ await this.exitPiP()
663
+ }
664
+ await this._provider?.enterFullscreen()
665
+ if (this._options.fullscreenOrientationLock !== false) {
666
+ lockOrientation('landscape')
667
+ }
668
+ }
669
+
670
+ async exitFullscreen(): Promise<void> {
671
+ await this._provider?.exitFullscreen()
672
+ if (this._options.fullscreenOrientationLock !== false) {
673
+ unlockOrientation()
674
+ }
675
+ }
676
+
677
+ async toggleFullscreen(): Promise<void> {
678
+ if (this.state.fullscreen) {
679
+ await this.exitFullscreen()
680
+ // eslint-disable-next-line style/brace-style
681
+ } else {
682
+ await this.enterFullscreen()
683
+ }
684
+ }
685
+
686
+ // === Picture-in-Picture ===
687
+
688
+ async enterPiP(): Promise<void> {
689
+ if (this.state.pipAvailability === 'unsupported') return
690
+ // Exit fullscreen before entering PiP (mutual exclusion)
691
+ if (this.state.fullscreen) {
692
+ await this.exitFullscreen()
693
+ }
694
+ await this._provider?.enterPiP()
695
+ }
696
+
697
+ async exitPiP(): Promise<void> {
698
+ await this._provider?.exitPiP()
699
+ }
700
+
701
+ async togglePiP(): Promise<void> {
702
+ if (this.state.pictureInPicture) {
703
+ await this.exitPiP()
704
+ // eslint-disable-next-line style/brace-style
705
+ } else {
706
+ await this.enterPiP()
707
+ }
708
+ }
709
+
710
+ // === Tracks ===
711
+
712
+ setQuality(quality: VideoQuality | 'auto'): void {
713
+ this._provider?.setQuality(quality)
714
+ if (quality === 'auto') {
715
+ this._store.set('autoQuality', true)
716
+ // eslint-disable-next-line style/brace-style
717
+ } else {
718
+ this._store.set('autoQuality', false)
719
+ }
720
+ }
721
+
722
+ setTextTrack(trackId: string, mode: 'disabled' | 'hidden' | 'showing'): void {
723
+ this._provider?.setTextTrackMode(trackId, mode)
724
+ this._store.set('textTracks', this._provider?.getTextTracks() || [])
725
+ }
726
+
727
+ setAudioTrack(trackId: string): void {
728
+ this._provider?.setAudioTrack(trackId)
729
+ this._store.set('audioTracks', this._provider?.getAudioTracks() || [])
730
+ }
731
+
732
+ private toggleCaptions(): void {
733
+ const textTracks = this.state.textTracks
734
+ const showing = textTracks.find((t) => t.mode === 'showing')
735
+
736
+ if (showing) {
737
+ this.setTextTrack(showing.id, 'disabled')
738
+ // eslint-disable-next-line style/brace-style
739
+ } else {
740
+ const first = textTracks.find((t) => t.kind === 'subtitles' || t.kind === 'captions')
741
+ if (first) {
742
+ this.setTextTrack(first.id, 'showing')
743
+ }
744
+ }
745
+ }
746
+
747
+ // === Events ===
748
+
749
+ on<K extends keyof PlayerEventMap>(event: K, handler: PlayerEventMap[K]): void {
750
+ this._events.on(event, handler)
751
+ }
752
+
753
+ off<K extends keyof PlayerEventMap>(event: K, handler: PlayerEventMap[K]): void {
754
+ this._events.off(event, handler)
755
+ }
756
+
757
+ once<K extends keyof PlayerEventMap>(event: K, handler: PlayerEventMap[K]): void {
758
+ this._events.once(event, handler)
759
+ }
760
+
761
+ // === State Subscription ===
762
+
763
+ subscribe(
764
+ // eslint-disable-next-line pickier/no-unused-vars
765
+ keyOrListener: keyof PlayerState | '*' | ((state: PlayerState, key?: keyof PlayerState) => void),
766
+ listener?: (state: PlayerState, key?: keyof PlayerState) => void,
767
+ ): () => void {
768
+ return this._store.subscribe(keyOrListener, listener)
769
+ }
770
+ }
771
+
772
+ // =============================================================================
773
+ // Default Options
774
+ // =============================================================================
775
+
776
+ // eslint-disable-next-line pickier/no-unused-vars
777
+ const defaultOptions: PlayerOptions = {
778
+ autoplay: false,
779
+ loop: false,
780
+ muted: false,
781
+ volume: 1,
782
+ playbackRate: 1,
783
+ playsinline: true,
784
+ preload: 'metadata',
785
+ controls: true,
786
+ keyboard: true,
787
+ controlsTimeout: 3000,
788
+ storage: true,
789
+ }
790
+
791
+ // =============================================================================
792
+ // Factory Function
793
+ // =============================================================================
794
+
795
+ /**
796
+ * Create a new player instance
797
+ *
798
+ * @example
799
+ * ```typescript
800
+ * const player = createPlayer('#video-container', {
801
+ * src: 'https://example.com/video.mp4',
802
+ * autoplay: true,
803
+ * })
804
+ *
805
+ * player.on('playing', () => console.log('Video is playing!'))
806
+ * ```
807
+ */
808
+ export function createPlayer(container: HTMLElement | string, options?: PlayerOptions): Player {
809
+ return new Player(container, options)
810
+ }
811
+
812
+ // =============================================================================
813
+ // Get Player Instance
814
+ // =============================================================================
815
+
816
+ /**
817
+ * Get player instance from element
818
+ */
819
+ export function getPlayer(element: HTMLElement): Player | null {
820
+ return (element as any).__videoPlayer || null
821
+ }