@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,664 @@
1
+ import React, { useRef, useEffect, useState } from "react";
2
+ import DvdLogo from "./DvdLogo";
3
+ import logomarkAsset from "../assets/logomark.svg";
4
+ import type { StreamStatus } from "../types";
5
+
6
+ // ============================================================================
7
+ // AnimatedBubble Component
8
+ // ============================================================================
9
+
10
+ interface AnimatedBubbleProps {
11
+ index: number;
12
+ }
13
+
14
+ const AnimatedBubble: React.FC<AnimatedBubbleProps> = ({ index }) => {
15
+ const [position, setPosition] = useState({ top: 0, left: 0 });
16
+ const [size, setSize] = useState(40);
17
+ const [opacity, setOpacity] = useState(0);
18
+
19
+ const getRandomPosition = () => ({
20
+ top: Math.random() * 80 + 10,
21
+ left: Math.random() * 80 + 10,
22
+ });
23
+
24
+ const getRandomSize = () => Math.random() * 60 + 30;
25
+
26
+ useEffect(() => {
27
+ setPosition(getRandomPosition());
28
+ setSize(getRandomSize());
29
+
30
+ const animationCycle = () => {
31
+ setOpacity(0.15);
32
+ setTimeout(() => {
33
+ setOpacity(0);
34
+ setTimeout(() => {
35
+ setPosition(getRandomPosition());
36
+ setSize(getRandomSize());
37
+ setTimeout(() => {
38
+ animationCycle();
39
+ }, 200);
40
+ }, 1500);
41
+ }, 4000 + Math.random() * 3000);
42
+ };
43
+
44
+ const timeout = setTimeout(animationCycle, index * 500);
45
+ return () => clearTimeout(timeout);
46
+ }, [index]);
47
+
48
+ const bubbleColors = [
49
+ "rgba(122, 162, 247, 0.2)",
50
+ "rgba(187, 154, 247, 0.2)",
51
+ "rgba(158, 206, 106, 0.2)",
52
+ "rgba(115, 218, 202, 0.2)",
53
+ "rgba(125, 207, 255, 0.2)",
54
+ "rgba(247, 118, 142, 0.2)",
55
+ "rgba(224, 175, 104, 0.2)",
56
+ "rgba(42, 195, 222, 0.2)",
57
+ ];
58
+
59
+ return (
60
+ <div
61
+ style={{
62
+ position: "absolute",
63
+ top: `${position.top}%`,
64
+ left: `${position.left}%`,
65
+ width: `${size}px`,
66
+ height: `${size}px`,
67
+ borderRadius: "50%",
68
+ background: bubbleColors[index % bubbleColors.length],
69
+ opacity,
70
+ transition: "opacity 1s ease-in-out",
71
+ pointerEvents: "none",
72
+ userSelect: "none",
73
+ } as React.CSSProperties}
74
+ />
75
+ );
76
+ };
77
+
78
+ // ============================================================================
79
+ // CenterLogo Component
80
+ // ============================================================================
81
+
82
+ interface CenterLogoProps {
83
+ containerRef: React.RefObject<HTMLDivElement>;
84
+ scale?: number;
85
+ onHitmarker?: (e: { clientX: number; clientY: number }) => void;
86
+ }
87
+
88
+ const CenterLogo: React.FC<CenterLogoProps> = ({ containerRef, scale = 0.2, onHitmarker }) => {
89
+ const [logoSize, setLogoSize] = useState(100);
90
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
91
+ const [isHovered, setIsHovered] = useState(false);
92
+
93
+ useEffect(() => {
94
+ if (containerRef.current) {
95
+ const containerWidth = containerRef.current.clientWidth;
96
+ const containerHeight = containerRef.current.clientHeight;
97
+ const minDimension = Math.min(containerWidth, containerHeight);
98
+ setLogoSize(minDimension * scale);
99
+ }
100
+ }, [containerRef, scale]);
101
+
102
+ const handleLogoClick = (e: React.MouseEvent) => {
103
+ e.stopPropagation();
104
+ if (onHitmarker) {
105
+ onHitmarker({ clientX: e.clientX, clientY: e.clientY });
106
+ }
107
+ };
108
+
109
+ const handleMouseMove = (e: MouseEvent) => {
110
+ if (!containerRef.current) return;
111
+
112
+ const rect = containerRef.current.getBoundingClientRect();
113
+ const centerX = rect.left + rect.width / 2;
114
+ const centerY = rect.top + rect.height / 2;
115
+ const deltaX = e.clientX - centerX;
116
+ const deltaY = e.clientY - centerY;
117
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
118
+
119
+ const maxDistance = logoSize * 1.5;
120
+ if (distance < maxDistance && distance > 0) {
121
+ const pushStrength = (maxDistance - distance) / maxDistance;
122
+ const pushDistance = 50 * pushStrength;
123
+ const pushX = -(deltaX / distance) * pushDistance;
124
+ const pushY = -(deltaY / distance) * pushDistance;
125
+ setOffset({ x: pushX, y: pushY });
126
+ setIsHovered(true);
127
+ } else {
128
+ setOffset({ x: 0, y: 0 });
129
+ setIsHovered(false);
130
+ }
131
+ };
132
+
133
+ const handleMouseLeave = () => {
134
+ setOffset({ x: 0, y: 0 });
135
+ setIsHovered(false);
136
+ };
137
+
138
+ useEffect(() => {
139
+ if (containerRef.current) {
140
+ const container = containerRef.current;
141
+ container.addEventListener('mousemove', handleMouseMove);
142
+ container.addEventListener('mouseleave', handleMouseLeave);
143
+ return () => {
144
+ container.removeEventListener('mousemove', handleMouseMove);
145
+ container.removeEventListener('mouseleave', handleMouseLeave);
146
+ };
147
+ }
148
+ }, [logoSize, containerRef]);
149
+
150
+ return (
151
+ <div
152
+ style={{
153
+ position: "absolute",
154
+ top: "50%",
155
+ left: "50%",
156
+ transform: `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px)`,
157
+ display: "flex",
158
+ alignItems: "center",
159
+ justifyContent: "center",
160
+ zIndex: 10,
161
+ transition: "transform 0.3s ease-out",
162
+ userSelect: "none",
163
+ }}
164
+ >
165
+ <div
166
+ style={{
167
+ position: "absolute",
168
+ width: `${logoSize * 1.4}px`,
169
+ height: `${logoSize * 1.4}px`,
170
+ borderRadius: "50%",
171
+ background: "rgba(122, 162, 247, 0.15)",
172
+ animation: isHovered ? "logoPulse 1s ease-in-out infinite" : "logoPulse 3s ease-in-out infinite",
173
+ transform: isHovered ? "scale(1.2)" : "scale(1)",
174
+ transition: "transform 0.3s ease-out",
175
+ pointerEvents: "none",
176
+ }}
177
+ />
178
+ <img
179
+ src={logomarkAsset}
180
+ alt="FrameWorks Logo"
181
+ onClick={handleLogoClick}
182
+ style={{
183
+ width: `${logoSize}px`,
184
+ height: `${logoSize}px`,
185
+ position: "relative",
186
+ zIndex: 1,
187
+ filter: isHovered
188
+ ? "drop-shadow(0 6px 12px rgba(36, 40, 59, 0.4)) brightness(1.1)"
189
+ : "drop-shadow(0 4px 8px rgba(36, 40, 59, 0.3))",
190
+ transform: isHovered ? "scale(1.1)" : "scale(1)",
191
+ transition: "all 0.3s ease-out",
192
+ cursor: isHovered ? "pointer" : "default",
193
+ userSelect: "none",
194
+ WebkitUserDrag: "none",
195
+ } as React.CSSProperties}
196
+ />
197
+ </div>
198
+ );
199
+ };
200
+
201
+ // ============================================================================
202
+ // Status Overlay Component (shows on top of the fancy background)
203
+ // ============================================================================
204
+
205
+ interface StatusOverlayProps {
206
+ status?: StreamStatus;
207
+ message: string;
208
+ percentage?: number;
209
+ error?: string;
210
+ onRetry?: () => void;
211
+ }
212
+
213
+ function getStatusLabel(status?: StreamStatus): string {
214
+ switch (status) {
215
+ case 'ONLINE': return 'ONLINE';
216
+ case 'OFFLINE': return 'OFFLINE';
217
+ case 'INITIALIZING': return 'STARTING';
218
+ case 'BOOTING': return 'STARTING';
219
+ case 'WAITING_FOR_DATA': return 'WAITING';
220
+ case 'SHUTTING_DOWN': return 'ENDING';
221
+ case 'ERROR': return 'ERROR';
222
+ case 'INVALID': return 'ERROR';
223
+ default: return 'CONNECTING';
224
+ }
225
+ }
226
+
227
+ function StatusIcon({ status }: { status?: StreamStatus }) {
228
+ const iconClass = "w-5 h-5";
229
+
230
+ // Spinner for loading states
231
+ if (status === 'INITIALIZING' || status === 'BOOTING' || status === 'WAITING_FOR_DATA' || !status) {
232
+ return (
233
+ <svg className={`${iconClass} animate-spin`} fill="none" viewBox="0 0 24 24" style={{ color: 'hsl(var(--tn-yellow, 40 95% 64%))' }}>
234
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
235
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
236
+ </svg>
237
+ );
238
+ }
239
+
240
+ // Offline icon
241
+ if (status === 'OFFLINE') {
242
+ return (
243
+ <svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'hsl(var(--tn-red, 348 100% 72%))' }}>
244
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
245
+ </svg>
246
+ );
247
+ }
248
+
249
+ // Error icon
250
+ if (status === 'ERROR' || status === 'INVALID') {
251
+ return (
252
+ <svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'hsl(var(--tn-red, 348 100% 72%))' }}>
253
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
254
+ </svg>
255
+ );
256
+ }
257
+
258
+ // Default spinner
259
+ return (
260
+ <svg className={`${iconClass} animate-spin`} fill="none" viewBox="0 0 24 24" style={{ color: 'hsl(var(--tn-cyan, 193 100% 75%))' }}>
261
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
262
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
263
+ </svg>
264
+ );
265
+ }
266
+
267
+ const StatusOverlay: React.FC<StatusOverlayProps> = ({ status, message, percentage, error, onRetry }) => {
268
+ const showRetry = (status === 'ERROR' || status === 'INVALID') && onRetry;
269
+ const showProgress = status === 'INITIALIZING' && percentage !== undefined;
270
+ const displayMessage = error || message;
271
+
272
+ return (
273
+ <div
274
+ style={{
275
+ position: "absolute",
276
+ bottom: "16px",
277
+ left: "50%",
278
+ transform: "translateX(-50%)",
279
+ zIndex: 20,
280
+ display: "flex",
281
+ flexDirection: "column",
282
+ alignItems: "center",
283
+ gap: "8px",
284
+ maxWidth: "280px",
285
+ textAlign: "center",
286
+ }}
287
+ >
288
+ {/* Subtle status indicator - just icon + message */}
289
+ <div
290
+ style={{
291
+ display: "flex",
292
+ alignItems: "center",
293
+ gap: "8px",
294
+ color: "#787c99",
295
+ fontSize: "13px",
296
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
297
+ }}
298
+ >
299
+ <StatusIcon status={status} />
300
+ <span>{displayMessage}</span>
301
+ </div>
302
+
303
+ {/* Progress bar */}
304
+ {showProgress && (
305
+ <div
306
+ style={{
307
+ width: "160px",
308
+ height: "4px",
309
+ background: "rgba(65, 72, 104, 0.4)",
310
+ borderRadius: "2px",
311
+ overflow: "hidden",
312
+ }}
313
+ >
314
+ <div
315
+ style={{
316
+ width: `${Math.min(100, percentage)}%`,
317
+ height: "100%",
318
+ background: "hsl(var(--tn-cyan, 193 100% 75%))",
319
+ transition: "width 0.3s ease-out",
320
+ }}
321
+ />
322
+ </div>
323
+ )}
324
+
325
+ {/* Retry button - only for errors */}
326
+ {showRetry && (
327
+ <button
328
+ type="button"
329
+ onClick={onRetry}
330
+ style={{
331
+ padding: "6px 16px",
332
+ background: "transparent",
333
+ border: "1px solid rgba(122, 162, 247, 0.4)",
334
+ borderRadius: "4px",
335
+ color: "#7aa2f7",
336
+ fontSize: "11px",
337
+ fontWeight: 500,
338
+ cursor: "pointer",
339
+ transition: "all 0.2s ease",
340
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
341
+ }}
342
+ onMouseEnter={(e) => {
343
+ e.currentTarget.style.background = "rgba(122, 162, 247, 0.1)";
344
+ }}
345
+ onMouseLeave={(e) => {
346
+ e.currentTarget.style.background = "transparent";
347
+ }}
348
+ >
349
+ Retry
350
+ </button>
351
+ )}
352
+ </div>
353
+ );
354
+ };
355
+
356
+ // ============================================================================
357
+ // Hitmarker System
358
+ // ============================================================================
359
+
360
+ interface Hitmarker {
361
+ id: number;
362
+ x: number;
363
+ y: number;
364
+ }
365
+
366
+ const playHitmarkerSound = () => {
367
+ try {
368
+ // Embedded hitmarker sound as base64 data URL
369
+ const hitmarkerDataUrl = 'data:audio/mpeg;base64,SUQzBAAAAAAANFRDT04AAAAHAAADT3RoZXIAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAA' +
370
+ 'AAD/+1QAAAAAAAAAAAAAAAAAAAAA' +
371
+ 'AAAAAAAAAAAAAAAAAAAAAABJbmZvAAAADwAAAAYAAAnAADs7Ozs7Ozs7Ozs7Ozs7OztiYmJiYmJiYmJi' +
372
+ 'YmJiYmJiYomJiYmJiYmJiYmJiYmJiYmxsbGxsbGxsbGxsbGxsbGxsdjY2NjY2NjY2NjY2NjY2NjY////' +
373
+ '/////////////////wAAAABMYXZjNTcuMTAAAAAAAAAAAAAAAAAkAkAAAAAAAAAJwOuMZun/+5RkAA8S' +
374
+ '/F23AGAaAi0AF0AAAAAInXsEAIRXyQ8D4OQgjEhE3cO7ujuHF0XCOu4G7xKbi3Funu7u7p9dw7unu7u7' +
375
+ 'p7u7u6fXcW7om7u7uiU3dxdT67u7p7uHdxelN3cW6fXcW7oXXd3eJTd3d0+u4t3iXdw4up70W4uiPruL' +
376
+ 'DzMw8Pz79Y99JfkyfPv5/h9uTJoy79Y99Y97q3vyZPJk0ZfrL6x73Vn+J35dKKS/STQyQ8CAiCPNuRAO' +
377
+ 'OqquAx+fzJeBKDAsgAMBuWcBsHKhjJTcCwIALyAvABbI0ZIcCmP8jHJe8gZAdVRp2TpnU/kUXV4iQuBA' +
378
+ 'AkAQgisLPvwQ2Jz7wIkIpQ8QOl/KFy75w+2HpTFnRqXLQo0fzlSYRe5Ce9yZMEzRM4xesu95Mo8QQsoM' +
379
+ 'H4gLg+fJqkmY3GZJE2kwGfMECJiAdIttoEa2yotfC7jsS2mjKgbzAfEMeiwZpGSUFCQwPKQiWXh0TnkN' +
380
+ 'or5SmrKvwHlX2zFxKxPCzRL/+5RkIwADvUxLawwb0GdF6Y1hJlgNNJk+DSRwyQwI6AD2JCiBmhaff0dz' +
381
+ 'CEBjgFABAcDNFc3YAEV4hQn0L/QvQnevom+n13eIjoTvABLrHg/L9RzdWXYonHbbbE2K0pX+gkL2g56R' +
382
+ 'iwrbuWwhoABzQoMKOAIGAfE4UKk6BhSIJpECBq0CEYmZKYIiAJt72H24dNou7y/Ee7a/3v+MgySemSTY' +
383
+ 'mnBAFwIAAGfCJ8/D9YfkwQEBcP38uA1d/EB1T5dZKEsgnuhwZirY5fIMRMdRn7U4OcN2m5NWeYdcPBwX' +
384
+ 'DBOsJF1DBYks62pAURqz1hGoGHH/QIoRC80tYAJ8g4f3MPD51sywAbhAn/X9P/75tvZww3gZ3pYPDx/+' +
385
+ 'ACO/7//ffHj/D/AAfATC4DYGFA3MRABo0lqWjBOl2yAda1C1BdhduXgm8FGnAQB/lDiEi6j9qw9EHigI' +
386
+ 'IOLB6F1eIPd+T6Agc4//lMo6+k3tdttJY2gArU7cN07m2FLSm4gCjyz/+5RECwACwSRZawkdLFGi2mVh' +
387
+ '5h4LfFdPVPGACViTavaeMAAV0UkkEsDhxxJwqF04on002mZah8w9+5ItfSAoyZa1dchnPpLmAEKrVMRA' +
388
+ '//sD8w0WsB4xiw4JqaZMB45TdpIuXXUPf8Bpa35p/jQIAOAuZkmUeJoM5W6L2gqqO6rTuHjUTDnhy4Qi' +
389
+ 'K348vtFysOizShoHbBpsPRYcSINCbiN4XOLPPAgq3dW2Ga7SlyiKXBV7W1RQl5BiiVGkwayJfEnPxgXk' +
390
+ 'QeZxxzyhTuLO2XFUDDstoc6CkM1J8QZAjUN3bM8580cRygNfmPAELGjIH0Z/0A+8csyH/4eHvgAf8APg' +
391
+ 'ABmZ98AARAADP////Dw8PHEmIpgGttpJQJsmZjq5nPQ8j5VqWW1evqdjP182PA6tHJZgkC5iSbEQkyJS' +
392
+ 'z/BvP3eucLKN0+Wiza4feKKFBqiAEBAMXyYni5NZc16CDl/QY9j6BAcWSmQYcIcoMHYoQNBiIBgIBUAz' +
393
+ 'QUMSnjj/+5RkCwADsFLffjEAAjrJe63JHACO6WtlnPMACKaCK1uMMADU5dI6JhW2cam98UlRmY4ihyKF' +
394
+ 'rNsgpZd5PYgBALnYofKEt82De0GbW1DLibvFDK+bSeOm8qKdqUFZ7uiK8XMPHyqm3pTxUvcunUfxXEo9' +
395
+ 'RNe5b/8vfCD3kzDN7vTtHyaIcntVDAYBAUBAAAAQBI2vguYNsHWm5AR3mZtZib8WAHFvz2Kf9//iYvlR' +
396
+ 'B/+n///////////+UH7XoIDMoJAEAMtj8JshJPRwklVqNSpYnalfE+VzNCAISCoxVHEpIo/WrTiMvP7V' +
397
+ 'TujOPnOglLbMLN/pq/d2Y4lRJIkSnPlUSJEjSKJqM41d88zWtMzP+fCOORmc9NeM+f1nnO//efM52/fG' +
398
+ '/ef385+5u+u1bRJkwU8FAkEItZpkRYeQYcAgZTEYlaZa2yROLeC0qdX73rZJJ/d2f6v6Or0u/+5FBYcn' +
399
+ 'g0MlCiQTR9GUU5LScmSuSlH00IWqXA6jlw4BEcD/+5REEAAi3RtU+eYbGF1E+lk9g0YJzLUgh7BlQVGT' +
400
+ 'ZJD0jKhhTNVilqrMzFRK+x/szcMKBWKep4NP1A0DR6RESkTp5Z1Q9Y8REgqMg1DpUBPleeqlRQcerBpM' +
401
+ 'jiURHVD4XwAALhAgbxxlxYD5OFkG8oQRPB2EpsxSCNVlgcYUqoAyiVJmaARlkwplICfPoUy/zWEzM2pc' +
402
+ 'NYzAQNJDSniEYecSEqxFEzQqEvUFGnvzwUfcRlpZ9T2LCR5QdDQDDhKICAjpJCagpRo9UQRPClZZlg6E' +
403
+ 'p9DMTkTl+okuhRIVIzAQEf9L+Mx/DUjqmqN6kX7M36lS4zgLyJV3iV6j3xF8kJduJawVw1nndAlBaLLg' +
404
+ 'JupwsTcLkxmJgFLgSzoCmHjSNGSqkGPCpnNqTXIwolf6qlVWN+q/su37HzgrES1pWGg3KnWh0FXCVniJ' +
405
+ '9K5b4iCrpLEuIcFTqwkVLFiqgaDqCCSMVWqxBAVCFOLVrVahm2ahUThUKJnmFCw15hD0Qhb/+5REEAhC' +
406
+ 'YSRCSQEb4FOGaBUMI6JIRYC0QIB2SQsgGpgwDghgIlS6FU8VBXDoiBp5Y9gtkVnhEhYBdJFQ7kQ3w1yp' +
407
+ '0NB2CoNPEttZ1/aeDUAAA26FEghWgEKNVAVWkFAQEmMK2Uwk/qI0hqUb/4epVIZH1ai6szf6kzH1f2ar' +
408
+ 'xYGS9FcOsN5UlJLQt///+oo0FRDTUQ0FBQr9f5LxXP+mEUfk0AIrf/5GRmQ0//mX//ZbLP5b5GrWSz+W' +
409
+ 'SkZMrWyyyy2GRqyggVRyMv////////st//sn/yyVDI1l8mVgoYGDCOqiqIQBxmvxWCggTpZZZD//aWfy' +
410
+ 'yWf/y/7KGDA0ssBggTof9k/+WS/8slQyMp/5Nfln8WAqGcUbULCrKxT9ISF+kKsxQWpMQU1FMy4xMDCq' +
411
+ 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' +
412
+ 'qqqqqqqqqqqqqqqqqqqqqqqqqqo=';
413
+
414
+ const audio = new Audio(hitmarkerDataUrl);
415
+ audio.volume = 0.3;
416
+ audio.play().catch(() => createSyntheticHitmarkerSound());
417
+ } catch {
418
+ createSyntheticHitmarkerSound();
419
+ }
420
+ };
421
+
422
+ const createSyntheticHitmarkerSound = () => {
423
+ try {
424
+ const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
425
+ const oscillator1 = audioContext.createOscillator();
426
+ const oscillator2 = audioContext.createOscillator();
427
+ const gainNode1 = audioContext.createGain();
428
+ const gainNode2 = audioContext.createGain();
429
+ const masterGain = audioContext.createGain();
430
+
431
+ oscillator1.connect(gainNode1);
432
+ oscillator2.connect(gainNode2);
433
+ gainNode1.connect(masterGain);
434
+ gainNode2.connect(masterGain);
435
+ masterGain.connect(audioContext.destination);
436
+
437
+ oscillator1.frequency.setValueAtTime(1800, audioContext.currentTime);
438
+ oscillator1.frequency.exponentialRampToValueAtTime(900, audioContext.currentTime + 0.08);
439
+ oscillator2.frequency.setValueAtTime(3600, audioContext.currentTime);
440
+ oscillator2.frequency.exponentialRampToValueAtTime(1800, audioContext.currentTime + 0.04);
441
+
442
+ oscillator1.type = 'triangle';
443
+ oscillator2.type = 'sine';
444
+
445
+ gainNode1.gain.setValueAtTime(0, audioContext.currentTime);
446
+ gainNode1.gain.linearRampToValueAtTime(0.4, audioContext.currentTime + 0.002);
447
+ gainNode1.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.12);
448
+
449
+ gainNode2.gain.setValueAtTime(0, audioContext.currentTime);
450
+ gainNode2.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.001);
451
+ gainNode2.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.06);
452
+
453
+ masterGain.gain.setValueAtTime(0.5, audioContext.currentTime);
454
+
455
+ const startTime = audioContext.currentTime;
456
+ const stopTime = startTime + 0.15;
457
+
458
+ oscillator1.start(startTime);
459
+ oscillator2.start(startTime);
460
+ oscillator1.stop(stopTime);
461
+ oscillator2.stop(stopTime);
462
+ } catch {
463
+ // Audio context not available
464
+ }
465
+ };
466
+
467
+ // ============================================================================
468
+ // IdleScreen Component (Main Export)
469
+ // ============================================================================
470
+
471
+ export interface IdleScreenProps {
472
+ /** Stream status (OFFLINE, INITIALIZING, ERROR, etc.) */
473
+ status?: StreamStatus;
474
+ /** Human-readable message */
475
+ message?: string;
476
+ /** Processing percentage (for INITIALIZING) */
477
+ percentage?: number;
478
+ /** Error message */
479
+ error?: string;
480
+ /** Callback for retry button */
481
+ onRetry?: () => void;
482
+ }
483
+
484
+ export const IdleScreen: React.FC<IdleScreenProps> = ({
485
+ status,
486
+ message = "Waiting for stream...",
487
+ percentage,
488
+ error,
489
+ onRetry,
490
+ }) => {
491
+ const containerRef = useRef<HTMLDivElement>(null);
492
+ const [hitmarkers, setHitmarkers] = useState<Hitmarker[]>([]);
493
+
494
+ const createHitmarker = (e: { clientX: number; clientY: number }) => {
495
+ if (!containerRef.current) return;
496
+
497
+ const rect = containerRef.current.getBoundingClientRect();
498
+ const x = e.clientX - rect.left;
499
+ const y = e.clientY - rect.top;
500
+
501
+ const newHitmarker: Hitmarker = {
502
+ id: Date.now() + Math.random(),
503
+ x,
504
+ y,
505
+ };
506
+
507
+ setHitmarkers(prev => [...prev, newHitmarker]);
508
+ playHitmarkerSound();
509
+
510
+ setTimeout(() => {
511
+ setHitmarkers(prev => prev.filter(h => h.id !== newHitmarker.id));
512
+ }, 600);
513
+ };
514
+
515
+ // Inject CSS animations
516
+ useEffect(() => {
517
+ const styleId = 'idle-screen-animations';
518
+ if (!document.getElementById(styleId)) {
519
+ const style = document.createElement('style');
520
+ style.id = styleId;
521
+ style.textContent = `
522
+ @keyframes fadeInOut {
523
+ 0%, 100% { opacity: 0.6; }
524
+ 50% { opacity: 0.9; }
525
+ }
526
+ @keyframes logoPulse {
527
+ 0%, 100% { opacity: 0.15; transform: scale(1); }
528
+ 50% { opacity: 0.25; transform: scale(1.05); }
529
+ }
530
+ @keyframes floatUp {
531
+ 0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
532
+ 10% { opacity: 0.6; }
533
+ 90% { opacity: 0.6; }
534
+ 100% { transform: translateY(-100px) rotate(360deg); opacity: 0; }
535
+ }
536
+ @keyframes gradientShift {
537
+ 0%, 100% { background-position: 0% 50%; }
538
+ 50% { background-position: 100% 50%; }
539
+ }
540
+ @keyframes hitmarkerFade45 {
541
+ 0% { opacity: 1; transform: translate(-50%, -50%) rotate(45deg) scale(0.5); }
542
+ 20% { opacity: 1; transform: translate(-50%, -50%) rotate(45deg) scale(1.2); }
543
+ 100% { opacity: 0; transform: translate(-50%, -50%) rotate(45deg) scale(1); }
544
+ }
545
+ @keyframes hitmarkerFadeNeg45 {
546
+ 0% { opacity: 1; transform: translate(-50%, -50%) rotate(-45deg) scale(0.5); }
547
+ 20% { opacity: 1; transform: translate(-50%, -50%) rotate(-45deg) scale(1.2); }
548
+ 100% { opacity: 0; transform: translate(-50%, -50%) rotate(-45deg) scale(1); }
549
+ }
550
+ `;
551
+ document.head.appendChild(style);
552
+ }
553
+ }, []);
554
+
555
+ return (
556
+ <div
557
+ ref={containerRef}
558
+ className="fw-player-root"
559
+ style={{
560
+ position: "absolute",
561
+ inset: 0,
562
+ zIndex: 5,
563
+ background: `
564
+ linear-gradient(135deg,
565
+ hsl(var(--tn-bg-dark, 235 21% 11%)) 0%,
566
+ hsl(var(--tn-bg, 233 23% 17%)) 25%,
567
+ hsl(var(--tn-bg-dark, 235 21% 11%)) 50%,
568
+ hsl(var(--tn-bg, 233 23% 17%)) 75%,
569
+ hsl(var(--tn-bg-dark, 235 21% 11%)) 100%
570
+ )
571
+ `,
572
+ backgroundSize: "400% 400%",
573
+ animation: "gradientShift 16s ease-in-out infinite",
574
+ display: "flex",
575
+ flexDirection: "column",
576
+ alignItems: "center",
577
+ justifyContent: "center",
578
+ overflow: "hidden",
579
+ borderRadius: "0",
580
+ userSelect: "none",
581
+ } as React.CSSProperties}
582
+ >
583
+ {/* Hitmarkers */}
584
+ {hitmarkers.map(hitmarker => (
585
+ <div
586
+ key={hitmarker.id}
587
+ style={{
588
+ position: "absolute",
589
+ left: `${hitmarker.x}px`,
590
+ top: `${hitmarker.y}px`,
591
+ transform: "translate(-50%, -50%)",
592
+ pointerEvents: "none",
593
+ zIndex: 100,
594
+ width: "40px",
595
+ height: "40px",
596
+ }}
597
+ >
598
+ <div style={{ position: "absolute", top: "25%", left: "25%", width: "12px", height: "3px", backgroundColor: "#ffffff", transform: "translate(-50%, -50%) rotate(45deg)", animation: "hitmarkerFade45 0.6s ease-out forwards", boxShadow: "0 0 8px rgba(255, 255, 255, 0.8)", borderRadius: "1px" }} />
599
+ <div style={{ position: "absolute", top: "25%", left: "75%", width: "12px", height: "3px", backgroundColor: "#ffffff", transform: "translate(-50%, -50%) rotate(-45deg)", animation: "hitmarkerFadeNeg45 0.6s ease-out forwards", boxShadow: "0 0 8px rgba(255, 255, 255, 0.8)", borderRadius: "1px" }} />
600
+ <div style={{ position: "absolute", top: "75%", left: "25%", width: "12px", height: "3px", backgroundColor: "#ffffff", transform: "translate(-50%, -50%) rotate(-45deg)", animation: "hitmarkerFadeNeg45 0.6s ease-out forwards", boxShadow: "0 0 8px rgba(255, 255, 255, 0.8)", borderRadius: "1px" }} />
601
+ <div style={{ position: "absolute", top: "75%", left: "75%", width: "12px", height: "3px", backgroundColor: "#ffffff", transform: "translate(-50%, -50%) rotate(45deg)", animation: "hitmarkerFade45 0.6s ease-out forwards", boxShadow: "0 0 8px rgba(255, 255, 255, 0.8)", borderRadius: "1px" }} />
602
+ </div>
603
+ ))}
604
+
605
+ {/* Floating particles */}
606
+ {[...Array(12)].map((_, index) => (
607
+ <div
608
+ key={`particle-${index}`}
609
+ style={{
610
+ position: "absolute",
611
+ left: `${Math.random() * 100}%`,
612
+ width: `${Math.random() * 4 + 2}px`,
613
+ height: `${Math.random() * 4 + 2}px`,
614
+ borderRadius: "50%",
615
+ background: ["#7aa2f7", "#bb9af7", "#9ece6a", "#73daca", "#7dcfff", "#f7768e", "#e0af68", "#2ac3de"][index % 8],
616
+ opacity: 0,
617
+ animation: `floatUp ${8 + Math.random() * 4}s linear infinite`,
618
+ animationDelay: `${Math.random() * 8}s`,
619
+ pointerEvents: "none",
620
+ }}
621
+ />
622
+ ))}
623
+
624
+ {/* Animated bubbles */}
625
+ {[...Array(8)].map((_, index) => (
626
+ <AnimatedBubble key={index} index={index} />
627
+ ))}
628
+
629
+ {/* Center logo */}
630
+ <CenterLogo containerRef={containerRef as React.RefObject<HTMLDivElement>} onHitmarker={createHitmarker} />
631
+
632
+ {/* DVD Logo */}
633
+ <DvdLogo parentRef={containerRef as React.RefObject<HTMLDivElement>} scale={0.08} />
634
+
635
+ {/* Status overlay */}
636
+ <StatusOverlay
637
+ status={status}
638
+ message={message}
639
+ percentage={percentage}
640
+ error={error}
641
+ onRetry={onRetry}
642
+ />
643
+
644
+ {/* Overlay texture */}
645
+ <div
646
+ style={{
647
+ position: "absolute",
648
+ top: 0,
649
+ left: 0,
650
+ right: 0,
651
+ bottom: 0,
652
+ background: `
653
+ radial-gradient(circle at 20% 80%, rgba(122, 162, 247, 0.03) 0%, transparent 50%),
654
+ radial-gradient(circle at 80% 20%, rgba(187, 154, 247, 0.03) 0%, transparent 50%),
655
+ radial-gradient(circle at 40% 40%, rgba(158, 206, 106, 0.02) 0%, transparent 50%)
656
+ `,
657
+ pointerEvents: "none",
658
+ }}
659
+ />
660
+ </div>
661
+ );
662
+ };
663
+
664
+ export default IdleScreen;