@livepeer-frameworks/player-react 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 +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types/components/DevModePanel.d.ts +47 -0
- package/dist/types/components/DvdLogo.d.ts +4 -0
- package/dist/types/components/Icons.d.ts +33 -0
- package/dist/types/components/IdleScreen.d.ts +16 -0
- package/dist/types/components/LoadingScreen.d.ts +6 -0
- package/dist/types/components/LogoOverlay.d.ts +11 -0
- package/dist/types/components/Player.d.ts +11 -0
- package/dist/types/components/PlayerControls.d.ts +60 -0
- package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
- package/dist/types/components/SeekBar.d.ts +33 -0
- package/dist/types/components/SkipIndicator.d.ts +14 -0
- package/dist/types/components/SpeedIndicator.d.ts +12 -0
- package/dist/types/components/StatsPanel.d.ts +31 -0
- package/dist/types/components/StreamStateOverlay.d.ts +24 -0
- package/dist/types/components/SubtitleRenderer.d.ts +69 -0
- package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
- package/dist/types/components/TitleOverlay.d.ts +13 -0
- package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
- package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
- package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
- package/dist/types/components/players/MistPlayer.d.ts +20 -0
- package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
- package/dist/types/components/players/NativePlayer.d.ts +19 -0
- package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
- package/dist/types/context/PlayerContext.d.ts +40 -0
- package/dist/types/context/index.d.ts +5 -0
- package/dist/types/hooks/useMetaTrack.d.ts +54 -0
- package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
- package/dist/types/hooks/usePlayerController.d.ts +163 -0
- package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
- package/dist/types/hooks/useStreamState.d.ts +27 -0
- package/dist/types/hooks/useTelemetry.d.ts +57 -0
- package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/types.d.ts +94 -0
- package/dist/types/ui/badge.d.ts +9 -0
- package/dist/types/ui/button.d.ts +11 -0
- package/dist/types/ui/context-menu.d.ts +27 -0
- package/dist/types/ui/select.d.ts +10 -0
- package/dist/types/ui/slider.d.ts +13 -0
- package/package.json +71 -0
- package/src/assets/logomark.svg +56 -0
- package/src/components/DevModePanel.tsx +822 -0
- package/src/components/DvdLogo.tsx +201 -0
- package/src/components/Icons.tsx +282 -0
- package/src/components/IdleScreen.tsx +664 -0
- package/src/components/LoadingScreen.tsx +710 -0
- package/src/components/LogoOverlay.tsx +75 -0
- package/src/components/Player.tsx +419 -0
- package/src/components/PlayerControls.tsx +820 -0
- package/src/components/PlayerErrorBoundary.tsx +70 -0
- package/src/components/SeekBar.tsx +291 -0
- package/src/components/SkipIndicator.tsx +113 -0
- package/src/components/SpeedIndicator.tsx +57 -0
- package/src/components/StatsPanel.tsx +150 -0
- package/src/components/StreamStateOverlay.tsx +200 -0
- package/src/components/SubtitleRenderer.tsx +235 -0
- package/src/components/ThumbnailOverlay.tsx +90 -0
- package/src/components/TitleOverlay.tsx +48 -0
- package/src/components/players/DashJsPlayer.tsx +56 -0
- package/src/components/players/HlsJsPlayer.tsx +56 -0
- package/src/components/players/MewsWsPlayer/index.tsx +56 -0
- package/src/components/players/MistPlayer.tsx +60 -0
- package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
- package/src/components/players/NativePlayer.tsx +58 -0
- package/src/components/players/VideoJsPlayer.tsx +56 -0
- package/src/context/PlayerContext.tsx +71 -0
- package/src/context/index.ts +11 -0
- package/src/global.d.ts +4 -0
- package/src/hooks/useMetaTrack.ts +187 -0
- package/src/hooks/usePlaybackQuality.ts +126 -0
- package/src/hooks/usePlayerController.ts +525 -0
- package/src/hooks/usePlayerSelection.ts +117 -0
- package/src/hooks/useStreamState.ts +381 -0
- package/src/hooks/useTelemetry.ts +138 -0
- package/src/hooks/useViewerEndpoints.ts +120 -0
- package/src/index.tsx +75 -0
- package/src/player.css +2 -0
- package/src/types.ts +135 -0
- package/src/ui/badge.tsx +27 -0
- package/src/ui/button.tsx +47 -0
- package/src/ui/context-menu.tsx +193 -0
- package/src/ui/select.tsx +105 -0
- package/src/ui/slider.tsx +67 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
UseStreamStateOptions,
|
|
4
|
+
StreamState,
|
|
5
|
+
StreamStatus,
|
|
6
|
+
MistStreamInfo,
|
|
7
|
+
} from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse MistServer error string into StreamStatus enum
|
|
11
|
+
*/
|
|
12
|
+
function parseErrorToStatus(error: string): StreamStatus {
|
|
13
|
+
const lowerError = error.toLowerCase();
|
|
14
|
+
|
|
15
|
+
if (lowerError.includes('offline')) return 'OFFLINE';
|
|
16
|
+
if (lowerError.includes('initializing')) return 'INITIALIZING';
|
|
17
|
+
if (lowerError.includes('booting')) return 'BOOTING';
|
|
18
|
+
if (lowerError.includes('waiting for data')) return 'WAITING_FOR_DATA';
|
|
19
|
+
if (lowerError.includes('shutting down')) return 'SHUTTING_DOWN';
|
|
20
|
+
if (lowerError.includes('invalid')) return 'INVALID';
|
|
21
|
+
|
|
22
|
+
return 'ERROR';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get human-readable message for stream status
|
|
27
|
+
*/
|
|
28
|
+
function getStatusMessage(status: StreamStatus, percentage?: number): string {
|
|
29
|
+
switch (status) {
|
|
30
|
+
case 'ONLINE':
|
|
31
|
+
return 'Stream is online';
|
|
32
|
+
case 'OFFLINE':
|
|
33
|
+
return 'Stream is offline';
|
|
34
|
+
case 'INITIALIZING':
|
|
35
|
+
return percentage !== undefined
|
|
36
|
+
? `Initializing... ${Math.round(percentage * 10) / 10}%`
|
|
37
|
+
: 'Stream is initializing';
|
|
38
|
+
case 'BOOTING':
|
|
39
|
+
return 'Stream is starting up';
|
|
40
|
+
case 'WAITING_FOR_DATA':
|
|
41
|
+
return 'Waiting for stream data';
|
|
42
|
+
case 'SHUTTING_DOWN':
|
|
43
|
+
return 'Stream is shutting down';
|
|
44
|
+
case 'INVALID':
|
|
45
|
+
return 'Stream status is invalid';
|
|
46
|
+
case 'ERROR':
|
|
47
|
+
default:
|
|
48
|
+
return 'Stream error';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initial stream state
|
|
54
|
+
*/
|
|
55
|
+
const initialState: StreamState = {
|
|
56
|
+
status: 'OFFLINE',
|
|
57
|
+
isOnline: false,
|
|
58
|
+
message: 'Connecting...',
|
|
59
|
+
lastUpdate: 0,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook to poll MistServer for stream status via WebSocket or HTTP
|
|
64
|
+
*
|
|
65
|
+
* Uses native MistServer protocol:
|
|
66
|
+
* - WebSocket: ws://{baseUrl}/json_{streamName}.js
|
|
67
|
+
* - HTTP fallback: GET {baseUrl}/json_{streamName}.js
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const { status, isOnline, message } = useStreamState({
|
|
72
|
+
* mistBaseUrl: 'https://mist.example.com',
|
|
73
|
+
* streamName: 'my-stream',
|
|
74
|
+
* pollInterval: 3000,
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export interface UseStreamStateReturn extends StreamState {
|
|
79
|
+
/** Manual refetch function */
|
|
80
|
+
refetch: () => void;
|
|
81
|
+
/** WebSocket reference for sharing with MistReporter */
|
|
82
|
+
socketRef: React.RefObject<WebSocket | null>;
|
|
83
|
+
/** True when WebSocket is connected and ready (triggers re-render) */
|
|
84
|
+
socketReady: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useStreamState(options: UseStreamStateOptions): UseStreamStateReturn {
|
|
88
|
+
const {
|
|
89
|
+
mistBaseUrl,
|
|
90
|
+
streamName,
|
|
91
|
+
pollInterval = 3000,
|
|
92
|
+
enabled = true,
|
|
93
|
+
useWebSocket = true,
|
|
94
|
+
debug = false,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
const [state, setState] = useState<StreamState>(initialState);
|
|
98
|
+
const [socketReady, setSocketReady] = useState(false);
|
|
99
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
100
|
+
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const wsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
102
|
+
const mountedRef = useRef(true);
|
|
103
|
+
|
|
104
|
+
// MistPlayer-style WebSocket timeout (5 seconds)
|
|
105
|
+
const WS_TIMEOUT_MS = 5000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Process MistServer response data
|
|
109
|
+
*/
|
|
110
|
+
const processStreamInfo = useCallback((data: MistStreamInfo) => {
|
|
111
|
+
if (!mountedRef.current) return;
|
|
112
|
+
|
|
113
|
+
if (data.error) {
|
|
114
|
+
// Stream has an error state - preserve previous streamInfo (track data)
|
|
115
|
+
const status = parseErrorToStatus(data.error);
|
|
116
|
+
const message = data.on_error || getStatusMessage(status, data.perc);
|
|
117
|
+
|
|
118
|
+
setState(prev => ({
|
|
119
|
+
status,
|
|
120
|
+
isOnline: false,
|
|
121
|
+
message,
|
|
122
|
+
percentage: data.perc,
|
|
123
|
+
lastUpdate: Date.now(),
|
|
124
|
+
error: data.error,
|
|
125
|
+
streamInfo: prev.streamInfo, // Preserve track data through error states
|
|
126
|
+
}));
|
|
127
|
+
} else {
|
|
128
|
+
// Stream is online with valid metadata
|
|
129
|
+
// Merge new data with existing streamInfo to preserve source/tracks from initial fetch
|
|
130
|
+
// WebSocket updates may not include source array - only status updates
|
|
131
|
+
setState(prev => {
|
|
132
|
+
const mergedStreamInfo: MistStreamInfo = {
|
|
133
|
+
...prev.streamInfo, // Keep existing source/meta if present
|
|
134
|
+
...data, // Override with new data
|
|
135
|
+
// Explicitly preserve source if not in new data
|
|
136
|
+
source: data.source || prev.streamInfo?.source,
|
|
137
|
+
// Merge meta to preserve tracks
|
|
138
|
+
meta: {
|
|
139
|
+
...prev.streamInfo?.meta,
|
|
140
|
+
...data.meta,
|
|
141
|
+
// Preserve tracks if not in new data
|
|
142
|
+
tracks: data.meta?.tracks || prev.streamInfo?.meta?.tracks,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
status: 'ONLINE',
|
|
148
|
+
isOnline: true,
|
|
149
|
+
message: 'Stream is online',
|
|
150
|
+
lastUpdate: Date.now(),
|
|
151
|
+
streamInfo: mergedStreamInfo,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* HTTP polling fallback
|
|
159
|
+
* Adds metaeverywhere=1 and inclzero=1 like MistPlayer
|
|
160
|
+
*/
|
|
161
|
+
const pollHttp = useCallback(async () => {
|
|
162
|
+
if (!mountedRef.current || !enabled) return;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Build URL with MistPlayer-style params
|
|
166
|
+
const baseUrl = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js`;
|
|
167
|
+
const url = `${baseUrl}?metaeverywhere=1&inclzero=1`;
|
|
168
|
+
const response = await fetch(url, {
|
|
169
|
+
method: 'GET',
|
|
170
|
+
headers: { 'Accept': 'application/json' },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`HTTP ${response.status}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MistServer returns JSON with potential JSONP wrapper
|
|
178
|
+
let text = await response.text();
|
|
179
|
+
// Strip JSONP callback if present (use [\s\S]* instead of /s flag for ES5 compat)
|
|
180
|
+
const jsonpMatch = text.match(/^[^(]+\(([\s\S]*)\);?$/);
|
|
181
|
+
if (jsonpMatch) {
|
|
182
|
+
text = jsonpMatch[1];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = JSON.parse(text) as MistStreamInfo;
|
|
186
|
+
processStreamInfo(data);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!mountedRef.current) return;
|
|
189
|
+
|
|
190
|
+
setState(prev => ({
|
|
191
|
+
...prev,
|
|
192
|
+
status: 'ERROR',
|
|
193
|
+
isOnline: false,
|
|
194
|
+
message: error instanceof Error ? error.message : 'Connection failed',
|
|
195
|
+
lastUpdate: Date.now(),
|
|
196
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Schedule next poll
|
|
201
|
+
if (mountedRef.current && enabled && !useWebSocket) {
|
|
202
|
+
pollTimeoutRef.current = setTimeout(pollHttp, pollInterval);
|
|
203
|
+
}
|
|
204
|
+
}, [mistBaseUrl, streamName, enabled, useWebSocket, pollInterval, processStreamInfo]);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* WebSocket connection with MistPlayer-style 5-second timeout
|
|
208
|
+
*/
|
|
209
|
+
const connectWebSocket = useCallback(() => {
|
|
210
|
+
if (!mountedRef.current || !enabled || !useWebSocket) return;
|
|
211
|
+
|
|
212
|
+
// Clean up existing connection and timeout
|
|
213
|
+
if (wsTimeoutRef.current) {
|
|
214
|
+
clearTimeout(wsTimeoutRef.current);
|
|
215
|
+
wsTimeoutRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
if (wsRef.current) {
|
|
218
|
+
wsRef.current.close();
|
|
219
|
+
wsRef.current = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Convert http(s) to ws(s)
|
|
224
|
+
const wsUrl = mistBaseUrl
|
|
225
|
+
.replace(/^http:/, 'ws:')
|
|
226
|
+
.replace(/^https:/, 'wss:')
|
|
227
|
+
.replace(/\/$/, '');
|
|
228
|
+
|
|
229
|
+
// Build URL with MistPlayer-style params
|
|
230
|
+
const url = `${wsUrl}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`;
|
|
231
|
+
const ws = new WebSocket(url);
|
|
232
|
+
wsRef.current = ws;
|
|
233
|
+
|
|
234
|
+
// MistPlayer-style timeout: if no message within 5 seconds, fall back to HTTP
|
|
235
|
+
wsTimeoutRef.current = setTimeout(() => {
|
|
236
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
237
|
+
if (debug) {
|
|
238
|
+
console.debug('[useStreamState] WebSocket timeout (5s), falling back to HTTP polling');
|
|
239
|
+
}
|
|
240
|
+
ws.close();
|
|
241
|
+
pollHttp();
|
|
242
|
+
}
|
|
243
|
+
}, WS_TIMEOUT_MS);
|
|
244
|
+
|
|
245
|
+
ws.onopen = () => {
|
|
246
|
+
if (debug) {
|
|
247
|
+
console.debug('[useStreamState] WebSocket connected');
|
|
248
|
+
}
|
|
249
|
+
setSocketReady(true);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
ws.onmessage = (event) => {
|
|
253
|
+
// Clear timeout on first message
|
|
254
|
+
if (wsTimeoutRef.current) {
|
|
255
|
+
clearTimeout(wsTimeoutRef.current);
|
|
256
|
+
wsTimeoutRef.current = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const data = JSON.parse(event.data) as MistStreamInfo;
|
|
261
|
+
processStreamInfo(data);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.warn('[useStreamState] Failed to parse WebSocket message:', e);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
ws.onerror = (event) => {
|
|
268
|
+
console.warn('[useStreamState] WebSocket error, falling back to HTTP polling');
|
|
269
|
+
if (wsTimeoutRef.current) {
|
|
270
|
+
clearTimeout(wsTimeoutRef.current);
|
|
271
|
+
wsTimeoutRef.current = null;
|
|
272
|
+
}
|
|
273
|
+
ws.close();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
ws.onclose = () => {
|
|
277
|
+
wsRef.current = null;
|
|
278
|
+
setSocketReady(false);
|
|
279
|
+
|
|
280
|
+
if (!mountedRef.current || !enabled) return;
|
|
281
|
+
|
|
282
|
+
// Fallback to HTTP polling or reconnect
|
|
283
|
+
if (debug) {
|
|
284
|
+
console.debug('[useStreamState] WebSocket closed, starting HTTP polling');
|
|
285
|
+
}
|
|
286
|
+
pollHttp();
|
|
287
|
+
};
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.warn('[useStreamState] WebSocket connection failed:', error);
|
|
290
|
+
// Fallback to HTTP polling
|
|
291
|
+
pollHttp();
|
|
292
|
+
}
|
|
293
|
+
}, [mistBaseUrl, streamName, enabled, useWebSocket, debug, processStreamInfo, pollHttp]);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Manual refetch function
|
|
297
|
+
*/
|
|
298
|
+
const refetch = useCallback(() => {
|
|
299
|
+
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
|
|
300
|
+
// WebSocket will receive updates automatically
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
pollHttp();
|
|
304
|
+
}, [useWebSocket, pollHttp]);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Setup connection on mount and when options change
|
|
308
|
+
* Always do initial HTTP poll to get full stream info (including sources),
|
|
309
|
+
* then connect WebSocket for real-time status updates.
|
|
310
|
+
* MistServer WebSocket updates may not include source array.
|
|
311
|
+
*/
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
mountedRef.current = true;
|
|
314
|
+
|
|
315
|
+
if (!enabled || !mistBaseUrl || !streamName) {
|
|
316
|
+
setState(initialState);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Reset state when stream changes
|
|
321
|
+
setState({
|
|
322
|
+
...initialState,
|
|
323
|
+
message: 'Connecting...',
|
|
324
|
+
lastUpdate: Date.now(),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Always do initial HTTP poll to get full data (including sources)
|
|
328
|
+
// Then connect WebSocket for real-time updates
|
|
329
|
+
const initializeConnection = async () => {
|
|
330
|
+
// First HTTP poll to get complete stream info
|
|
331
|
+
await pollHttp();
|
|
332
|
+
|
|
333
|
+
// Then connect WebSocket for status updates (if enabled)
|
|
334
|
+
if (useWebSocket && mountedRef.current) {
|
|
335
|
+
connectWebSocket();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
initializeConnection();
|
|
340
|
+
|
|
341
|
+
return () => {
|
|
342
|
+
// Set mounted=false FIRST before any other cleanup
|
|
343
|
+
mountedRef.current = false;
|
|
344
|
+
if (debug) {
|
|
345
|
+
console.debug('[useStreamState] cleanup starting, mountedRef set to false');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Cleanup WebSocket timeout
|
|
349
|
+
if (wsTimeoutRef.current) {
|
|
350
|
+
clearTimeout(wsTimeoutRef.current);
|
|
351
|
+
wsTimeoutRef.current = null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Cleanup WebSocket - remove handlers BEFORE closing to prevent onclose callback
|
|
355
|
+
if (wsRef.current) {
|
|
356
|
+
// Detach handlers first to prevent onclose from triggering pollHttp
|
|
357
|
+
wsRef.current.onclose = null;
|
|
358
|
+
wsRef.current.onerror = null;
|
|
359
|
+
wsRef.current.onmessage = null;
|
|
360
|
+
wsRef.current.onopen = null;
|
|
361
|
+
wsRef.current.close();
|
|
362
|
+
wsRef.current = null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Cleanup polling timeout
|
|
366
|
+
if (pollTimeoutRef.current) {
|
|
367
|
+
clearTimeout(pollTimeoutRef.current);
|
|
368
|
+
pollTimeoutRef.current = null;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}, [enabled, mistBaseUrl, streamName, useWebSocket, debug, connectWebSocket, pollHttp]);
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
...state,
|
|
375
|
+
refetch,
|
|
376
|
+
socketRef: wsRef,
|
|
377
|
+
socketReady,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export default useStreamState;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { TelemetryReporter, type TelemetryOptions, type PlaybackQuality, type ContentType } from '@livepeer-frameworks/player-core';
|
|
3
|
+
|
|
4
|
+
export interface UseTelemetryOptions extends TelemetryOptions {
|
|
5
|
+
/** Video element to monitor */
|
|
6
|
+
videoElement: HTMLVideoElement | null;
|
|
7
|
+
/** Content ID being played */
|
|
8
|
+
contentId: string;
|
|
9
|
+
/** Content type */
|
|
10
|
+
contentType: ContentType;
|
|
11
|
+
/** Player type name */
|
|
12
|
+
playerType: string;
|
|
13
|
+
/** Protocol being used */
|
|
14
|
+
protocol: string;
|
|
15
|
+
/** Optional quality getter function */
|
|
16
|
+
getQuality?: () => PlaybackQuality | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook to send telemetry data to a server
|
|
21
|
+
*
|
|
22
|
+
* Reports playback metrics at configurable intervals:
|
|
23
|
+
* - Current time and duration
|
|
24
|
+
* - Buffer health
|
|
25
|
+
* - Stall count and duration
|
|
26
|
+
* - Quality score and bitrate
|
|
27
|
+
* - Frame decode/drop stats
|
|
28
|
+
* - Errors encountered
|
|
29
|
+
*
|
|
30
|
+
* Uses navigator.sendBeacon() for reliable reporting on page unload.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* const { sessionId, recordError } = useTelemetry({
|
|
35
|
+
* enabled: true,
|
|
36
|
+
* endpoint: '/api/telemetry',
|
|
37
|
+
* interval: 5000,
|
|
38
|
+
* videoElement,
|
|
39
|
+
* contentId: 'my-stream',
|
|
40
|
+
* contentType: 'live',
|
|
41
|
+
* playerType: 'hlsjs',
|
|
42
|
+
* protocol: 'HLS',
|
|
43
|
+
* getQuality: () => qualityMonitor.getCurrentQuality(),
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* // Record custom error
|
|
47
|
+
* recordError('NETWORK_ERROR', 'Connection lost');
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function useTelemetry(options: UseTelemetryOptions) {
|
|
51
|
+
const {
|
|
52
|
+
enabled,
|
|
53
|
+
endpoint,
|
|
54
|
+
interval,
|
|
55
|
+
authToken,
|
|
56
|
+
batchSize,
|
|
57
|
+
videoElement,
|
|
58
|
+
contentId,
|
|
59
|
+
contentType,
|
|
60
|
+
playerType,
|
|
61
|
+
protocol,
|
|
62
|
+
getQuality,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
const reporterRef = useRef<TelemetryReporter | null>(null);
|
|
66
|
+
|
|
67
|
+
// Create reporter instance
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!enabled || !endpoint || !contentId) {
|
|
70
|
+
reporterRef.current?.stop();
|
|
71
|
+
reporterRef.current = null;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
reporterRef.current = new TelemetryReporter({
|
|
76
|
+
endpoint,
|
|
77
|
+
authToken,
|
|
78
|
+
interval,
|
|
79
|
+
batchSize,
|
|
80
|
+
contentId,
|
|
81
|
+
contentType,
|
|
82
|
+
playerType,
|
|
83
|
+
protocol,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
reporterRef.current?.stop();
|
|
88
|
+
reporterRef.current = null;
|
|
89
|
+
};
|
|
90
|
+
}, [enabled, endpoint, authToken, interval, batchSize, contentId, contentType, playerType, protocol]);
|
|
91
|
+
|
|
92
|
+
// Start/stop reporting when video element changes
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!enabled || !videoElement || !reporterRef.current) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reporterRef.current.start(videoElement, getQuality);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
reporterRef.current?.stop();
|
|
102
|
+
};
|
|
103
|
+
}, [enabled, videoElement, getQuality]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Record a custom error
|
|
107
|
+
*/
|
|
108
|
+
const recordError = useCallback((code: string, message: string) => {
|
|
109
|
+
reporterRef.current?.recordError(code, message);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get current session ID
|
|
114
|
+
*/
|
|
115
|
+
const getSessionId = useCallback((): string | null => {
|
|
116
|
+
return reporterRef.current?.getSessionId() ?? null;
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if telemetry is active
|
|
121
|
+
*/
|
|
122
|
+
const isActive = useCallback((): boolean => {
|
|
123
|
+
return reporterRef.current?.isActive() ?? false;
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
/** Session ID for this playback session */
|
|
128
|
+
sessionId: reporterRef.current?.getSessionId() ?? null,
|
|
129
|
+
/** Record a custom error */
|
|
130
|
+
recordError,
|
|
131
|
+
/** Get current session ID */
|
|
132
|
+
getSessionId,
|
|
133
|
+
/** Check if telemetry is active */
|
|
134
|
+
isActive,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default useTelemetry;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useState, useRef } from 'react';
|
|
2
|
+
import type { ContentType } from '@livepeer-frameworks/player-core';
|
|
3
|
+
import type { ContentEndpoints } from '../types';
|
|
4
|
+
|
|
5
|
+
const MAX_RETRIES = 3;
|
|
6
|
+
const INITIAL_DELAY_MS = 500;
|
|
7
|
+
|
|
8
|
+
interface Params {
|
|
9
|
+
gatewayUrl: string;
|
|
10
|
+
contentType: ContentType;
|
|
11
|
+
contentId: string;
|
|
12
|
+
authToken?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function fetchWithRetry(
|
|
16
|
+
url: string,
|
|
17
|
+
options: RequestInit,
|
|
18
|
+
maxRetries: number = MAX_RETRIES,
|
|
19
|
+
initialDelay: number = INITIAL_DELAY_MS
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
let lastError: Error | null = null;
|
|
22
|
+
|
|
23
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(url, options);
|
|
26
|
+
return response;
|
|
27
|
+
} catch (e) {
|
|
28
|
+
lastError = e instanceof Error ? e : new Error('Fetch failed');
|
|
29
|
+
|
|
30
|
+
// Don't retry on abort
|
|
31
|
+
if (options.signal?.aborted) {
|
|
32
|
+
throw lastError;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Wait before retrying (exponential backoff)
|
|
36
|
+
if (attempt < maxRetries - 1) {
|
|
37
|
+
const delay = initialDelay * Math.pow(2, attempt);
|
|
38
|
+
console.warn(`[useViewerEndpoints] Retry ${attempt + 1}/${maxRetries - 1} after ${delay}ms`);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw lastError ?? new Error('Gateway unreachable after retries');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useViewerEndpoints({ gatewayUrl, contentType, contentId, authToken }: Params) {
|
|
48
|
+
const [endpoints, setEndpoints] = useState<ContentEndpoints | null>(null);
|
|
49
|
+
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!gatewayUrl || !contentType || !contentId) return;
|
|
55
|
+
setStatus('loading');
|
|
56
|
+
setError(null);
|
|
57
|
+
abortRef.current?.abort();
|
|
58
|
+
const ac = new AbortController();
|
|
59
|
+
abortRef.current = ac;
|
|
60
|
+
|
|
61
|
+
(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const graphqlEndpoint = gatewayUrl.replace(/\/$/, '');
|
|
64
|
+
const query = `
|
|
65
|
+
query ResolveViewer($contentType: String!, $contentId: String!) {
|
|
66
|
+
resolveViewerEndpoint(contentType: $contentType, contentId: $contentId) {
|
|
67
|
+
primary { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
68
|
+
fallbacks { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
69
|
+
metadata { contentType contentId title description durationSeconds status isLive viewers recordingSizeBytes clipSource createdAt }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
const res = await fetchWithRetry(graphqlEndpoint, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({ query, variables: { contentType, contentId } }),
|
|
80
|
+
signal: ac.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) throw new Error(`Gateway GQL error ${res.status}`);
|
|
83
|
+
const payload = await res.json();
|
|
84
|
+
if (payload.errors?.length) throw new Error(payload.errors[0]?.message || 'GraphQL error');
|
|
85
|
+
const resp = payload.data?.resolveViewerEndpoint;
|
|
86
|
+
const primary = resp?.primary;
|
|
87
|
+
const fallbacks = Array.isArray(resp?.fallbacks) ? resp.fallbacks : [];
|
|
88
|
+
if (!primary) throw new Error('No endpoints');
|
|
89
|
+
|
|
90
|
+
// Parse outputs JSON string (GraphQL returns JSON scalar as string)
|
|
91
|
+
if (primary.outputs && typeof primary.outputs === 'string') {
|
|
92
|
+
try { primary.outputs = JSON.parse(primary.outputs); } catch {}
|
|
93
|
+
}
|
|
94
|
+
if (fallbacks) {
|
|
95
|
+
fallbacks.forEach((fb: any) => {
|
|
96
|
+
if (fb.outputs && typeof fb.outputs === 'string') {
|
|
97
|
+
try { fb.outputs = JSON.parse(fb.outputs); } catch {}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setEndpoints({ primary, fallbacks, metadata: resp?.metadata });
|
|
103
|
+
setStatus('ready');
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (ac.signal.aborted) return;
|
|
106
|
+
const message = e instanceof Error ? e.message : 'Unknown gateway error';
|
|
107
|
+
console.error('[useViewerEndpoints] Gateway resolution failed:', message);
|
|
108
|
+
setError(message);
|
|
109
|
+
setStatus('error');
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
return () => ac.abort();
|
|
114
|
+
}, [gatewayUrl, contentType, contentId, authToken]);
|
|
115
|
+
|
|
116
|
+
return { endpoints, status, error };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default useViewerEndpoints;
|
|
120
|
+
|