@livepeer-frameworks/player-core 0.0.3
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/dist/cjs/index.js +19493 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +19398 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/player.css +2140 -0
- package/dist/types/core/ABRController.d.ts +164 -0
- package/dist/types/core/CodecUtils.d.ts +54 -0
- package/dist/types/core/Disposable.d.ts +61 -0
- package/dist/types/core/EventEmitter.d.ts +73 -0
- package/dist/types/core/GatewayClient.d.ts +144 -0
- package/dist/types/core/InteractionController.d.ts +121 -0
- package/dist/types/core/LiveDurationProxy.d.ts +102 -0
- package/dist/types/core/MetaTrackManager.d.ts +220 -0
- package/dist/types/core/MistReporter.d.ts +163 -0
- package/dist/types/core/MistSignaling.d.ts +148 -0
- package/dist/types/core/PlayerController.d.ts +665 -0
- package/dist/types/core/PlayerInterface.d.ts +230 -0
- package/dist/types/core/PlayerManager.d.ts +182 -0
- package/dist/types/core/PlayerRegistry.d.ts +27 -0
- package/dist/types/core/QualityMonitor.d.ts +184 -0
- package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
- package/dist/types/core/SeekingUtils.d.ts +142 -0
- package/dist/types/core/StreamStateClient.d.ts +108 -0
- package/dist/types/core/SubtitleManager.d.ts +111 -0
- package/dist/types/core/TelemetryReporter.d.ts +79 -0
- package/dist/types/core/TimeFormat.d.ts +97 -0
- package/dist/types/core/TimerManager.d.ts +83 -0
- package/dist/types/core/UrlUtils.d.ts +81 -0
- package/dist/types/core/detector.d.ts +149 -0
- package/dist/types/core/index.d.ts +49 -0
- package/dist/types/core/scorer.d.ts +167 -0
- package/dist/types/core/selector.d.ts +9 -0
- package/dist/types/index.d.ts +45 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/players/DashJsPlayer.d.ts +102 -0
- package/dist/types/players/HlsJsPlayer.d.ts +70 -0
- package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
- package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
- package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
- package/dist/types/players/MistPlayer.d.ts +25 -0
- package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
- package/dist/types/players/NativePlayer.d.ts +143 -0
- package/dist/types/players/VideoJsPlayer.d.ts +59 -0
- package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
- package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
- package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
- package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
- package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
- package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
- package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
- package/dist/types/players/index.d.ts +14 -0
- package/dist/types/styles/index.d.ts +11 -0
- package/dist/types/types.d.ts +363 -0
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
- package/dist/types/vanilla/index.d.ts +19 -0
- package/dist/workers/decoder.worker.js +989 -0
- package/dist/workers/decoder.worker.js.map +1 -0
- package/package.json +80 -0
- package/src/core/ABRController.ts +550 -0
- package/src/core/CodecUtils.ts +257 -0
- package/src/core/Disposable.ts +120 -0
- package/src/core/EventEmitter.ts +113 -0
- package/src/core/GatewayClient.ts +439 -0
- package/src/core/InteractionController.ts +712 -0
- package/src/core/LiveDurationProxy.ts +270 -0
- package/src/core/MetaTrackManager.ts +753 -0
- package/src/core/MistReporter.ts +543 -0
- package/src/core/MistSignaling.ts +346 -0
- package/src/core/PlayerController.ts +2829 -0
- package/src/core/PlayerInterface.ts +432 -0
- package/src/core/PlayerManager.ts +900 -0
- package/src/core/PlayerRegistry.ts +149 -0
- package/src/core/QualityMonitor.ts +597 -0
- package/src/core/ScreenWakeLockManager.ts +163 -0
- package/src/core/SeekingUtils.ts +364 -0
- package/src/core/StreamStateClient.ts +457 -0
- package/src/core/SubtitleManager.ts +297 -0
- package/src/core/TelemetryReporter.ts +308 -0
- package/src/core/TimeFormat.ts +205 -0
- package/src/core/TimerManager.ts +209 -0
- package/src/core/UrlUtils.ts +179 -0
- package/src/core/detector.ts +382 -0
- package/src/core/index.ts +140 -0
- package/src/core/scorer.ts +553 -0
- package/src/core/selector.ts +16 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/players/DashJsPlayer.ts +642 -0
- package/src/players/HlsJsPlayer.ts +483 -0
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
- package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
- package/src/players/MewsWsPlayer/index.ts +1065 -0
- package/src/players/MewsWsPlayer/types.ts +106 -0
- package/src/players/MistPlayer.ts +188 -0
- package/src/players/MistWebRTCPlayer/index.ts +703 -0
- package/src/players/NativePlayer.ts +820 -0
- package/src/players/VideoJsPlayer.ts +643 -0
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
- package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
- package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
- package/src/players/WebCodecsPlayer/index.ts +1650 -0
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
- package/src/players/WebCodecsPlayer/types.ts +542 -0
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
- package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
- package/src/players/index.ts +22 -0
- package/src/styles/animations.css +21 -0
- package/src/styles/index.ts +52 -0
- package/src/styles/player.css +2126 -0
- package/src/styles/tailwind.css +1015 -0
- package/src/types.ts +421 -0
- package/src/vanilla/FrameWorksPlayer.ts +367 -0
- package/src/vanilla/index.ts +22 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleManager - WebVTT subtitle track management
|
|
3
|
+
*
|
|
4
|
+
* Based on MistMetaPlayer's subtitle handling (wrappers/html5.js, webrtc.js).
|
|
5
|
+
* Manages text tracks on video elements with support for:
|
|
6
|
+
* - Loading WebVTT from MistServer URLs
|
|
7
|
+
* - Multiple subtitle track selection
|
|
8
|
+
* - Sync correction for WebRTC seek offsets
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SubtitleTrackInfo {
|
|
12
|
+
/** Track ID (from MistServer) */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Display label */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Language code (e.g., 'en', 'es') */
|
|
17
|
+
lang: string;
|
|
18
|
+
/** Source URL for WebVTT file */
|
|
19
|
+
src: string;
|
|
20
|
+
/** Whether this is the default track */
|
|
21
|
+
default?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SubtitleManagerConfig {
|
|
25
|
+
/** Base URL for MistServer (for constructing track URLs) */
|
|
26
|
+
mistBaseUrl?: string;
|
|
27
|
+
/** Stream name */
|
|
28
|
+
streamName?: string;
|
|
29
|
+
/** URL append string (auth tokens, etc.) */
|
|
30
|
+
urlAppend?: string;
|
|
31
|
+
/** Debug logging */
|
|
32
|
+
debug?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* SubtitleManager handles text track lifecycle on a video element
|
|
37
|
+
*/
|
|
38
|
+
export class SubtitleManager {
|
|
39
|
+
private video: HTMLVideoElement | null = null;
|
|
40
|
+
private config: SubtitleManagerConfig;
|
|
41
|
+
private currentTrackId: string | null = null;
|
|
42
|
+
private seekOffset = 0;
|
|
43
|
+
private debug: boolean;
|
|
44
|
+
private listeners: Array<() => void> = [];
|
|
45
|
+
|
|
46
|
+
constructor(config: SubtitleManagerConfig = {}) {
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.debug = config.debug ?? false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Attach to a video element
|
|
53
|
+
*/
|
|
54
|
+
attach(video: HTMLVideoElement): void {
|
|
55
|
+
this.detach();
|
|
56
|
+
this.video = video;
|
|
57
|
+
|
|
58
|
+
// Listen for events that may require sync correction
|
|
59
|
+
const onLoadedData = () => this.correctSubtitleSync();
|
|
60
|
+
const onSeeked = () => this.correctSubtitleSync();
|
|
61
|
+
|
|
62
|
+
video.addEventListener('loadeddata', onLoadedData);
|
|
63
|
+
video.addEventListener('seeked', onSeeked);
|
|
64
|
+
|
|
65
|
+
this.listeners = [
|
|
66
|
+
() => video.removeEventListener('loadeddata', onLoadedData),
|
|
67
|
+
() => video.removeEventListener('seeked', onSeeked),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detach from video element
|
|
73
|
+
*/
|
|
74
|
+
detach(): void {
|
|
75
|
+
this.listeners.forEach(fn => fn());
|
|
76
|
+
this.listeners = [];
|
|
77
|
+
this.removeAllTracks();
|
|
78
|
+
this.video = null;
|
|
79
|
+
this.currentTrackId = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get available text tracks from the video element
|
|
84
|
+
*/
|
|
85
|
+
getTextTracks(): TextTrack[] {
|
|
86
|
+
if (!this.video) return [];
|
|
87
|
+
return Array.from(this.video.textTracks);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get all track elements from the video
|
|
92
|
+
*/
|
|
93
|
+
getTrackElements(): HTMLTrackElement[] {
|
|
94
|
+
if (!this.video) return [];
|
|
95
|
+
return Array.from(this.video.querySelectorAll('track'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Set the active subtitle track
|
|
100
|
+
* Pass null to disable subtitles
|
|
101
|
+
*/
|
|
102
|
+
setSubtitle(track: SubtitleTrackInfo | null): void {
|
|
103
|
+
if (!this.video) {
|
|
104
|
+
this.log('Cannot set subtitle: no video element attached');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Remove existing subtitle tracks
|
|
109
|
+
this.removeAllTracks();
|
|
110
|
+
|
|
111
|
+
if (!track) {
|
|
112
|
+
this.currentTrackId = null;
|
|
113
|
+
this.log('Subtitles disabled');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create new track element
|
|
118
|
+
const trackElement = document.createElement('track');
|
|
119
|
+
trackElement.kind = 'subtitles';
|
|
120
|
+
trackElement.label = track.label;
|
|
121
|
+
trackElement.srclang = track.lang;
|
|
122
|
+
trackElement.src = this.buildTrackUrl(track.src);
|
|
123
|
+
trackElement.default = true;
|
|
124
|
+
|
|
125
|
+
// Set up load handler for sync correction
|
|
126
|
+
trackElement.addEventListener('load', () => {
|
|
127
|
+
this.correctSubtitleSync();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this.video.appendChild(trackElement);
|
|
131
|
+
this.currentTrackId = track.id;
|
|
132
|
+
|
|
133
|
+
// Enable the track
|
|
134
|
+
const textTrack = this.video.textTracks[this.video.textTracks.length - 1];
|
|
135
|
+
if (textTrack) {
|
|
136
|
+
textTrack.mode = 'showing';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.log(`Subtitle track set: ${track.label} (${track.lang})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build track URL with base URL and append params
|
|
144
|
+
*/
|
|
145
|
+
private buildTrackUrl(src: string): string {
|
|
146
|
+
let url = src;
|
|
147
|
+
|
|
148
|
+
// If relative URL and base URL provided, construct full URL
|
|
149
|
+
if (!url.startsWith('http') && this.config.mistBaseUrl) {
|
|
150
|
+
const base = this.config.mistBaseUrl.replace(/\/$/, '');
|
|
151
|
+
url = url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Append URL params if configured
|
|
155
|
+
if (this.config.urlAppend) {
|
|
156
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
157
|
+
url = `${url}${separator}${this.config.urlAppend}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return url;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create subtitle track info from MistServer track metadata
|
|
165
|
+
*/
|
|
166
|
+
static createTrackInfo(
|
|
167
|
+
trackId: string,
|
|
168
|
+
label: string,
|
|
169
|
+
lang: string,
|
|
170
|
+
baseUrl: string,
|
|
171
|
+
streamName: string
|
|
172
|
+
): SubtitleTrackInfo {
|
|
173
|
+
// MistServer WebVTT URL format
|
|
174
|
+
const src = `${baseUrl}/${streamName}.vtt?track=${trackId}`;
|
|
175
|
+
return { id: trackId, label, lang, src };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove all track elements from video
|
|
180
|
+
*/
|
|
181
|
+
removeAllTracks(): void {
|
|
182
|
+
if (!this.video) return;
|
|
183
|
+
|
|
184
|
+
const tracks = this.video.querySelectorAll('track');
|
|
185
|
+
tracks.forEach(track => track.remove());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get currently active track ID
|
|
190
|
+
*/
|
|
191
|
+
getCurrentTrackId(): string | null {
|
|
192
|
+
return this.currentTrackId;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Set seek offset for WebRTC sync correction
|
|
197
|
+
* WebRTC playback has a seek offset that needs to be applied to subtitle timing
|
|
198
|
+
*/
|
|
199
|
+
setSeekOffset(offset: number): void {
|
|
200
|
+
const oldOffset = this.seekOffset;
|
|
201
|
+
this.seekOffset = offset;
|
|
202
|
+
|
|
203
|
+
// Re-sync if offset changed significantly
|
|
204
|
+
if (Math.abs(oldOffset - offset) > 1) {
|
|
205
|
+
this.correctSubtitleSync();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Correct subtitle timing based on seek offset
|
|
211
|
+
* This is needed for WebRTC where video.currentTime doesn't match actual playback position
|
|
212
|
+
*/
|
|
213
|
+
private correctSubtitleSync(): void {
|
|
214
|
+
if (!this.video || this.video.textTracks.length === 0) return;
|
|
215
|
+
|
|
216
|
+
const textTrack = this.video.textTracks[0];
|
|
217
|
+
if (!textTrack || !textTrack.cues) return;
|
|
218
|
+
|
|
219
|
+
const currentOffset = (textTrack as any).currentOffset || 0;
|
|
220
|
+
|
|
221
|
+
// Don't bother if change is small
|
|
222
|
+
if (Math.abs(this.seekOffset - currentOffset) < 1) return;
|
|
223
|
+
|
|
224
|
+
this.log(`Correcting subtitle sync: offset ${currentOffset} -> ${this.seekOffset}`);
|
|
225
|
+
|
|
226
|
+
// Collect and re-add cues with corrected timing
|
|
227
|
+
const newCues: VTTCue[] = [];
|
|
228
|
+
|
|
229
|
+
for (let i = textTrack.cues.length - 1; i >= 0; i--) {
|
|
230
|
+
const cue = textTrack.cues[i] as VTTCue;
|
|
231
|
+
textTrack.removeCue(cue);
|
|
232
|
+
|
|
233
|
+
// Store original timing if not already stored
|
|
234
|
+
if (!(cue as any).orig) {
|
|
235
|
+
(cue as any).orig = { start: cue.startTime, end: cue.endTime };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Apply offset correction
|
|
239
|
+
cue.startTime = (cue as any).orig.start - this.seekOffset;
|
|
240
|
+
cue.endTime = (cue as any).orig.end - this.seekOffset;
|
|
241
|
+
|
|
242
|
+
newCues.push(cue);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Re-add cues
|
|
246
|
+
for (const cue of newCues) {
|
|
247
|
+
try {
|
|
248
|
+
textTrack.addCue(cue);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// Ignore errors from invalid cue timing
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
(textTrack as any).currentOffset = this.seekOffset;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Parse subtitle tracks from MistServer stream info
|
|
259
|
+
*/
|
|
260
|
+
static parseTracksFromStreamInfo(
|
|
261
|
+
streamInfo: { meta?: { tracks?: Record<string, { type: string; codec: string; lang?: string }> } },
|
|
262
|
+
baseUrl: string,
|
|
263
|
+
streamName: string
|
|
264
|
+
): SubtitleTrackInfo[] {
|
|
265
|
+
const tracks: SubtitleTrackInfo[] = [];
|
|
266
|
+
|
|
267
|
+
if (!streamInfo.meta?.tracks) return tracks;
|
|
268
|
+
|
|
269
|
+
for (const [trackId, trackData] of Object.entries(streamInfo.meta.tracks)) {
|
|
270
|
+
if (trackData.type === 'meta' && trackData.codec === 'subtitle') {
|
|
271
|
+
const lang = trackData.lang || 'und';
|
|
272
|
+
const label = lang === 'und' ? `Subtitles ${trackId}` : lang.toUpperCase();
|
|
273
|
+
tracks.push(SubtitleManager.createTrackInfo(trackId, label, lang, baseUrl, streamName));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return tracks;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Debug logging
|
|
282
|
+
*/
|
|
283
|
+
private log(message: string): void {
|
|
284
|
+
if (this.debug) {
|
|
285
|
+
console.debug(`[SubtitleManager] ${message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Cleanup
|
|
291
|
+
*/
|
|
292
|
+
destroy(): void {
|
|
293
|
+
this.detach();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default SubtitleManager;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { TelemetryPayload, TelemetryOptions, PlaybackQuality, ContentType } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a unique session ID
|
|
5
|
+
*/
|
|
6
|
+
function generateSessionId(): string {
|
|
7
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TelemetryReporterConfig {
|
|
11
|
+
/** Telemetry endpoint URL */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/** Auth token for endpoint */
|
|
14
|
+
authToken?: string;
|
|
15
|
+
/** Report interval in ms (default: 5000) */
|
|
16
|
+
interval?: number;
|
|
17
|
+
/** Batch size before flush (default: 1) */
|
|
18
|
+
batchSize?: number;
|
|
19
|
+
/** Content ID being played */
|
|
20
|
+
contentId: string;
|
|
21
|
+
/** Content type */
|
|
22
|
+
contentType: ContentType;
|
|
23
|
+
/** Player type name */
|
|
24
|
+
playerType: string;
|
|
25
|
+
/** Protocol being used */
|
|
26
|
+
protocol: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* TelemetryReporter - Sends playback metrics to server
|
|
31
|
+
*
|
|
32
|
+
* Features:
|
|
33
|
+
* - Batched reporting at configurable interval
|
|
34
|
+
* - Retry with exponential backoff on failure
|
|
35
|
+
* - Uses navigator.sendBeacon() for reliable page unload reporting
|
|
36
|
+
* - Tracks errors during playback
|
|
37
|
+
*/
|
|
38
|
+
export class TelemetryReporter {
|
|
39
|
+
private config: Required<TelemetryReporterConfig>;
|
|
40
|
+
private sessionId: string;
|
|
41
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
private pendingPayloads: TelemetryPayload[] = [];
|
|
43
|
+
private errors: Array<{ code: string; message: string; timestamp: number }> = [];
|
|
44
|
+
private stallCount = 0;
|
|
45
|
+
private totalStallMs = 0;
|
|
46
|
+
private lastStallStart = 0;
|
|
47
|
+
private videoElement: HTMLVideoElement | null = null;
|
|
48
|
+
private qualityGetter: (() => PlaybackQuality | null) | null = null;
|
|
49
|
+
private listeners: Array<() => void> = [];
|
|
50
|
+
|
|
51
|
+
constructor(config: TelemetryReporterConfig) {
|
|
52
|
+
this.config = {
|
|
53
|
+
endpoint: config.endpoint,
|
|
54
|
+
authToken: config.authToken ?? '',
|
|
55
|
+
interval: config.interval ?? 5000,
|
|
56
|
+
batchSize: config.batchSize ?? 1,
|
|
57
|
+
contentId: config.contentId,
|
|
58
|
+
contentType: config.contentType,
|
|
59
|
+
playerType: config.playerType,
|
|
60
|
+
protocol: config.protocol,
|
|
61
|
+
};
|
|
62
|
+
this.sessionId = generateSessionId();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start telemetry reporting
|
|
67
|
+
*/
|
|
68
|
+
start(
|
|
69
|
+
videoElement: HTMLVideoElement,
|
|
70
|
+
qualityGetter?: () => PlaybackQuality | null
|
|
71
|
+
): void {
|
|
72
|
+
this.stop();
|
|
73
|
+
|
|
74
|
+
this.videoElement = videoElement;
|
|
75
|
+
this.qualityGetter = qualityGetter ?? null;
|
|
76
|
+
this.stallCount = 0;
|
|
77
|
+
this.totalStallMs = 0;
|
|
78
|
+
this.errors = [];
|
|
79
|
+
|
|
80
|
+
// Track stalls
|
|
81
|
+
const onWaiting = () => {
|
|
82
|
+
this.stallCount++;
|
|
83
|
+
this.lastStallStart = performance.now();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onPlaying = () => {
|
|
87
|
+
if (this.lastStallStart > 0) {
|
|
88
|
+
this.totalStallMs += performance.now() - this.lastStallStart;
|
|
89
|
+
this.lastStallStart = 0;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onError = () => {
|
|
94
|
+
const error = videoElement.error;
|
|
95
|
+
if (error) {
|
|
96
|
+
this.errors.push({
|
|
97
|
+
code: String(error.code),
|
|
98
|
+
message: error.message || 'Unknown error',
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
videoElement.addEventListener('waiting', onWaiting);
|
|
105
|
+
videoElement.addEventListener('playing', onPlaying);
|
|
106
|
+
videoElement.addEventListener('error', onError);
|
|
107
|
+
|
|
108
|
+
this.listeners = [
|
|
109
|
+
() => videoElement.removeEventListener('waiting', onWaiting),
|
|
110
|
+
() => videoElement.removeEventListener('playing', onPlaying),
|
|
111
|
+
() => videoElement.removeEventListener('error', onError),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// Setup unload handler for reliable final report
|
|
115
|
+
const onUnload = () => this.flushSync();
|
|
116
|
+
window.addEventListener('beforeunload', onUnload);
|
|
117
|
+
window.addEventListener('pagehide', onUnload);
|
|
118
|
+
this.listeners.push(
|
|
119
|
+
() => window.removeEventListener('beforeunload', onUnload),
|
|
120
|
+
() => window.removeEventListener('pagehide', onUnload)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Start reporting interval
|
|
124
|
+
this.intervalId = setInterval(() => this.report(), this.config.interval);
|
|
125
|
+
|
|
126
|
+
// Take initial report
|
|
127
|
+
this.report();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stop telemetry reporting
|
|
132
|
+
*/
|
|
133
|
+
stop(): void {
|
|
134
|
+
// Final report before stopping
|
|
135
|
+
this.flushSync();
|
|
136
|
+
|
|
137
|
+
if (this.intervalId) {
|
|
138
|
+
clearInterval(this.intervalId);
|
|
139
|
+
this.intervalId = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.listeners.forEach(cleanup => cleanup());
|
|
143
|
+
this.listeners = [];
|
|
144
|
+
|
|
145
|
+
this.videoElement = null;
|
|
146
|
+
this.qualityGetter = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Record a custom error
|
|
151
|
+
*/
|
|
152
|
+
recordError(code: string, message: string): void {
|
|
153
|
+
this.errors.push({
|
|
154
|
+
code,
|
|
155
|
+
message,
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Generate telemetry payload
|
|
162
|
+
*/
|
|
163
|
+
private generatePayload(): TelemetryPayload | null {
|
|
164
|
+
const video = this.videoElement;
|
|
165
|
+
if (!video) return null;
|
|
166
|
+
|
|
167
|
+
// Get quality metrics if available
|
|
168
|
+
const quality = this.qualityGetter?.() ?? null;
|
|
169
|
+
|
|
170
|
+
// Get frame stats if available
|
|
171
|
+
let framesDecoded = 0;
|
|
172
|
+
let framesDropped = 0;
|
|
173
|
+
|
|
174
|
+
if ('getVideoPlaybackQuality' in video) {
|
|
175
|
+
const stats = video.getVideoPlaybackQuality();
|
|
176
|
+
framesDecoded = stats.totalVideoFrames;
|
|
177
|
+
framesDropped = stats.droppedVideoFrames;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Calculate buffered seconds
|
|
181
|
+
let bufferedSeconds = 0;
|
|
182
|
+
if (video.buffered.length > 0) {
|
|
183
|
+
for (let i = 0; i < video.buffered.length; i++) {
|
|
184
|
+
if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) > video.currentTime) {
|
|
185
|
+
bufferedSeconds = video.buffered.end(i) - video.currentTime;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
sessionId: this.sessionId,
|
|
194
|
+
contentId: this.config.contentId,
|
|
195
|
+
contentType: this.config.contentType,
|
|
196
|
+
metrics: {
|
|
197
|
+
currentTime: video.currentTime,
|
|
198
|
+
duration: isFinite(video.duration) ? video.duration : -1,
|
|
199
|
+
bufferedSeconds,
|
|
200
|
+
stallCount: this.stallCount,
|
|
201
|
+
totalStallMs: this.totalStallMs,
|
|
202
|
+
bitrate: quality?.bitrate ?? 0,
|
|
203
|
+
qualityScore: quality?.score ?? 100,
|
|
204
|
+
framesDecoded,
|
|
205
|
+
framesDropped,
|
|
206
|
+
playerType: this.config.playerType,
|
|
207
|
+
protocol: this.config.protocol,
|
|
208
|
+
resolution: video.videoWidth > 0 ? {
|
|
209
|
+
width: video.videoWidth,
|
|
210
|
+
height: video.videoHeight,
|
|
211
|
+
} : undefined,
|
|
212
|
+
},
|
|
213
|
+
errors: this.errors.length > 0 ? [...this.errors] : undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Send telemetry report
|
|
219
|
+
*/
|
|
220
|
+
private async report(): Promise<void> {
|
|
221
|
+
const payload = this.generatePayload();
|
|
222
|
+
if (!payload) return;
|
|
223
|
+
|
|
224
|
+
this.pendingPayloads.push(payload);
|
|
225
|
+
|
|
226
|
+
// Flush if batch size reached
|
|
227
|
+
if (this.pendingPayloads.length >= this.config.batchSize) {
|
|
228
|
+
await this.flush();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Flush pending payloads (async)
|
|
234
|
+
*/
|
|
235
|
+
private async flush(): Promise<void> {
|
|
236
|
+
if (this.pendingPayloads.length === 0) return;
|
|
237
|
+
|
|
238
|
+
const payloads = [...this.pendingPayloads];
|
|
239
|
+
this.pendingPayloads = [];
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const headers: HeadersInit = {
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (this.config.authToken) {
|
|
247
|
+
headers['Authorization'] = `Bearer ${this.config.authToken}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const response = await fetch(this.config.endpoint, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers,
|
|
253
|
+
body: JSON.stringify(payloads.length === 1 ? payloads[0] : payloads),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
console.warn('[TelemetryReporter] Report failed:', response.status);
|
|
258
|
+
// Re-queue failed payloads (up to a limit)
|
|
259
|
+
if (this.pendingPayloads.length < 10) {
|
|
260
|
+
this.pendingPayloads.unshift(...payloads);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Clear reported errors
|
|
264
|
+
this.errors = [];
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn('[TelemetryReporter] Report error:', error);
|
|
268
|
+
// Re-queue failed payloads
|
|
269
|
+
if (this.pendingPayloads.length < 10) {
|
|
270
|
+
this.pendingPayloads.unshift(...payloads);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Flush synchronously using sendBeacon (for page unload)
|
|
277
|
+
*/
|
|
278
|
+
private flushSync(): void {
|
|
279
|
+
const payload = this.generatePayload();
|
|
280
|
+
if (!payload) return;
|
|
281
|
+
|
|
282
|
+
const payloads = [...this.pendingPayloads, payload];
|
|
283
|
+
this.pendingPayloads = [];
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const data = JSON.stringify(payloads.length === 1 ? payloads[0] : payloads);
|
|
287
|
+
navigator.sendBeacon(this.config.endpoint, new Blob([data], { type: 'application/json' }));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.warn('[TelemetryReporter] Beacon failed:', error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get session ID
|
|
295
|
+
*/
|
|
296
|
+
getSessionId(): string {
|
|
297
|
+
return this.sessionId;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check if reporting is active
|
|
302
|
+
*/
|
|
303
|
+
isActive(): boolean {
|
|
304
|
+
return this.intervalId !== null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export default TelemetryReporter;
|