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,673 @@
1
+ /**
2
+ * Video Analytics Plugin
3
+ *
4
+ * Deep integration with ts-analytics for comprehensive video tracking.
5
+ *
6
+ * @module plugins/analytics
7
+ */
8
+
9
+ import type { Player } from '../player'
10
+ import type { VideoQuality, TextTrack, AudioTrack } from '../types'
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ export interface VideoAnalyticsConfig {
17
+ /** Site ID for analytics */
18
+ siteId: string
19
+ /** Analytics API endpoint */
20
+ apiEndpoint: string
21
+ /** Video ID (unique identifier) */
22
+ videoId: string
23
+ /** Video title */
24
+ videoTitle?: string
25
+ /** Video duration (auto-detected if not provided) */
26
+ duration?: number
27
+ /** Content type (vod, live, etc.) */
28
+ contentType?: 'vod' | 'live' | 'dvr'
29
+ /** Content category */
30
+ category?: string
31
+ /** Content tags */
32
+ tags?: string[]
33
+ /** Creator/channel ID */
34
+ creatorId?: string
35
+ /** Series/playlist ID */
36
+ seriesId?: string
37
+ /** Episode number */
38
+ episode?: number
39
+ /** Season number */
40
+ season?: number
41
+ /** Track playback milestones (percentages) */
42
+ milestones?: number[]
43
+ /** Track heartbeat interval (seconds) */
44
+ heartbeatInterval?: number
45
+ /** Track quality changes */
46
+ trackQualityChanges?: boolean
47
+ /** Track seeking behavior */
48
+ trackSeeking?: boolean
49
+ /** Track buffering */
50
+ trackBuffering?: boolean
51
+ /** Track errors */
52
+ trackErrors?: boolean
53
+ /** Track engagement (pauses, replays) */
54
+ trackEngagement?: boolean
55
+ /** Custom properties to include */
56
+ customProperties?: Record<string, string | number | boolean>
57
+ /** Session ID (auto-generated if not provided) */
58
+ sessionId?: string
59
+ /** Visitor ID (auto-generated if not provided) */
60
+ visitorId?: string
61
+ /** Disable analytics */
62
+ disabled?: boolean
63
+ }
64
+
65
+ export interface VideoEvent {
66
+ /** Event name */
67
+ name: VideoEventName
68
+ /** Timestamp */
69
+ timestamp: Date
70
+ /** Current playback time */
71
+ currentTime: number
72
+ /** Video duration */
73
+ duration: number
74
+ /** Additional properties */
75
+ properties?: Record<string, unknown>
76
+ }
77
+
78
+ export type VideoEventName =
79
+ | 'video_start'
80
+ | 'video_play'
81
+ | 'video_pause'
82
+ | 'video_resume'
83
+ | 'video_seek'
84
+ | 'video_buffer_start'
85
+ | 'video_buffer_end'
86
+ | 'video_quality_change'
87
+ | 'video_volume_change'
88
+ | 'video_mute'
89
+ | 'video_unmute'
90
+ | 'video_fullscreen_enter'
91
+ | 'video_fullscreen_exit'
92
+ | 'video_pip_enter'
93
+ | 'video_pip_exit'
94
+ | 'video_caption_enable'
95
+ | 'video_caption_disable'
96
+ | 'video_caption_change'
97
+ | 'video_audio_track_change'
98
+ | 'video_speed_change'
99
+ | 'video_error'
100
+ | 'video_milestone'
101
+ | 'video_quartile'
102
+ | 'video_complete'
103
+ | 'video_heartbeat'
104
+ | 'video_engagement'
105
+ | 'video_ad_start'
106
+ | 'video_ad_complete'
107
+ | 'video_ad_skip'
108
+ | 'video_ad_click'
109
+
110
+ export interface VideoAnalyticsState {
111
+ /** Has video started playing */
112
+ hasStarted: boolean
113
+ /** Has video completed */
114
+ hasCompleted: boolean
115
+ /** Total watch time in seconds */
116
+ watchTime: number
117
+ /** Number of pauses */
118
+ pauseCount: number
119
+ /** Number of seeks */
120
+ seekCount: number
121
+ /** Number of buffer events */
122
+ bufferCount: number
123
+ /** Total buffer time in seconds */
124
+ bufferTime: number
125
+ /** Number of quality changes */
126
+ qualityChangeCount: number
127
+ /** Reached milestones */
128
+ reachedMilestones: number[]
129
+ /** Reached quartiles */
130
+ reachedQuartiles: number[]
131
+ /** Playback start time */
132
+ playbackStartTime: number | null
133
+ /** Last heartbeat time */
134
+ lastHeartbeat: number
135
+ /** Is currently buffering */
136
+ isBuffering: boolean
137
+ /** Buffer start time */
138
+ bufferStartTime: number | null
139
+ }
140
+
141
+ // =============================================================================
142
+ // Default Configuration
143
+ // =============================================================================
144
+
145
+ const DEFAULT_MILESTONES = [10, 25, 50, 75, 90, 100]
146
+ const DEFAULT_HEARTBEAT_INTERVAL = 30 // seconds
147
+
148
+ // =============================================================================
149
+ // Video Analytics Class
150
+ // =============================================================================
151
+
152
+ export class VideoAnalytics {
153
+ private player: Player | null = null
154
+ private config: VideoAnalyticsConfig
155
+ private state: VideoAnalyticsState
156
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
157
+ private eventQueue: VideoEvent[] = []
158
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
159
+ private sessionId: string
160
+ private visitorId: string
161
+ private unsubscribers: Array<() => void> = []
162
+
163
+ constructor(config: VideoAnalyticsConfig) {
164
+ this.config = {
165
+ milestones: DEFAULT_MILESTONES,
166
+ heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
167
+ trackQualityChanges: true,
168
+ trackSeeking: true,
169
+ trackBuffering: true,
170
+ trackErrors: true,
171
+ trackEngagement: true,
172
+ ...config,
173
+ }
174
+
175
+ this.sessionId = config.sessionId || this.generateId()
176
+ this.visitorId = config.visitorId || this.getOrCreateVisitorId()
177
+
178
+ this.state = this.createInitialState()
179
+ }
180
+
181
+ /**
182
+ * Create initial analytics state
183
+ */
184
+ private createInitialState(): VideoAnalyticsState {
185
+ return {
186
+ hasStarted: false,
187
+ hasCompleted: false,
188
+ watchTime: 0,
189
+ pauseCount: 0,
190
+ seekCount: 0,
191
+ bufferCount: 0,
192
+ bufferTime: 0,
193
+ qualityChangeCount: 0,
194
+ reachedMilestones: [],
195
+ reachedQuartiles: [],
196
+ playbackStartTime: null,
197
+ lastHeartbeat: 0,
198
+ isBuffering: false,
199
+ bufferStartTime: null,
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Attach to a player instance
205
+ */
206
+ attach(player: Player): void {
207
+ if (this.config.disabled) return
208
+
209
+ this.player = player
210
+ this.attachEventListeners()
211
+ this.startHeartbeat()
212
+ }
213
+
214
+ /**
215
+ * Detach from player
216
+ */
217
+ detach(): void {
218
+ this.stopHeartbeat()
219
+ this.unsubscribers.forEach(unsub => unsub())
220
+ this.unsubscribers = []
221
+ this.flush()
222
+ this.player = null
223
+ }
224
+
225
+ /**
226
+ * Attach event listeners to player
227
+ */
228
+ private attachEventListeners(): void {
229
+ if (!this.player) return
230
+
231
+ // Play events
232
+ this.on('play', () => {
233
+ if (!this.state.hasStarted) {
234
+ this.state.hasStarted = true
235
+ this.state.playbackStartTime = Date.now()
236
+ this.trackEvent('video_start')
237
+ }
238
+ else {
239
+ this.trackEvent('video_resume')
240
+ }
241
+ this.trackEvent('video_play')
242
+ })
243
+
244
+ // Pause
245
+ this.on('pause', () => {
246
+ this.state.pauseCount++
247
+ this.updateWatchTime()
248
+ this.trackEvent('video_pause')
249
+ })
250
+
251
+ // Seeking
252
+ if (this.config.trackSeeking) {
253
+ this.on('seeking', () => {
254
+ this.state.seekCount++
255
+ })
256
+
257
+ this.on('seeked', () => {
258
+ const playerState = this.player?.state
259
+ this.trackEvent('video_seek', {
260
+ seekTo: playerState?.currentTime || 0,
261
+ })
262
+ })
263
+ }
264
+
265
+ // Buffering
266
+ if (this.config.trackBuffering) {
267
+ this.on('waiting', () => {
268
+ if (!this.state.isBuffering) {
269
+ this.state.isBuffering = true
270
+ this.state.bufferStartTime = Date.now()
271
+ this.state.bufferCount++
272
+ this.trackEvent('video_buffer_start')
273
+ }
274
+ })
275
+
276
+ this.on('playing', () => {
277
+ if (this.state.isBuffering) {
278
+ this.state.isBuffering = false
279
+ if (this.state.bufferStartTime) {
280
+ this.state.bufferTime += (Date.now() - this.state.bufferStartTime) / 1000
281
+ }
282
+ this.trackEvent('video_buffer_end', {
283
+ bufferDuration: this.state.bufferStartTime
284
+ ? (Date.now() - this.state.bufferStartTime) / 1000
285
+ : 0,
286
+ })
287
+ this.state.bufferStartTime = null
288
+ }
289
+ })
290
+ }
291
+
292
+ // Time update for milestones
293
+ this.on('timeupdate', () => {
294
+ this.checkMilestones()
295
+ })
296
+
297
+ // Quality changes
298
+ if (this.config.trackQualityChanges) {
299
+ this.on('qualitychange', (quality: VideoQuality | null) => {
300
+ this.state.qualityChangeCount++
301
+ this.trackEvent('video_quality_change', {
302
+ quality: quality ? `${quality.height}p` : 'auto',
303
+ bitrate: quality?.bitrate,
304
+ })
305
+ })
306
+ }
307
+
308
+ // Volume changes
309
+ this.on('volumechange', () => {
310
+ const playerState = this.player?.state
311
+ if (playerState?.muted) {
312
+ this.trackEvent('video_mute')
313
+ }
314
+ else {
315
+ this.trackEvent('video_volume_change', {
316
+ volume: playerState?.volume,
317
+ })
318
+ }
319
+ })
320
+
321
+ // Fullscreen
322
+ this.on('fullscreenchange', (isFullscreen: boolean) => {
323
+ this.trackEvent(isFullscreen ? 'video_fullscreen_enter' : 'video_fullscreen_exit')
324
+ })
325
+
326
+ // PiP
327
+ this.on('pipchange', (isPiP: boolean) => {
328
+ this.trackEvent(isPiP ? 'video_pip_enter' : 'video_pip_exit')
329
+ })
330
+
331
+ // Captions
332
+ this.on('texttrackchange', (track: TextTrack | null) => {
333
+ if (track) {
334
+ this.trackEvent('video_caption_change', {
335
+ language: track.language,
336
+ label: track.label,
337
+ })
338
+ }
339
+ else {
340
+ this.trackEvent('video_caption_disable')
341
+ }
342
+ })
343
+
344
+ // Audio track
345
+ this.on('audiotrackchange', (track: AudioTrack | null) => {
346
+ if (track) {
347
+ this.trackEvent('video_audio_track_change', {
348
+ language: track.language,
349
+ label: track.label,
350
+ })
351
+ }
352
+ })
353
+
354
+ // Speed change
355
+ this.on('ratechange', (rate: number) => {
356
+ this.trackEvent('video_speed_change', { speed: rate })
357
+ })
358
+
359
+ // Ended
360
+ this.on('ended', () => {
361
+ if (!this.state.hasCompleted) {
362
+ this.state.hasCompleted = true
363
+ this.updateWatchTime()
364
+ this.trackEvent('video_complete', {
365
+ watchTime: this.state.watchTime,
366
+ pauseCount: this.state.pauseCount,
367
+ seekCount: this.state.seekCount,
368
+ bufferCount: this.state.bufferCount,
369
+ bufferTime: this.state.bufferTime,
370
+ })
371
+ }
372
+ })
373
+
374
+ // Errors
375
+ if (this.config.trackErrors) {
376
+ this.on('error', (error: any) => {
377
+ this.trackEvent('video_error', {
378
+ code: error?.code,
379
+ message: error?.message,
380
+ })
381
+ })
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Helper to subscribe to player events
387
+ */
388
+ private on(event: string, handler: (...args: any[]) => void): void {
389
+ if (!this.player) return
390
+ this.player.on(event as any, handler)
391
+ this.unsubscribers.push(() => this.player?.off(event as any, handler))
392
+ }
393
+
394
+ /**
395
+ * Check and track milestones
396
+ */
397
+ private checkMilestones(): void {
398
+ const playerState = this.player?.state
399
+ if (!playerState || !playerState.duration) return
400
+
401
+ const progress = (playerState.currentTime / playerState.duration) * 100
402
+
403
+ // Check custom milestones
404
+ for (const milestone of this.config.milestones || []) {
405
+ if (progress >= milestone && !this.state.reachedMilestones.includes(milestone)) {
406
+ this.state.reachedMilestones.push(milestone)
407
+ this.trackEvent('video_milestone', { milestone })
408
+ }
409
+ }
410
+
411
+ // Check quartiles (25%, 50%, 75%, 100%)
412
+ const quartiles = [25, 50, 75, 100]
413
+ for (const quartile of quartiles) {
414
+ if (progress >= quartile && !this.state.reachedQuartiles.includes(quartile)) {
415
+ this.state.reachedQuartiles.push(quartile)
416
+ this.trackEvent('video_quartile', { quartile })
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Update watch time
423
+ */
424
+ private updateWatchTime(): void {
425
+ if (this.state.playbackStartTime) {
426
+ this.state.watchTime += (Date.now() - this.state.playbackStartTime) / 1000
427
+ this.state.playbackStartTime = Date.now()
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Start heartbeat tracking
433
+ */
434
+ private startHeartbeat(): void {
435
+ if (this.heartbeatTimer) return
436
+
437
+ const interval = (this.config.heartbeatInterval || DEFAULT_HEARTBEAT_INTERVAL) * 1000
438
+
439
+ this.heartbeatTimer = setInterval(() => {
440
+ const playerState = this.player?.state
441
+ if (playerState?.playbackState === 'playing') {
442
+ this.updateWatchTime()
443
+ this.trackEvent('video_heartbeat', {
444
+ watchTime: this.state.watchTime,
445
+ currentTime: playerState.currentTime,
446
+ })
447
+ }
448
+ }, interval)
449
+ }
450
+
451
+ /**
452
+ * Stop heartbeat tracking
453
+ */
454
+ private stopHeartbeat(): void {
455
+ if (this.heartbeatTimer) {
456
+ clearInterval(this.heartbeatTimer)
457
+ this.heartbeatTimer = null
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Track an event
463
+ */
464
+ trackEvent(name: VideoEventName, properties?: Record<string, unknown>): void {
465
+ if (this.config.disabled) return
466
+
467
+ const playerState = this.player?.state
468
+
469
+ const event: VideoEvent = {
470
+ name,
471
+ timestamp: new Date(),
472
+ currentTime: playerState?.currentTime || 0,
473
+ duration: playerState?.duration || this.config.duration || 0,
474
+ properties,
475
+ }
476
+
477
+ this.eventQueue.push(event)
478
+ this.scheduleFlush()
479
+ }
480
+
481
+ /**
482
+ * Schedule event flush
483
+ */
484
+ private scheduleFlush(): void {
485
+ if (this.flushTimer) return
486
+
487
+ this.flushTimer = setTimeout(() => {
488
+ this.flush()
489
+ this.flushTimer = null
490
+ }, 1000) // Batch events for 1 second
491
+ }
492
+
493
+ /**
494
+ * Flush events to analytics endpoint
495
+ */
496
+ async flush(): Promise<void> {
497
+ if (this.eventQueue.length === 0) return
498
+
499
+ const events = [...this.eventQueue]
500
+ this.eventQueue = []
501
+
502
+ try {
503
+ await this.sendEvents(events)
504
+ }
505
+ catch (error) {
506
+ // Re-queue on failure
507
+ console.error('Failed to send analytics events:', error)
508
+ this.eventQueue.unshift(...events)
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Send events to analytics endpoint
514
+ */
515
+ private async sendEvents(events: VideoEvent[]): Promise<void> {
516
+ const payload = events.map(event => this.buildEventPayload(event))
517
+
518
+ // Send to ts-analytics /collect endpoint
519
+ for (const p of payload) {
520
+ await fetch(this.config.apiEndpoint, {
521
+ method: 'POST',
522
+ headers: {
523
+ 'Content-Type': 'application/json',
524
+ },
525
+ body: JSON.stringify(p),
526
+ keepalive: true,
527
+ })
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Build event payload for ts-analytics
533
+ */
534
+ private buildEventPayload(event: VideoEvent): Record<string, unknown> {
535
+ return {
536
+ s: this.config.siteId, // siteId
537
+ sid: this.sessionId, // sessionId
538
+ vid: this.visitorId, // visitorId
539
+ e: 'event', // event type (custom event)
540
+ u: typeof window !== 'undefined' ? window.location.href : '', // URL
541
+ p: {
542
+ // Event properties
543
+ eventName: event.name,
544
+ videoId: this.config.videoId,
545
+ videoTitle: this.config.videoTitle,
546
+ contentType: this.config.contentType,
547
+ category: this.config.category,
548
+ creatorId: this.config.creatorId,
549
+ seriesId: this.config.seriesId,
550
+ episode: this.config.episode,
551
+ season: this.config.season,
552
+ currentTime: event.currentTime,
553
+ duration: event.duration,
554
+ progress: event.duration > 0 ? (event.currentTime / event.duration) * 100 : 0,
555
+ watchTime: this.state.watchTime,
556
+ ...this.config.customProperties,
557
+ ...event.properties,
558
+ },
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Generate unique ID
564
+ */
565
+ private generateId(): string {
566
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
567
+ }
568
+
569
+ /**
570
+ * Get or create visitor ID
571
+ */
572
+ private getOrCreateVisitorId(): string {
573
+ if (typeof localStorage === 'undefined') {
574
+ return this.generateId()
575
+ }
576
+
577
+ const key = 'tsvp_visitor_id'
578
+ let visitorId = localStorage.getItem(key)
579
+
580
+ if (!visitorId) {
581
+ visitorId = this.generateId()
582
+ localStorage.setItem(key, visitorId)
583
+ }
584
+
585
+ return visitorId
586
+ }
587
+
588
+ /**
589
+ * Get current analytics state
590
+ */
591
+ getState(): VideoAnalyticsState {
592
+ return { ...this.state }
593
+ }
594
+
595
+ /**
596
+ * Get session summary
597
+ */
598
+ getSummary(): Record<string, unknown> {
599
+ return {
600
+ videoId: this.config.videoId,
601
+ sessionId: this.sessionId,
602
+ hasStarted: this.state.hasStarted,
603
+ hasCompleted: this.state.hasCompleted,
604
+ watchTime: this.state.watchTime,
605
+ pauseCount: this.state.pauseCount,
606
+ seekCount: this.state.seekCount,
607
+ bufferCount: this.state.bufferCount,
608
+ bufferTime: this.state.bufferTime,
609
+ qualityChangeCount: this.state.qualityChangeCount,
610
+ reachedMilestones: this.state.reachedMilestones,
611
+ completionRate: this.state.reachedQuartiles.length > 0
612
+ ? Math.max(...this.state.reachedQuartiles)
613
+ : 0,
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Reset analytics state
619
+ */
620
+ reset(): void {
621
+ this.flush()
622
+ this.state = this.createInitialState()
623
+ this.sessionId = this.generateId()
624
+ }
625
+
626
+ /**
627
+ * Destroy analytics instance
628
+ */
629
+ destroy(): void {
630
+ this.detach()
631
+ if (this.flushTimer) {
632
+ clearTimeout(this.flushTimer)
633
+ }
634
+ }
635
+ }
636
+
637
+ // =============================================================================
638
+ // Factory
639
+ // =============================================================================
640
+
641
+ export function createVideoAnalytics(config: VideoAnalyticsConfig): VideoAnalytics {
642
+ return new VideoAnalytics(config)
643
+ }
644
+
645
+ // =============================================================================
646
+ // Player Plugin Integration
647
+ // =============================================================================
648
+
649
+ /**
650
+ * Analytics plugin for Player
651
+ */
652
+ export function analyticsPlugin(config: Omit<VideoAnalyticsConfig, 'videoId'>): { name: string, install: (player: Player, videoConfig: { videoId: string, videoTitle?: string }) => () => void } {
653
+ return {
654
+ name: 'analytics',
655
+
656
+ install(player: Player, videoConfig: { videoId: string; videoTitle?: string }) {
657
+ const analytics = createVideoAnalytics({
658
+ ...config,
659
+ ...videoConfig,
660
+ })
661
+
662
+ analytics.attach(player)
663
+
664
+ // Expose on player
665
+ ;(player as any).analytics = analytics
666
+
667
+ return () => {
668
+ analytics.destroy()
669
+ delete (player as any).analytics
670
+ }
671
+ },
672
+ }
673
+ }