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.
- package/LICENSE.md +21 -0
- package/README.md +438 -0
- package/dist/cdn.d.ts +10 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.min.js +1046 -0
- package/dist/cdn.min.js.map +54 -0
- package/dist/cdn.test.d.ts +2 -0
- package/dist/cdn.test.d.ts.map +1 -0
- package/dist/chunk-36x4yhjn.js +765 -0
- package/dist/chunk-36x4yhjn.js.map +12 -0
- package/dist/chunk-eajts2jv.js +4243 -0
- package/dist/chunk-eajts2jv.js.map +42 -0
- package/dist/chunk-z1ehbz9f.js +380 -0
- package/dist/chunk-z1ehbz9f.js.map +19 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/core/audio-gain.d.ts +67 -0
- package/dist/core/audio-gain.d.ts.map +1 -0
- package/dist/core/caption-styles.d.ts +121 -0
- package/dist/core/caption-styles.d.ts.map +1 -0
- package/dist/core/chapters.d.ts +150 -0
- package/dist/core/chapters.d.ts.map +1 -0
- package/dist/core/error-overlay.d.ts +71 -0
- package/dist/core/error-overlay.d.ts.map +1 -0
- package/dist/core/events.d.ts +119 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/features.d.ts +64 -0
- package/dist/core/features.d.ts.map +1 -0
- package/dist/core/i18n.d.ts +132 -0
- package/dist/core/i18n.d.ts.map +1 -0
- package/dist/core/index.d.ts +37 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/live.d.ts +130 -0
- package/dist/core/live.d.ts.map +1 -0
- package/dist/core/media-session.d.ts +38 -0
- package/dist/core/media-session.d.ts.map +1 -0
- package/dist/core/orientation.d.ts +22 -0
- package/dist/core/orientation.d.ts.map +1 -0
- package/dist/core/popover.d.ts +42 -0
- package/dist/core/popover.d.ts.map +1 -0
- package/dist/core/remote-playback.d.ts +195 -0
- package/dist/core/remote-playback.d.ts.map +1 -0
- package/dist/core/settings-menu.d.ts +132 -0
- package/dist/core/settings-menu.d.ts.map +1 -0
- package/dist/core/spinner.d.ts +67 -0
- package/dist/core/spinner.d.ts.map +1 -0
- package/dist/core/state.d.ts +142 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/text-tracks.d.ts +162 -0
- package/dist/core/text-tracks.d.ts.map +1 -0
- package/dist/core/themes.d.ts +213 -0
- package/dist/core/themes.d.ts.map +1 -0
- package/dist/core/thumbnails.d.ts +124 -0
- package/dist/core/thumbnails.d.ts.map +1 -0
- package/dist/core/tooltips.d.ts +100 -0
- package/dist/core/tooltips.d.ts.map +1 -0
- package/dist/elements/base.d.ts +3 -0
- package/dist/elements/base.d.ts.map +1 -0
- package/dist/elements/index.d.ts +33 -0
- package/dist/elements/index.d.ts.map +1 -0
- package/dist/elements/index.js +55 -0
- package/dist/elements/index.js.map +9 -0
- package/dist/elements/media-airplay-button.d.ts +13 -0
- package/dist/elements/media-airplay-button.d.ts.map +1 -0
- package/dist/elements/media-error-display.d.ts +13 -0
- package/dist/elements/media-error-display.d.ts.map +1 -0
- package/dist/elements/media-fullscreen-button.d.ts +13 -0
- package/dist/elements/media-fullscreen-button.d.ts.map +1 -0
- package/dist/elements/media-live-button.d.ts +13 -0
- package/dist/elements/media-live-button.d.ts.map +1 -0
- package/dist/elements/media-mute-button.d.ts +13 -0
- package/dist/elements/media-mute-button.d.ts.map +1 -0
- package/dist/elements/media-pip-button.d.ts +13 -0
- package/dist/elements/media-pip-button.d.ts.map +1 -0
- package/dist/elements/media-play-button.d.ts +14 -0
- package/dist/elements/media-play-button.d.ts.map +1 -0
- package/dist/elements/media-playback-rate-button.d.ts +10 -0
- package/dist/elements/media-playback-rate-button.d.ts.map +1 -0
- package/dist/elements/media-preview-time.d.ts +18 -0
- package/dist/elements/media-preview-time.d.ts.map +1 -0
- package/dist/elements/media-progress-bar.d.ts +16 -0
- package/dist/elements/media-progress-bar.d.ts.map +1 -0
- package/dist/elements/media-remote-playback-button.d.ts +16 -0
- package/dist/elements/media-remote-playback-button.d.ts.map +1 -0
- package/dist/elements/media-seek-button.d.ts +9 -0
- package/dist/elements/media-seek-button.d.ts.map +1 -0
- package/dist/elements/media-settings-menu.d.ts +32 -0
- package/dist/elements/media-settings-menu.d.ts.map +1 -0
- package/dist/elements/media-time-display.d.ts +21 -0
- package/dist/elements/media-time-display.d.ts.map +1 -0
- package/dist/elements/media-time-group.d.ts +22 -0
- package/dist/elements/media-time-group.d.ts.map +1 -0
- package/dist/elements/media-time-separator.d.ts +13 -0
- package/dist/elements/media-time-separator.d.ts.map +1 -0
- package/dist/elements/media-volume-slider.d.ts +16 -0
- package/dist/elements/media-volume-slider.d.ts.map +1 -0
- package/dist/elements/utils.d.ts +27 -0
- package/dist/elements/utils.d.ts.map +1 -0
- package/dist/elements/video-player.d.ts +23 -0
- package/dist/elements/video-player.d.ts.map +1 -0
- package/dist/elements/video-skin.d.ts +23 -0
- package/dist/elements/video-skin.d.ts.map +1 -0
- package/dist/features/captions.d.ts +8 -0
- package/dist/features/captions.d.ts.map +1 -0
- package/dist/features/create-player.d.ts +45 -0
- package/dist/features/create-player.d.ts.map +1 -0
- package/dist/features/fullscreen.d.ts +8 -0
- package/dist/features/fullscreen.d.ts.map +1 -0
- package/dist/features/gestures.d.ts +8 -0
- package/dist/features/gestures.d.ts.map +1 -0
- package/dist/features/index.d.ts +26 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +31 -0
- package/dist/features/index.js.map +9 -0
- package/dist/features/keyboard.d.ts +8 -0
- package/dist/features/keyboard.d.ts.map +1 -0
- package/dist/features/pip.d.ts +8 -0
- package/dist/features/pip.d.ts.map +1 -0
- package/dist/features/playback.d.ts +8 -0
- package/dist/features/playback.d.ts.map +1 -0
- package/dist/features/quality.d.ts +8 -0
- package/dist/features/quality.d.ts.map +1 -0
- package/dist/features/types.d.ts +26 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/volume.d.ts +8 -0
- package/dist/features/volume.d.ts.map +1 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8149 -0
- package/dist/index.js.map +31 -0
- package/dist/layouts/index.d.ts +58 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/player.d.ts +90 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/plugins/ads.d.ts +311 -0
- package/dist/plugins/ads.d.ts.map +1 -0
- package/dist/plugins/analytics.d.ts +203 -0
- package/dist/plugins/analytics.d.ts.map +1 -0
- package/dist/plugins/end-screen.d.ts +184 -0
- package/dist/plugins/end-screen.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +13 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +20 -0
- package/dist/plugins/index.js.map +9 -0
- package/dist/plugins/skip-segments.d.ts +159 -0
- package/dist/plugins/skip-segments.d.ts.map +1 -0
- package/dist/plugins/watermark.d.ts +157 -0
- package/dist/plugins/watermark.d.ts.map +1 -0
- package/dist/providers/base.d.ts +56 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/dash.d.ts +94 -0
- package/dist/providers/dash.d.ts.map +1 -0
- package/dist/providers/hls.d.ts +182 -0
- package/dist/providers/hls.d.ts.map +1 -0
- package/dist/providers/html5.d.ts +64 -0
- package/dist/providers/html5.d.ts.map +1 -0
- package/dist/providers/index.d.ts +41 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/vimeo.d.ts +157 -0
- package/dist/providers/vimeo.d.ts.map +1 -0
- package/dist/providers/youtube.d.ts +134 -0
- package/dist/providers/youtube.d.ts.map +1 -0
- package/dist/stx/index.d.ts +59 -0
- package/dist/stx/index.d.ts.map +1 -0
- package/dist/stx/index.js +480 -0
- package/dist/stx/index.js.map +11 -0
- package/dist/types.d.ts +724 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/ui/components.d.ts +121 -0
- package/dist/ui/components.d.ts.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +16 -0
- package/dist/ui/index.js.map +9 -0
- package/package.json +109 -0
- package/src/cdn.test.ts +10 -0
- package/src/cdn.ts +60 -0
- package/src/config.ts +25 -0
- package/src/core/audio-gain.ts +171 -0
- package/src/core/caption-styles.ts +568 -0
- package/src/core/chapters.ts +551 -0
- package/src/core/error-overlay.ts +354 -0
- package/src/core/events.ts +552 -0
- package/src/core/features.ts +287 -0
- package/src/core/i18n.ts +590 -0
- package/src/core/index.ts +249 -0
- package/src/core/live.ts +472 -0
- package/src/core/media-session.ts +137 -0
- package/src/core/orientation.ts +57 -0
- package/src/core/popover.ts +143 -0
- package/src/core/remote-playback.ts +661 -0
- package/src/core/settings-menu.ts +630 -0
- package/src/core/spinner.ts +310 -0
- package/src/core/state.ts +476 -0
- package/src/core/text-tracks.ts +599 -0
- package/src/core/themes.ts +607 -0
- package/src/core/thumbnails.ts +457 -0
- package/src/core/tooltips.ts +370 -0
- package/src/elements/base.ts +2 -0
- package/src/elements/index.ts +82 -0
- package/src/elements/media-airplay-button.ts +78 -0
- package/src/elements/media-error-display.ts +47 -0
- package/src/elements/media-fullscreen-button.ts +72 -0
- package/src/elements/media-live-button.ts +65 -0
- package/src/elements/media-mute-button.ts +69 -0
- package/src/elements/media-pip-button.ts +66 -0
- package/src/elements/media-play-button.ts +69 -0
- package/src/elements/media-playback-rate-button.ts +59 -0
- package/src/elements/media-preview-time.ts +75 -0
- package/src/elements/media-progress-bar.ts +200 -0
- package/src/elements/media-remote-playback-button.ts +108 -0
- package/src/elements/media-seek-button.ts +50 -0
- package/src/elements/media-settings-menu.ts +302 -0
- package/src/elements/media-time-display.ts +99 -0
- package/src/elements/media-time-group.ts +41 -0
- package/src/elements/media-time-separator.ts +35 -0
- package/src/elements/media-volume-slider.ts +140 -0
- package/src/elements/utils.ts +82 -0
- package/src/elements/video-player.ts +175 -0
- package/src/elements/video-skin.ts +127 -0
- package/src/features/captions.ts +56 -0
- package/src/features/create-player.ts +91 -0
- package/src/features/fullscreen.ts +44 -0
- package/src/features/gestures.ts +44 -0
- package/src/features/index.ts +44 -0
- package/src/features/keyboard.ts +89 -0
- package/src/features/pip.ts +47 -0
- package/src/features/playback.ts +68 -0
- package/src/features/quality.ts +19 -0
- package/src/features/types.ts +28 -0
- package/src/features/volume.ts +46 -0
- package/src/index.ts +635 -0
- package/src/layouts/index.ts +672 -0
- package/src/player.ts +821 -0
- package/src/plugins/ads.ts +1045 -0
- package/src/plugins/analytics.ts +673 -0
- package/src/plugins/end-screen.ts +793 -0
- package/src/plugins/index.ts +61 -0
- package/src/plugins/skip-segments.ts +537 -0
- package/src/plugins/watermark.ts +537 -0
- package/src/providers/base.ts +197 -0
- package/src/providers/dash.ts +588 -0
- package/src/providers/hls.ts +624 -0
- package/src/providers/html5.ts +473 -0
- package/src/providers/index.ts +97 -0
- package/src/providers/vimeo.ts +533 -0
- package/src/providers/youtube.ts +542 -0
- package/src/stx/index.ts +606 -0
- package/src/types.ts +874 -0
- package/src/ui/components.ts +910 -0
- package/src/ui/index.ts +29 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Ads Plugin
|
|
3
|
+
*
|
|
4
|
+
* VAST/VPAID ad support for video playback.
|
|
5
|
+
*
|
|
6
|
+
* @module plugins/ads
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Player } from '../player'
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export type AdType = 'linear' | 'nonlinear' | 'companion'
|
|
16
|
+
export type AdBreakType = 'preroll' | 'midroll' | 'postroll'
|
|
17
|
+
|
|
18
|
+
export interface AdConfig {
|
|
19
|
+
/** VAST tag URL or inline VAST XML */
|
|
20
|
+
vastUrl?: string
|
|
21
|
+
/** VAST XML content */
|
|
22
|
+
vastXml?: string
|
|
23
|
+
/** VMAP URL for ad schedule */
|
|
24
|
+
vmapUrl?: string
|
|
25
|
+
/** Ad schedule */
|
|
26
|
+
schedule?: AdBreak[]
|
|
27
|
+
/** Skip delay in seconds (0 = no skip, -1 = use VAST value) */
|
|
28
|
+
skipDelay?: number
|
|
29
|
+
/** Maximum number of redirects to follow */
|
|
30
|
+
maxRedirects?: number
|
|
31
|
+
/** Timeout for ad requests in ms */
|
|
32
|
+
timeout?: number
|
|
33
|
+
/** Enable VPAID */
|
|
34
|
+
vpaidEnabled?: boolean
|
|
35
|
+
/** Companion ad container selectors */
|
|
36
|
+
companionContainers?: Record<string, string>
|
|
37
|
+
/** Preload ads */
|
|
38
|
+
preloadAds?: boolean
|
|
39
|
+
/** Debug mode */
|
|
40
|
+
debug?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AdBreak {
|
|
44
|
+
/** Break type */
|
|
45
|
+
type: AdBreakType
|
|
46
|
+
/** Time offset in seconds (for midrolls) */
|
|
47
|
+
timeOffset?: number
|
|
48
|
+
/** VAST URL */
|
|
49
|
+
vastUrl?: string
|
|
50
|
+
/** VAST XML */
|
|
51
|
+
vastXml?: string
|
|
52
|
+
/** Has played */
|
|
53
|
+
played?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Ad {
|
|
57
|
+
/** Ad ID */
|
|
58
|
+
id: string
|
|
59
|
+
/** Ad type */
|
|
60
|
+
type: AdType
|
|
61
|
+
/** Ad system */
|
|
62
|
+
adSystem?: string
|
|
63
|
+
/** Ad title */
|
|
64
|
+
title?: string
|
|
65
|
+
/** Ad description */
|
|
66
|
+
description?: string
|
|
67
|
+
/** Ad duration in seconds */
|
|
68
|
+
duration: number
|
|
69
|
+
/** Skip offset in seconds */
|
|
70
|
+
skipOffset?: number
|
|
71
|
+
/** Click through URL */
|
|
72
|
+
clickThroughUrl?: string
|
|
73
|
+
/** Media files */
|
|
74
|
+
mediaFiles: AdMediaFile[]
|
|
75
|
+
/** Companion ads */
|
|
76
|
+
companions?: CompanionAd[]
|
|
77
|
+
/** Tracking events */
|
|
78
|
+
trackingEvents: AdTrackingEvent[]
|
|
79
|
+
/** VPAID script */
|
|
80
|
+
vpaidScript?: string
|
|
81
|
+
/** Icons */
|
|
82
|
+
icons?: AdIcon[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AdMediaFile {
|
|
86
|
+
/** Media URL */
|
|
87
|
+
url: string
|
|
88
|
+
/** MIME type */
|
|
89
|
+
type: string
|
|
90
|
+
/** Width */
|
|
91
|
+
width: number
|
|
92
|
+
/** Height */
|
|
93
|
+
height: number
|
|
94
|
+
/** Bitrate */
|
|
95
|
+
bitrate?: number
|
|
96
|
+
/** Codec */
|
|
97
|
+
codec?: string
|
|
98
|
+
/** Delivery type */
|
|
99
|
+
delivery: 'progressive' | 'streaming'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CompanionAd {
|
|
103
|
+
/** Width */
|
|
104
|
+
width: number
|
|
105
|
+
/** Height */
|
|
106
|
+
height: number
|
|
107
|
+
/** Resource URL (image, iframe, HTML) */
|
|
108
|
+
resourceUrl: string
|
|
109
|
+
/** Resource type */
|
|
110
|
+
resourceType: 'static' | 'iframe' | 'html'
|
|
111
|
+
/** Click through URL */
|
|
112
|
+
clickThroughUrl?: string
|
|
113
|
+
/** Alt text */
|
|
114
|
+
altText?: string
|
|
115
|
+
/** Tracking pixel */
|
|
116
|
+
trackingUrl?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface AdTrackingEvent {
|
|
120
|
+
/** Event name */
|
|
121
|
+
event: string
|
|
122
|
+
/** Tracking URL */
|
|
123
|
+
url: string
|
|
124
|
+
/** Offset (for progress events) */
|
|
125
|
+
offset?: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface AdIcon {
|
|
129
|
+
/** Icon URL */
|
|
130
|
+
url: string
|
|
131
|
+
/** Width */
|
|
132
|
+
width: number
|
|
133
|
+
/** Height */
|
|
134
|
+
height: number
|
|
135
|
+
/** Position */
|
|
136
|
+
xPosition: string
|
|
137
|
+
/** Position */
|
|
138
|
+
yPosition: string
|
|
139
|
+
/** Click URL */
|
|
140
|
+
clickUrl?: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AdState {
|
|
144
|
+
/** Is ad playing */
|
|
145
|
+
isPlaying: boolean
|
|
146
|
+
/** Current ad */
|
|
147
|
+
currentAd: Ad | null
|
|
148
|
+
/** Current ad break */
|
|
149
|
+
currentBreak: AdBreak | null
|
|
150
|
+
/** Time remaining */
|
|
151
|
+
timeRemaining: number
|
|
152
|
+
/** Can skip */
|
|
153
|
+
canSkip: boolean
|
|
154
|
+
/** Skip countdown */
|
|
155
|
+
skipCountdown: number
|
|
156
|
+
/** Is loading */
|
|
157
|
+
isLoading: boolean
|
|
158
|
+
/** Error */
|
|
159
|
+
error: string | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// VAST Parser
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
export class VASTParser {
|
|
167
|
+
private maxRedirects: number
|
|
168
|
+
private timeout: number
|
|
169
|
+
private redirectCount = 0
|
|
170
|
+
|
|
171
|
+
constructor(options: { maxRedirects?: number; timeout?: number } = {}) {
|
|
172
|
+
this.maxRedirects = options.maxRedirects ?? 5
|
|
173
|
+
this.timeout = options.timeout ?? 10000
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse VAST from URL
|
|
178
|
+
*/
|
|
179
|
+
async parseFromUrl(url: string): Promise<Ad[]> {
|
|
180
|
+
this.redirectCount = 0
|
|
181
|
+
return this.fetchAndParse(url)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse VAST from XML string
|
|
186
|
+
*/
|
|
187
|
+
parseFromXml(xml: string): Ad[] {
|
|
188
|
+
const parser = new DOMParser()
|
|
189
|
+
const doc = parser.parseFromString(xml, 'text/xml')
|
|
190
|
+
return this.parseDocument(doc)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fetch and parse VAST
|
|
195
|
+
*/
|
|
196
|
+
private async fetchAndParse(url: string): Promise<Ad[]> {
|
|
197
|
+
if (this.redirectCount >= this.maxRedirects) {
|
|
198
|
+
throw new Error('Too many VAST redirects')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const controller = new AbortController()
|
|
202
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(url, { signal: controller.signal })
|
|
206
|
+
const text = await response.text()
|
|
207
|
+
const parser = new DOMParser()
|
|
208
|
+
const doc = parser.parseFromString(text, 'text/xml')
|
|
209
|
+
|
|
210
|
+
// Check for wrapper (redirect)
|
|
211
|
+
const wrapper = doc.querySelector('Wrapper')
|
|
212
|
+
if (wrapper) {
|
|
213
|
+
const vastAdTagUri = wrapper.querySelector('VASTAdTagURI')?.textContent?.trim()
|
|
214
|
+
if (vastAdTagUri) {
|
|
215
|
+
this.redirectCount++
|
|
216
|
+
return this.fetchAndParse(vastAdTagUri)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.parseDocument(doc)
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
clearTimeout(timeoutId)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse VAST document
|
|
229
|
+
*/
|
|
230
|
+
private parseDocument(doc: Document): Ad[] {
|
|
231
|
+
const ads: Ad[] = []
|
|
232
|
+
const adElements = doc.querySelectorAll('Ad')
|
|
233
|
+
|
|
234
|
+
adElements.forEach((adEl) => {
|
|
235
|
+
const inLine = adEl.querySelector('InLine')
|
|
236
|
+
if (!inLine) return
|
|
237
|
+
|
|
238
|
+
const ad: Ad = {
|
|
239
|
+
id: adEl.getAttribute('id') || this.generateId(),
|
|
240
|
+
type: 'linear',
|
|
241
|
+
adSystem: inLine.querySelector('AdSystem')?.textContent?.trim(),
|
|
242
|
+
title: inLine.querySelector('AdTitle')?.textContent?.trim(),
|
|
243
|
+
description: inLine.querySelector('Description')?.textContent?.trim(),
|
|
244
|
+
duration: 0,
|
|
245
|
+
mediaFiles: [],
|
|
246
|
+
trackingEvents: [],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse Linear
|
|
250
|
+
const linear = inLine.querySelector('Linear')
|
|
251
|
+
if (linear) {
|
|
252
|
+
// Duration
|
|
253
|
+
const durationStr = linear.querySelector('Duration')?.textContent?.trim()
|
|
254
|
+
if (durationStr) {
|
|
255
|
+
ad.duration = this.parseDuration(durationStr)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Skip offset
|
|
259
|
+
const skipOffset = linear.getAttribute('skipoffset')
|
|
260
|
+
if (skipOffset) {
|
|
261
|
+
ad.skipOffset = this.parseOffset(skipOffset, ad.duration)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Media files
|
|
265
|
+
linear.querySelectorAll('MediaFile').forEach((mf) => {
|
|
266
|
+
const url = mf.textContent?.trim()
|
|
267
|
+
if (url) {
|
|
268
|
+
ad.mediaFiles.push({
|
|
269
|
+
url,
|
|
270
|
+
type: mf.getAttribute('type') || 'video/mp4',
|
|
271
|
+
width: Number.parseInt(mf.getAttribute('width') || '0', 10),
|
|
272
|
+
height: Number.parseInt(mf.getAttribute('height') || '0', 10),
|
|
273
|
+
bitrate: mf.getAttribute('bitrate')
|
|
274
|
+
? Number.parseInt(mf.getAttribute('bitrate')!, 10)
|
|
275
|
+
: undefined,
|
|
276
|
+
delivery: (mf.getAttribute('delivery') as any) || 'progressive',
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Click through
|
|
282
|
+
const clickThrough = linear.querySelector('VideoClicks > ClickThrough')
|
|
283
|
+
if (clickThrough) {
|
|
284
|
+
ad.clickThroughUrl = clickThrough.textContent?.trim()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Tracking events
|
|
288
|
+
linear.querySelectorAll('TrackingEvents > Tracking').forEach((tracking) => {
|
|
289
|
+
const event = tracking.getAttribute('event')
|
|
290
|
+
const url = tracking.textContent?.trim()
|
|
291
|
+
if (event && url) {
|
|
292
|
+
const trackingEvent: AdTrackingEvent = { event, url }
|
|
293
|
+
|
|
294
|
+
// Handle progress offset
|
|
295
|
+
if (event === 'progress') {
|
|
296
|
+
const offset = tracking.getAttribute('offset')
|
|
297
|
+
if (offset) {
|
|
298
|
+
trackingEvent.offset = this.parseOffset(offset, ad.duration)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
ad.trackingEvents.push(trackingEvent)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Icons
|
|
307
|
+
linear.querySelectorAll('Icons > Icon').forEach((icon) => {
|
|
308
|
+
const staticResource = icon.querySelector('StaticResource')
|
|
309
|
+
if (staticResource?.textContent) {
|
|
310
|
+
ad.icons = ad.icons || []
|
|
311
|
+
ad.icons.push({
|
|
312
|
+
url: staticResource.textContent.trim(),
|
|
313
|
+
width: Number.parseInt(icon.getAttribute('width') || '0', 10),
|
|
314
|
+
height: Number.parseInt(icon.getAttribute('height') || '0', 10),
|
|
315
|
+
xPosition: icon.getAttribute('xPosition') || 'right',
|
|
316
|
+
yPosition: icon.getAttribute('yPosition') || 'top',
|
|
317
|
+
clickUrl: icon.querySelector('IconClicks > IconClickThrough')?.textContent?.trim(),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Parse Companion ads
|
|
324
|
+
inLine.querySelectorAll('CompanionAds > Companion').forEach((comp) => {
|
|
325
|
+
ad.companions = ad.companions || []
|
|
326
|
+
|
|
327
|
+
const staticResource = comp.querySelector('StaticResource')
|
|
328
|
+
const iframeResource = comp.querySelector('IFrameResource')
|
|
329
|
+
const htmlResource = comp.querySelector('HTMLResource')
|
|
330
|
+
|
|
331
|
+
let resourceUrl = ''
|
|
332
|
+
let resourceType: CompanionAd['resourceType'] = 'static'
|
|
333
|
+
|
|
334
|
+
if (staticResource?.textContent) {
|
|
335
|
+
resourceUrl = staticResource.textContent.trim()
|
|
336
|
+
resourceType = 'static'
|
|
337
|
+
}
|
|
338
|
+
else if (iframeResource?.textContent) {
|
|
339
|
+
resourceUrl = iframeResource.textContent.trim()
|
|
340
|
+
resourceType = 'iframe'
|
|
341
|
+
}
|
|
342
|
+
else if (htmlResource?.textContent) {
|
|
343
|
+
resourceUrl = htmlResource.textContent.trim()
|
|
344
|
+
resourceType = 'html'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (resourceUrl) {
|
|
348
|
+
ad.companions.push({
|
|
349
|
+
width: Number.parseInt(comp.getAttribute('width') || '0', 10),
|
|
350
|
+
height: Number.parseInt(comp.getAttribute('height') || '0', 10),
|
|
351
|
+
resourceUrl,
|
|
352
|
+
resourceType,
|
|
353
|
+
clickThroughUrl: comp.querySelector('CompanionClickThrough')?.textContent?.trim(),
|
|
354
|
+
altText: comp.getAttribute('altText') || undefined,
|
|
355
|
+
trackingUrl: comp.querySelector('TrackingEvents > Tracking')?.textContent?.trim(),
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
if (ad.mediaFiles.length > 0) {
|
|
361
|
+
ads.push(ad)
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
return ads
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Parse duration string (HH:MM:SS.mmm)
|
|
370
|
+
*/
|
|
371
|
+
private parseDuration(duration: string): number {
|
|
372
|
+
const parts = duration.split(':')
|
|
373
|
+
if (parts.length !== 3) return 0
|
|
374
|
+
|
|
375
|
+
const hours = Number.parseFloat(parts[0])
|
|
376
|
+
const minutes = Number.parseFloat(parts[1])
|
|
377
|
+
const seconds = Number.parseFloat(parts[2])
|
|
378
|
+
|
|
379
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Parse offset (time or percentage)
|
|
384
|
+
*/
|
|
385
|
+
private parseOffset(offset: string, duration: number): number {
|
|
386
|
+
if (offset.endsWith('%')) {
|
|
387
|
+
const percent = Number.parseFloat(offset) / 100
|
|
388
|
+
return duration * percent
|
|
389
|
+
}
|
|
390
|
+
return this.parseDuration(offset)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Generate unique ID
|
|
395
|
+
*/
|
|
396
|
+
private generateId(): string {
|
|
397
|
+
return `ad-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// =============================================================================
|
|
402
|
+
// Ads Manager
|
|
403
|
+
// =============================================================================
|
|
404
|
+
|
|
405
|
+
export class AdsManager {
|
|
406
|
+
private player: Player | null = null
|
|
407
|
+
private config: AdConfig
|
|
408
|
+
private parser: VASTParser
|
|
409
|
+
private state: AdState
|
|
410
|
+
private container: HTMLElement | null = null
|
|
411
|
+
private adVideo: HTMLVideoElement | null = null
|
|
412
|
+
private adContainer: HTMLElement | null = null
|
|
413
|
+
private skipButton: HTMLButtonElement | null = null
|
|
414
|
+
private adBreaks: AdBreak[] = []
|
|
415
|
+
private currentAds: Ad[] = []
|
|
416
|
+
private currentAdIndex = 0
|
|
417
|
+
private skipTimer: ReturnType<typeof setInterval> | null = null
|
|
418
|
+
private contentPaused = false
|
|
419
|
+
|
|
420
|
+
// Event handlers
|
|
421
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
422
|
+
private onAdStart: ((_ad: Ad) => void) | null = null
|
|
423
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
424
|
+
private onAdComplete: ((_ad: Ad) => void) | null = null
|
|
425
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
426
|
+
private onAdSkip: ((_ad: Ad) => void) | null = null
|
|
427
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
428
|
+
private onAdClick: ((_ad: Ad) => void) | null = null
|
|
429
|
+
// eslint-disable-next-line pickier/no-unused-vars
|
|
430
|
+
private onAdErrorCallback: ((_error: string) => void) | null = null
|
|
431
|
+
private onAllAdsComplete: (() => void) | null = null
|
|
432
|
+
|
|
433
|
+
constructor(config: AdConfig = {}) {
|
|
434
|
+
this.config = {
|
|
435
|
+
skipDelay: 5,
|
|
436
|
+
maxRedirects: 5,
|
|
437
|
+
timeout: 10000,
|
|
438
|
+
vpaidEnabled: false,
|
|
439
|
+
preloadAds: true,
|
|
440
|
+
debug: false,
|
|
441
|
+
...config,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.parser = new VASTParser({
|
|
445
|
+
maxRedirects: this.config.maxRedirects,
|
|
446
|
+
timeout: this.config.timeout,
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
this.state = {
|
|
450
|
+
isPlaying: false,
|
|
451
|
+
currentAd: null,
|
|
452
|
+
currentBreak: null,
|
|
453
|
+
timeRemaining: 0,
|
|
454
|
+
canSkip: false,
|
|
455
|
+
skipCountdown: 0,
|
|
456
|
+
isLoading: false,
|
|
457
|
+
error: null,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Parse schedule
|
|
461
|
+
if (this.config.schedule) {
|
|
462
|
+
this.adBreaks = this.config.schedule
|
|
463
|
+
}
|
|
464
|
+
else if (this.config.vastUrl || this.config.vastXml) {
|
|
465
|
+
// Single preroll
|
|
466
|
+
this.adBreaks = [{
|
|
467
|
+
type: 'preroll',
|
|
468
|
+
vastUrl: this.config.vastUrl,
|
|
469
|
+
vastXml: this.config.vastXml,
|
|
470
|
+
}]
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Attach to player
|
|
476
|
+
*/
|
|
477
|
+
attach(player: Player, container: HTMLElement): void {
|
|
478
|
+
this.player = player
|
|
479
|
+
this.container = container
|
|
480
|
+
|
|
481
|
+
this.createAdContainer()
|
|
482
|
+
this.attachPlayerEvents()
|
|
483
|
+
|
|
484
|
+
// Preload ads
|
|
485
|
+
if (this.config.preloadAds) {
|
|
486
|
+
this.preloadAds()
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Create ad container
|
|
492
|
+
*/
|
|
493
|
+
private createAdContainer(): void {
|
|
494
|
+
if (!this.container) return
|
|
495
|
+
|
|
496
|
+
this.adContainer = document.createElement('div')
|
|
497
|
+
this.adContainer.className = 'tsvp-ad-container'
|
|
498
|
+
this.adContainer.style.cssText = `
|
|
499
|
+
position: absolute;
|
|
500
|
+
inset: 0;
|
|
501
|
+
z-index: 50;
|
|
502
|
+
display: none;
|
|
503
|
+
background: #000;
|
|
504
|
+
`
|
|
505
|
+
|
|
506
|
+
// Ad video
|
|
507
|
+
this.adVideo = document.createElement('video')
|
|
508
|
+
this.adVideo.style.cssText = `
|
|
509
|
+
width: 100%;
|
|
510
|
+
height: 100%;
|
|
511
|
+
object-fit: contain;
|
|
512
|
+
`
|
|
513
|
+
this.adVideo.playsInline = true
|
|
514
|
+
|
|
515
|
+
// Ad overlay
|
|
516
|
+
const overlay = document.createElement('div')
|
|
517
|
+
overlay.className = 'tsvp-ad-overlay'
|
|
518
|
+
overlay.style.cssText = `
|
|
519
|
+
position: absolute;
|
|
520
|
+
inset: 0;
|
|
521
|
+
display: flex;
|
|
522
|
+
flex-direction: column;
|
|
523
|
+
pointer-events: none;
|
|
524
|
+
`
|
|
525
|
+
|
|
526
|
+
// Ad info
|
|
527
|
+
const adInfo = document.createElement('div')
|
|
528
|
+
adInfo.className = 'tsvp-ad-info'
|
|
529
|
+
adInfo.style.cssText = `
|
|
530
|
+
position: absolute;
|
|
531
|
+
top: 12px;
|
|
532
|
+
left: 12px;
|
|
533
|
+
padding: 6px 12px;
|
|
534
|
+
background: rgba(0, 0, 0, 0.7);
|
|
535
|
+
color: #fff;
|
|
536
|
+
font-size: 12px;
|
|
537
|
+
font-family: var(--tsvp-font-family, sans-serif);
|
|
538
|
+
border-radius: 4px;
|
|
539
|
+
`
|
|
540
|
+
adInfo.textContent = 'Ad'
|
|
541
|
+
|
|
542
|
+
// Skip button
|
|
543
|
+
this.skipButton = document.createElement('button')
|
|
544
|
+
this.skipButton.className = 'tsvp-ad-skip'
|
|
545
|
+
this.skipButton.style.cssText = `
|
|
546
|
+
position: absolute;
|
|
547
|
+
bottom: 80px;
|
|
548
|
+
right: 12px;
|
|
549
|
+
padding: 10px 16px;
|
|
550
|
+
background: rgba(0, 0, 0, 0.8);
|
|
551
|
+
color: #fff;
|
|
552
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
553
|
+
border-radius: 4px;
|
|
554
|
+
font-size: 14px;
|
|
555
|
+
font-family: var(--tsvp-font-family, sans-serif);
|
|
556
|
+
cursor: pointer;
|
|
557
|
+
pointer-events: auto;
|
|
558
|
+
display: none;
|
|
559
|
+
`
|
|
560
|
+
this.skipButton.addEventListener('click', () => this.skipAd())
|
|
561
|
+
|
|
562
|
+
// Learn more button
|
|
563
|
+
const learnMore = document.createElement('button')
|
|
564
|
+
learnMore.className = 'tsvp-ad-learn-more'
|
|
565
|
+
learnMore.style.cssText = `
|
|
566
|
+
position: absolute;
|
|
567
|
+
bottom: 80px;
|
|
568
|
+
left: 12px;
|
|
569
|
+
padding: 10px 16px;
|
|
570
|
+
background: var(--tsvp-color-primary, #00a8ff);
|
|
571
|
+
color: #fff;
|
|
572
|
+
border: none;
|
|
573
|
+
border-radius: 4px;
|
|
574
|
+
font-size: 14px;
|
|
575
|
+
font-family: var(--tsvp-font-family, sans-serif);
|
|
576
|
+
cursor: pointer;
|
|
577
|
+
pointer-events: auto;
|
|
578
|
+
display: none;
|
|
579
|
+
`
|
|
580
|
+
learnMore.textContent = 'Learn More'
|
|
581
|
+
learnMore.addEventListener('click', () => this.handleAdClick())
|
|
582
|
+
|
|
583
|
+
overlay.appendChild(adInfo)
|
|
584
|
+
overlay.appendChild(this.skipButton)
|
|
585
|
+
overlay.appendChild(learnMore)
|
|
586
|
+
|
|
587
|
+
this.adContainer.appendChild(this.adVideo)
|
|
588
|
+
this.adContainer.appendChild(overlay)
|
|
589
|
+
this.container.appendChild(this.adContainer)
|
|
590
|
+
|
|
591
|
+
// Ad video events
|
|
592
|
+
this.adVideo.addEventListener('timeupdate', () => this.onAdTimeUpdate())
|
|
593
|
+
this.adVideo.addEventListener('ended', () => this.onAdEnded())
|
|
594
|
+
this.adVideo.addEventListener('error', () => this.handleAdError())
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Attach player events
|
|
599
|
+
*/
|
|
600
|
+
private attachPlayerEvents(): void {
|
|
601
|
+
if (!this.player) return
|
|
602
|
+
|
|
603
|
+
// Check for preroll on first play
|
|
604
|
+
let firstPlay = true
|
|
605
|
+
this.player.on('play', async () => {
|
|
606
|
+
if (firstPlay) {
|
|
607
|
+
firstPlay = false
|
|
608
|
+
const preroll = this.adBreaks.find(b => b.type === 'preroll' && !b.played)
|
|
609
|
+
if (preroll) {
|
|
610
|
+
await this.playAdBreak(preroll)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
// Check for midrolls
|
|
616
|
+
this.player.on('timeupdate', () => {
|
|
617
|
+
const state = this.player?.state
|
|
618
|
+
if (!state) return
|
|
619
|
+
|
|
620
|
+
for (const adBreak of this.adBreaks) {
|
|
621
|
+
if (
|
|
622
|
+
adBreak.type === 'midroll'
|
|
623
|
+
&& !adBreak.played
|
|
624
|
+
&& adBreak.timeOffset
|
|
625
|
+
&& state.currentTime >= adBreak.timeOffset
|
|
626
|
+
) {
|
|
627
|
+
this.playAdBreak(adBreak)
|
|
628
|
+
break
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// Check for postroll
|
|
634
|
+
this.player.on('ended', async () => {
|
|
635
|
+
const postroll = this.adBreaks.find(b => b.type === 'postroll' && !b.played)
|
|
636
|
+
if (postroll) {
|
|
637
|
+
await this.playAdBreak(postroll)
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Preload ads
|
|
644
|
+
*/
|
|
645
|
+
private async preloadAds(): Promise<void> {
|
|
646
|
+
for (const adBreak of this.adBreaks) {
|
|
647
|
+
if (adBreak.vastUrl) {
|
|
648
|
+
try {
|
|
649
|
+
// Pre-parse VAST
|
|
650
|
+
await this.parser.parseFromUrl(adBreak.vastUrl)
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
this.log('Failed to preload ad:', error)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Play ad break
|
|
661
|
+
*/
|
|
662
|
+
async playAdBreak(adBreak: AdBreak): Promise<void> {
|
|
663
|
+
if (this.state.isPlaying) return
|
|
664
|
+
|
|
665
|
+
this.state.currentBreak = adBreak
|
|
666
|
+
this.state.isLoading = true
|
|
667
|
+
|
|
668
|
+
// Pause content
|
|
669
|
+
this.pauseContent()
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
// Parse VAST
|
|
673
|
+
if (adBreak.vastUrl) {
|
|
674
|
+
this.currentAds = await this.parser.parseFromUrl(adBreak.vastUrl)
|
|
675
|
+
}
|
|
676
|
+
else if (adBreak.vastXml) {
|
|
677
|
+
this.currentAds = this.parser.parseFromXml(adBreak.vastXml)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (this.currentAds.length === 0) {
|
|
681
|
+
throw new Error('No ads found in VAST response')
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
this.currentAdIndex = 0
|
|
685
|
+
adBreak.played = true
|
|
686
|
+
|
|
687
|
+
await this.playAd(this.currentAds[0])
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
this.state.error = (error as Error).message
|
|
691
|
+
this.state.isLoading = false
|
|
692
|
+
this.onAdErrorCallback?.((error as Error).message)
|
|
693
|
+
this.resumeContent()
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Play single ad
|
|
699
|
+
*/
|
|
700
|
+
private async playAd(ad: Ad): Promise<void> {
|
|
701
|
+
if (!this.adVideo || !this.adContainer) return
|
|
702
|
+
|
|
703
|
+
this.state.currentAd = ad
|
|
704
|
+
this.state.isPlaying = true
|
|
705
|
+
this.state.isLoading = false
|
|
706
|
+
this.state.timeRemaining = ad.duration
|
|
707
|
+
this.state.canSkip = false
|
|
708
|
+
this.state.skipCountdown = this.config.skipDelay || 0
|
|
709
|
+
|
|
710
|
+
// Select best media file
|
|
711
|
+
const mediaFile = this.selectMediaFile(ad.mediaFiles)
|
|
712
|
+
if (!mediaFile) {
|
|
713
|
+
throw new Error('No suitable media file found')
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Show ad container
|
|
717
|
+
this.adContainer.style.display = 'block'
|
|
718
|
+
|
|
719
|
+
// Set source and play
|
|
720
|
+
this.adVideo.src = mediaFile.url
|
|
721
|
+
await this.adVideo.play()
|
|
722
|
+
|
|
723
|
+
// Track impression
|
|
724
|
+
this.trackEvent(ad, 'impression')
|
|
725
|
+
this.trackEvent(ad, 'start')
|
|
726
|
+
|
|
727
|
+
// Start skip timer
|
|
728
|
+
this.startSkipTimer(ad)
|
|
729
|
+
|
|
730
|
+
this.onAdStart?.(ad)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Select best media file
|
|
735
|
+
*/
|
|
736
|
+
private selectMediaFile(mediaFiles: AdMediaFile[]): AdMediaFile | null {
|
|
737
|
+
// Sort by bitrate (highest first) and prefer mp4
|
|
738
|
+
const sorted = [...mediaFiles].sort((a, b) => {
|
|
739
|
+
if (a.type.includes('mp4') && !b.type.includes('mp4')) return -1
|
|
740
|
+
if (!a.type.includes('mp4') && b.type.includes('mp4')) return 1
|
|
741
|
+
return (b.bitrate || 0) - (a.bitrate || 0)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
return sorted[0] || null
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Start skip timer
|
|
749
|
+
*/
|
|
750
|
+
private startSkipTimer(ad: Ad): void {
|
|
751
|
+
const skipDelay = this.config.skipDelay === -1
|
|
752
|
+
? (ad.skipOffset || 5)
|
|
753
|
+
: (this.config.skipDelay || 0)
|
|
754
|
+
|
|
755
|
+
if (skipDelay <= 0) {
|
|
756
|
+
this.state.canSkip = true
|
|
757
|
+
this.updateSkipButton()
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this.state.skipCountdown = skipDelay
|
|
762
|
+
|
|
763
|
+
this.skipTimer = setInterval(() => {
|
|
764
|
+
this.state.skipCountdown--
|
|
765
|
+
|
|
766
|
+
if (this.state.skipCountdown <= 0) {
|
|
767
|
+
this.state.canSkip = true
|
|
768
|
+
if (this.skipTimer) {
|
|
769
|
+
clearInterval(this.skipTimer)
|
|
770
|
+
this.skipTimer = null
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
this.updateSkipButton()
|
|
775
|
+
}, 1000)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Update skip button
|
|
780
|
+
*/
|
|
781
|
+
private updateSkipButton(): void {
|
|
782
|
+
if (!this.skipButton) return
|
|
783
|
+
|
|
784
|
+
if (this.state.canSkip) {
|
|
785
|
+
this.skipButton.textContent = 'Skip Ad'
|
|
786
|
+
this.skipButton.style.display = 'block'
|
|
787
|
+
}
|
|
788
|
+
else if (this.state.skipCountdown > 0) {
|
|
789
|
+
this.skipButton.textContent = `Skip in ${this.state.skipCountdown}s`
|
|
790
|
+
this.skipButton.style.display = 'block'
|
|
791
|
+
this.skipButton.style.cursor = 'default'
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
this.skipButton.style.display = 'none'
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Skip current ad
|
|
800
|
+
*/
|
|
801
|
+
skipAd(): void {
|
|
802
|
+
if (!this.state.canSkip || !this.state.currentAd) return
|
|
803
|
+
|
|
804
|
+
this.trackEvent(this.state.currentAd, 'skip')
|
|
805
|
+
this.onAdSkip?.(this.state.currentAd)
|
|
806
|
+
this.onAdEnded()
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Handle ad click
|
|
811
|
+
*/
|
|
812
|
+
private handleAdClick(): void {
|
|
813
|
+
if (!this.state.currentAd?.clickThroughUrl) return
|
|
814
|
+
|
|
815
|
+
// Pause ad
|
|
816
|
+
this.adVideo?.pause()
|
|
817
|
+
|
|
818
|
+
// Track click
|
|
819
|
+
this.trackEvent(this.state.currentAd, 'clickThrough')
|
|
820
|
+
this.onAdClick?.(this.state.currentAd)
|
|
821
|
+
|
|
822
|
+
// Open URL
|
|
823
|
+
window.open(this.state.currentAd.clickThroughUrl, '_blank')
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Handle ad time update
|
|
828
|
+
*/
|
|
829
|
+
private onAdTimeUpdate(): void {
|
|
830
|
+
if (!this.adVideo || !this.state.currentAd) return
|
|
831
|
+
|
|
832
|
+
const currentTime = this.adVideo.currentTime
|
|
833
|
+
const duration = this.state.currentAd.duration
|
|
834
|
+
|
|
835
|
+
this.state.timeRemaining = Math.max(0, duration - currentTime)
|
|
836
|
+
|
|
837
|
+
// Track quartiles
|
|
838
|
+
const progress = currentTime / duration
|
|
839
|
+
if (progress >= 0.25) this.trackEvent(this.state.currentAd, 'firstQuartile')
|
|
840
|
+
if (progress >= 0.5) this.trackEvent(this.state.currentAd, 'midpoint')
|
|
841
|
+
if (progress >= 0.75) this.trackEvent(this.state.currentAd, 'thirdQuartile')
|
|
842
|
+
|
|
843
|
+
// Track progress events
|
|
844
|
+
for (const event of this.state.currentAd.trackingEvents) {
|
|
845
|
+
if (event.event === 'progress' && event.offset && currentTime >= event.offset) {
|
|
846
|
+
this.trackUrl(event.url)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Handle ad ended
|
|
853
|
+
*/
|
|
854
|
+
private onAdEnded(): void {
|
|
855
|
+
if (this.state.currentAd) {
|
|
856
|
+
this.trackEvent(this.state.currentAd, 'complete')
|
|
857
|
+
this.onAdComplete?.(this.state.currentAd)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Clean up skip timer
|
|
861
|
+
if (this.skipTimer) {
|
|
862
|
+
clearInterval(this.skipTimer)
|
|
863
|
+
this.skipTimer = null
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Play next ad or resume content
|
|
867
|
+
this.currentAdIndex++
|
|
868
|
+
if (this.currentAdIndex < this.currentAds.length) {
|
|
869
|
+
this.playAd(this.currentAds[this.currentAdIndex])
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
this.finishAdBreak()
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Handle ad error
|
|
878
|
+
*/
|
|
879
|
+
private handleAdError(): void {
|
|
880
|
+
const error = 'Ad playback failed'
|
|
881
|
+
this.state.error = error
|
|
882
|
+
this.onAdErrorCallback?.(error)
|
|
883
|
+
this.trackEvent(this.state.currentAd!, 'error')
|
|
884
|
+
this.finishAdBreak()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Finish ad break
|
|
889
|
+
*/
|
|
890
|
+
private finishAdBreak(): void {
|
|
891
|
+
this.state.isPlaying = false
|
|
892
|
+
this.state.currentAd = null
|
|
893
|
+
this.state.currentBreak = null
|
|
894
|
+
this.currentAds = []
|
|
895
|
+
this.currentAdIndex = 0
|
|
896
|
+
|
|
897
|
+
// Hide ad container
|
|
898
|
+
if (this.adContainer) {
|
|
899
|
+
this.adContainer.style.display = 'none'
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
this.onAllAdsComplete?.()
|
|
903
|
+
this.resumeContent()
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Pause content
|
|
908
|
+
*/
|
|
909
|
+
private pauseContent(): void {
|
|
910
|
+
if (!this.player) return
|
|
911
|
+
|
|
912
|
+
const state = this.player.state
|
|
913
|
+
if (state.playbackState === 'playing') {
|
|
914
|
+
this.contentPaused = true
|
|
915
|
+
this.player.pause()
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Resume content
|
|
921
|
+
*/
|
|
922
|
+
private resumeContent(): void {
|
|
923
|
+
if (!this.player) return
|
|
924
|
+
|
|
925
|
+
if (this.contentPaused) {
|
|
926
|
+
this.contentPaused = false
|
|
927
|
+
this.player.play()
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Track ad event
|
|
933
|
+
*/
|
|
934
|
+
private trackEvent(ad: Ad, eventName: string): void {
|
|
935
|
+
const events = ad.trackingEvents.filter(e => e.event === eventName)
|
|
936
|
+
for (const event of events) {
|
|
937
|
+
this.trackUrl(event.url)
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Track URL (fire pixel)
|
|
943
|
+
*/
|
|
944
|
+
private trackUrl(url: string): void {
|
|
945
|
+
const img = new Image()
|
|
946
|
+
img.src = url
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Log debug message
|
|
951
|
+
*/
|
|
952
|
+
private log(...args: unknown[]): void {
|
|
953
|
+
if (this.config.debug) {
|
|
954
|
+
// eslint-disable-next-line no-console
|
|
955
|
+
console.log('[VideoAds]', ...args)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Get current state
|
|
961
|
+
*/
|
|
962
|
+
getState(): AdState {
|
|
963
|
+
return { ...this.state }
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Set event handlers
|
|
968
|
+
*/
|
|
969
|
+
on(event: 'adStart', handler: (ad: Ad) => void): void
|
|
970
|
+
on(event: 'adComplete', handler: (ad: Ad) => void): void
|
|
971
|
+
on(event: 'adSkip', handler: (ad: Ad) => void): void
|
|
972
|
+
on(event: 'adClick', handler: (ad: Ad) => void): void
|
|
973
|
+
on(event: 'adError', handler: (error: string) => void): void
|
|
974
|
+
on(event: 'allAdsComplete', handler: () => void): void
|
|
975
|
+
on(event: string, handler: (...args: any[]) => void): void {
|
|
976
|
+
switch (event) {
|
|
977
|
+
case 'adStart':
|
|
978
|
+
this.onAdStart = handler
|
|
979
|
+
break
|
|
980
|
+
case 'adComplete':
|
|
981
|
+
this.onAdComplete = handler
|
|
982
|
+
break
|
|
983
|
+
case 'adSkip':
|
|
984
|
+
this.onAdSkip = handler
|
|
985
|
+
break
|
|
986
|
+
case 'adClick':
|
|
987
|
+
this.onAdClick = handler
|
|
988
|
+
break
|
|
989
|
+
case 'adError':
|
|
990
|
+
this.onAdErrorCallback = handler
|
|
991
|
+
break
|
|
992
|
+
case 'allAdsComplete':
|
|
993
|
+
this.onAllAdsComplete = handler
|
|
994
|
+
break
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Destroy ads manager
|
|
1000
|
+
*/
|
|
1001
|
+
destroy(): void {
|
|
1002
|
+
if (this.skipTimer) {
|
|
1003
|
+
clearInterval(this.skipTimer)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (this.adContainer) {
|
|
1007
|
+
this.adContainer.remove()
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
this.player = null
|
|
1011
|
+
this.container = null
|
|
1012
|
+
this.adVideo = null
|
|
1013
|
+
this.adContainer = null
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// =============================================================================
|
|
1018
|
+
// Factory
|
|
1019
|
+
// =============================================================================
|
|
1020
|
+
|
|
1021
|
+
export function createAdsManager(config?: AdConfig): AdsManager {
|
|
1022
|
+
return new AdsManager(config)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Ads plugin for Player
|
|
1027
|
+
*/
|
|
1028
|
+
export function adsPlugin(config: AdConfig): { name: string, install: (player: Player, container: HTMLElement) => () => void } {
|
|
1029
|
+
return {
|
|
1030
|
+
name: 'ads',
|
|
1031
|
+
|
|
1032
|
+
install(player: Player, container: HTMLElement) {
|
|
1033
|
+
const ads = createAdsManager(config)
|
|
1034
|
+
ads.attach(player, container)
|
|
1035
|
+
|
|
1036
|
+
// Expose on player
|
|
1037
|
+
;(player as any).ads = ads
|
|
1038
|
+
|
|
1039
|
+
return () => {
|
|
1040
|
+
ads.destroy()
|
|
1041
|
+
delete (player as any).ads
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
}
|