@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.
Files changed (88) hide show
  1. package/dist/cjs/index.js +2 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +2 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/types/components/DevModePanel.d.ts +47 -0
  6. package/dist/types/components/DvdLogo.d.ts +4 -0
  7. package/dist/types/components/Icons.d.ts +33 -0
  8. package/dist/types/components/IdleScreen.d.ts +16 -0
  9. package/dist/types/components/LoadingScreen.d.ts +6 -0
  10. package/dist/types/components/LogoOverlay.d.ts +11 -0
  11. package/dist/types/components/Player.d.ts +11 -0
  12. package/dist/types/components/PlayerControls.d.ts +60 -0
  13. package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
  14. package/dist/types/components/SeekBar.d.ts +33 -0
  15. package/dist/types/components/SkipIndicator.d.ts +14 -0
  16. package/dist/types/components/SpeedIndicator.d.ts +12 -0
  17. package/dist/types/components/StatsPanel.d.ts +31 -0
  18. package/dist/types/components/StreamStateOverlay.d.ts +24 -0
  19. package/dist/types/components/SubtitleRenderer.d.ts +69 -0
  20. package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
  21. package/dist/types/components/TitleOverlay.d.ts +13 -0
  22. package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
  23. package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
  24. package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
  25. package/dist/types/components/players/MistPlayer.d.ts +20 -0
  26. package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
  27. package/dist/types/components/players/NativePlayer.d.ts +19 -0
  28. package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
  29. package/dist/types/context/PlayerContext.d.ts +40 -0
  30. package/dist/types/context/index.d.ts +5 -0
  31. package/dist/types/hooks/useMetaTrack.d.ts +54 -0
  32. package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
  33. package/dist/types/hooks/usePlayerController.d.ts +163 -0
  34. package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
  35. package/dist/types/hooks/useStreamState.d.ts +27 -0
  36. package/dist/types/hooks/useTelemetry.d.ts +57 -0
  37. package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
  38. package/dist/types/index.d.ts +33 -0
  39. package/dist/types/types.d.ts +94 -0
  40. package/dist/types/ui/badge.d.ts +9 -0
  41. package/dist/types/ui/button.d.ts +11 -0
  42. package/dist/types/ui/context-menu.d.ts +27 -0
  43. package/dist/types/ui/select.d.ts +10 -0
  44. package/dist/types/ui/slider.d.ts +13 -0
  45. package/package.json +71 -0
  46. package/src/assets/logomark.svg +56 -0
  47. package/src/components/DevModePanel.tsx +822 -0
  48. package/src/components/DvdLogo.tsx +201 -0
  49. package/src/components/Icons.tsx +282 -0
  50. package/src/components/IdleScreen.tsx +664 -0
  51. package/src/components/LoadingScreen.tsx +710 -0
  52. package/src/components/LogoOverlay.tsx +75 -0
  53. package/src/components/Player.tsx +419 -0
  54. package/src/components/PlayerControls.tsx +820 -0
  55. package/src/components/PlayerErrorBoundary.tsx +70 -0
  56. package/src/components/SeekBar.tsx +291 -0
  57. package/src/components/SkipIndicator.tsx +113 -0
  58. package/src/components/SpeedIndicator.tsx +57 -0
  59. package/src/components/StatsPanel.tsx +150 -0
  60. package/src/components/StreamStateOverlay.tsx +200 -0
  61. package/src/components/SubtitleRenderer.tsx +235 -0
  62. package/src/components/ThumbnailOverlay.tsx +90 -0
  63. package/src/components/TitleOverlay.tsx +48 -0
  64. package/src/components/players/DashJsPlayer.tsx +56 -0
  65. package/src/components/players/HlsJsPlayer.tsx +56 -0
  66. package/src/components/players/MewsWsPlayer/index.tsx +56 -0
  67. package/src/components/players/MistPlayer.tsx +60 -0
  68. package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
  69. package/src/components/players/NativePlayer.tsx +58 -0
  70. package/src/components/players/VideoJsPlayer.tsx +56 -0
  71. package/src/context/PlayerContext.tsx +71 -0
  72. package/src/context/index.ts +11 -0
  73. package/src/global.d.ts +4 -0
  74. package/src/hooks/useMetaTrack.ts +187 -0
  75. package/src/hooks/usePlaybackQuality.ts +126 -0
  76. package/src/hooks/usePlayerController.ts +525 -0
  77. package/src/hooks/usePlayerSelection.ts +117 -0
  78. package/src/hooks/useStreamState.ts +381 -0
  79. package/src/hooks/useTelemetry.ts +138 -0
  80. package/src/hooks/useViewerEndpoints.ts +120 -0
  81. package/src/index.tsx +75 -0
  82. package/src/player.css +2 -0
  83. package/src/types.ts +135 -0
  84. package/src/ui/badge.tsx +27 -0
  85. package/src/ui/button.tsx +47 -0
  86. package/src/ui/context-menu.tsx +193 -0
  87. package/src/ui/select.tsx +105 -0
  88. 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
+