@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,200 @@
1
+ import React from 'react';
2
+ import type { StreamStatus } from '../types';
3
+
4
+ export interface StreamStateOverlayProps {
5
+ /** Current stream status */
6
+ status: StreamStatus;
7
+ /** Human-readable message */
8
+ message: string;
9
+ /** Processing percentage (for INITIALIZING state) */
10
+ percentage?: number;
11
+ /** Callback for retry button */
12
+ onRetry?: () => void;
13
+ /** Whether to show the overlay */
14
+ visible?: boolean;
15
+ /** Additional className */
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Get status icon based on stream state
21
+ */
22
+ function StatusIcon({ status }: { status: StreamStatus }) {
23
+ const iconClass = "w-5 h-5";
24
+
25
+ switch (status) {
26
+ case 'ONLINE':
27
+ return (
28
+ <svg className={`${iconClass} fw-status-online`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
29
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
30
+ </svg>
31
+ );
32
+
33
+ case 'OFFLINE':
34
+ return (
35
+ <svg className={`${iconClass} fw-status-offline`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
36
+ <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" />
37
+ </svg>
38
+ );
39
+
40
+ case 'INITIALIZING':
41
+ case 'BOOTING':
42
+ case 'WAITING_FOR_DATA':
43
+ return (
44
+ <svg className={`${iconClass} fw-status-warning animate-spin`} fill="none" viewBox="0 0 24 24">
45
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
46
+ <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" />
47
+ </svg>
48
+ );
49
+
50
+ case 'SHUTTING_DOWN':
51
+ return (
52
+ <svg className={`${iconClass} fw-status-warning`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
53
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
54
+ </svg>
55
+ );
56
+
57
+ case 'ERROR':
58
+ case 'INVALID':
59
+ default:
60
+ return (
61
+ <svg className={`${iconClass} fw-status-offline`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
62
+ <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" />
63
+ </svg>
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Get status label for header
70
+ */
71
+ function getStatusLabel(status: StreamStatus): string {
72
+ switch (status) {
73
+ case 'ONLINE': return 'ONLINE';
74
+ case 'OFFLINE': return 'OFFLINE';
75
+ case 'INITIALIZING': return 'INITIALIZING';
76
+ case 'BOOTING': return 'STARTING';
77
+ case 'WAITING_FOR_DATA': return 'WAITING';
78
+ case 'SHUTTING_DOWN': return 'ENDING';
79
+ case 'ERROR': return 'ERROR';
80
+ case 'INVALID': return 'INVALID';
81
+ default: return 'STATUS';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * StreamStateOverlay - Shows stream status when not playable
87
+ *
88
+ * Slab-based design with header/body/actions zones.
89
+ * Uses Tokyo Night color palette and seam-based layout.
90
+ */
91
+ export const StreamStateOverlay: React.FC<StreamStateOverlayProps> = ({
92
+ status,
93
+ message,
94
+ percentage,
95
+ onRetry,
96
+ visible = true,
97
+ className = '',
98
+ }) => {
99
+ if (!visible || status === 'ONLINE') {
100
+ return null;
101
+ }
102
+
103
+ const showRetry = status === 'ERROR' || status === 'INVALID' || status === 'OFFLINE';
104
+ const showProgress = status === 'INITIALIZING' && percentage !== undefined;
105
+
106
+ return (
107
+ <div
108
+ className={`absolute inset-0 z-20 flex items-center justify-center ${className}`}
109
+ style={{ backgroundColor: 'hsl(var(--tn-bg-dark) / 0.8)', backdropFilter: 'blur(4px)' }}
110
+ role="status"
111
+ aria-live="polite"
112
+ >
113
+ {/* Slab container - no rounded corners, seam borders */}
114
+ <div
115
+ className="fw-slab w-[280px] max-w-[90%]"
116
+ style={{ backgroundColor: 'hsl(var(--tn-bg) / 0.95)' }}
117
+ >
118
+ {/* Slab header - status label with icon */}
119
+ <div className="fw-slab-header flex items-center gap-2">
120
+ <StatusIcon status={status} />
121
+ <span>{getStatusLabel(status)}</span>
122
+ </div>
123
+
124
+ {/* Slab body - message and progress */}
125
+ <div className="fw-slab-body">
126
+ <p className="text-sm" style={{ color: 'hsl(var(--tn-fg))' }}>
127
+ {message}
128
+ </p>
129
+
130
+ {showProgress && (
131
+ <div className="mt-3">
132
+ {/* Progress bar - no rounded corners */}
133
+ <div
134
+ className="h-1.5 w-full overflow-hidden"
135
+ style={{ backgroundColor: 'hsl(var(--tn-bg-visual))' }}
136
+ >
137
+ <div
138
+ className="h-full transition-all duration-300"
139
+ style={{
140
+ width: `${Math.min(100, percentage)}%`,
141
+ backgroundColor: 'hsl(var(--tn-yellow))',
142
+ }}
143
+ />
144
+ </div>
145
+ <p
146
+ className="mt-1.5 text-xs font-mono"
147
+ style={{ color: 'hsl(var(--tn-fg-dark))' }}
148
+ >
149
+ {Math.round(percentage)}%
150
+ </p>
151
+ </div>
152
+ )}
153
+
154
+ {status === 'OFFLINE' && (
155
+ <p className="mt-2 text-xs" style={{ color: 'hsl(var(--tn-fg-dark))' }}>
156
+ The stream will start when the broadcaster goes live
157
+ </p>
158
+ )}
159
+
160
+ {(status === 'BOOTING' || status === 'WAITING_FOR_DATA') && (
161
+ <p className="mt-2 text-xs" style={{ color: 'hsl(var(--tn-fg-dark))' }}>
162
+ Please wait while the stream prepares...
163
+ </p>
164
+ )}
165
+
166
+ {/* Polling indicator for non-error states */}
167
+ {!showRetry && (
168
+ <div
169
+ className="mt-3 flex items-center gap-2 text-xs"
170
+ style={{ color: 'hsl(var(--tn-fg-dark))' }}
171
+ >
172
+ <span
173
+ className="h-1.5 w-1.5 animate-pulse"
174
+ style={{ backgroundColor: 'hsl(var(--tn-cyan))' }}
175
+ />
176
+ <span>Checking stream status...</span>
177
+ </div>
178
+ )}
179
+ </div>
180
+
181
+ {/* Slab actions - flush retry button */}
182
+ {showRetry && onRetry && (
183
+ <div className="fw-slab-actions">
184
+ <button
185
+ type="button"
186
+ onClick={onRetry}
187
+ className="fw-btn-flush py-2.5 text-xs font-medium uppercase tracking-wide"
188
+ style={{ color: 'hsl(var(--tn-blue))' }}
189
+ aria-label="Retry connection"
190
+ >
191
+ Retry Connection
192
+ </button>
193
+ </div>
194
+ )}
195
+ </div>
196
+ </div>
197
+ );
198
+ };
199
+
200
+ export default StreamStateOverlay;
@@ -0,0 +1,235 @@
1
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
2
+ import type { SubtitleCue, MetaTrackEvent } from '../types';
3
+
4
+ export interface SubtitleRendererProps {
5
+ /** Current video playback time in seconds */
6
+ currentTime: number;
7
+ /** Whether subtitles are enabled */
8
+ enabled?: boolean;
9
+ /** Subtitle cues to render (static or from meta track) */
10
+ cues?: SubtitleCue[];
11
+ /** Subscribe to meta track function (for live subtitles) */
12
+ subscribeToMetaTrack?: (trackId: string, callback: (event: MetaTrackEvent) => void) => () => void;
13
+ /** Meta track ID for live subtitles */
14
+ metaTrackId?: string;
15
+ /** Custom styles */
16
+ style?: SubtitleStyle;
17
+ /** Container class name */
18
+ className?: string;
19
+ }
20
+
21
+ export interface SubtitleStyle {
22
+ /** Font size (default: '1.5rem') */
23
+ fontSize?: string;
24
+ /** Font family (default: system) */
25
+ fontFamily?: string;
26
+ /** Text color (default: 'white') */
27
+ color?: string;
28
+ /** Background color (default: 'rgba(0,0,0,0.75)') */
29
+ backgroundColor?: string;
30
+ /** Text shadow for readability */
31
+ textShadow?: string;
32
+ /** Bottom offset from video (default: '5%') */
33
+ bottom?: string;
34
+ /** Max width (default: '90%') */
35
+ maxWidth?: string;
36
+ /** Padding (default: '0.5em 1em') */
37
+ padding?: string;
38
+ /** Border radius (default: '4px') */
39
+ borderRadius?: string;
40
+ }
41
+
42
+ const DEFAULT_STYLE: SubtitleStyle = {
43
+ fontSize: '1.5rem',
44
+ fontFamily: 'system-ui, -apple-system, sans-serif',
45
+ color: 'white',
46
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
47
+ textShadow: '2px 2px 4px rgba(0, 0, 0, 0.5)',
48
+ bottom: '5%',
49
+ maxWidth: '90%',
50
+ padding: '0.5em 1em',
51
+ borderRadius: '4px',
52
+ };
53
+
54
+ /**
55
+ * Parse subtitle cue from meta track event data
56
+ */
57
+ function parseSubtitleCue(data: unknown): SubtitleCue | null {
58
+ if (typeof data !== 'object' || data === null) return null;
59
+
60
+ const obj = data as Record<string, unknown>;
61
+
62
+ // Extract text
63
+ const text = typeof obj.text === 'string' ? obj.text : String(obj.text ?? '');
64
+ if (!text) return null;
65
+
66
+ // Extract timing
67
+ let startTime = 0;
68
+ let endTime = Infinity;
69
+
70
+ if ('startTime' in obj) startTime = Number(obj.startTime);
71
+ else if ('start' in obj) startTime = Number(obj.start);
72
+
73
+ if ('endTime' in obj) endTime = Number(obj.endTime);
74
+ else if ('end' in obj) endTime = Number(obj.end);
75
+
76
+ // Extract ID
77
+ const id = typeof obj.id === 'string' ? obj.id : String(Date.now());
78
+
79
+ return {
80
+ id,
81
+ text,
82
+ startTime,
83
+ endTime,
84
+ lang: typeof obj.lang === 'string' ? obj.lang : undefined,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * SubtitleRenderer - Renders live or static subtitles over video
90
+ *
91
+ * Supports:
92
+ * - Static cue list (pre-loaded)
93
+ * - Live cues from meta track subscription
94
+ * - Customizable styling (font, colors, position)
95
+ * - Automatic timing synchronization with video
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * // Static subtitles
100
+ * <SubtitleRenderer
101
+ * currentTime={videoElement.currentTime}
102
+ * enabled={showSubtitles}
103
+ * cues={subtitleCues}
104
+ * />
105
+ *
106
+ * // Live subtitles from meta track
107
+ * const { subscribe } = useMetaTrack({ mistBaseUrl, streamName });
108
+ *
109
+ * <SubtitleRenderer
110
+ * currentTime={videoElement.currentTime}
111
+ * enabled={showSubtitles}
112
+ * subscribeToMetaTrack={subscribe}
113
+ * metaTrackId="subtitle-track"
114
+ * />
115
+ * ```
116
+ */
117
+ export const SubtitleRenderer: React.FC<SubtitleRendererProps> = ({
118
+ currentTime,
119
+ enabled = true,
120
+ cues: staticCues,
121
+ subscribeToMetaTrack,
122
+ metaTrackId,
123
+ style: customStyle,
124
+ className = '',
125
+ }) => {
126
+ const [liveCues, setLiveCues] = useState<SubtitleCue[]>([]);
127
+ const [displayedText, setDisplayedText] = useState<string>('');
128
+ const lastCueIdRef = useRef<string | null>(null);
129
+
130
+ const style = { ...DEFAULT_STYLE, ...customStyle };
131
+
132
+ // All available cues (static + live)
133
+ const allCues = [...(staticCues ?? []), ...liveCues];
134
+
135
+ // Subscribe to live subtitles if meta track is configured
136
+ useEffect(() => {
137
+ if (!enabled || !subscribeToMetaTrack || !metaTrackId) {
138
+ return;
139
+ }
140
+
141
+ const handleMetaEvent = (event: MetaTrackEvent) => {
142
+ if (event.type === 'subtitle') {
143
+ const cue = parseSubtitleCue(event.data);
144
+ if (cue) {
145
+ setLiveCues(prev => {
146
+ // Deduplicate by ID
147
+ const existing = prev.find(c => c.id === cue.id);
148
+ if (existing) return prev;
149
+
150
+ // Keep last 50 cues max
151
+ const updated = [...prev, cue];
152
+ return updated.slice(-50);
153
+ });
154
+ }
155
+ }
156
+ };
157
+
158
+ const unsubscribe = subscribeToMetaTrack(metaTrackId, handleMetaEvent);
159
+
160
+ return () => {
161
+ unsubscribe();
162
+ };
163
+ }, [enabled, subscribeToMetaTrack, metaTrackId]);
164
+
165
+ // Find active cue based on current time
166
+ useEffect(() => {
167
+ if (!enabled) {
168
+ setDisplayedText('');
169
+ return;
170
+ }
171
+
172
+ // Find cue that matches current time
173
+ const currentTimeMs = currentTime * 1000; // Convert to ms if needed
174
+ const activeCue = allCues.find(cue => {
175
+ const start = cue.startTime;
176
+ const end = cue.endTime;
177
+ return currentTimeMs >= start && currentTimeMs < end;
178
+ });
179
+
180
+ if (activeCue) {
181
+ setDisplayedText(activeCue.text);
182
+ lastCueIdRef.current = activeCue.id;
183
+ } else {
184
+ setDisplayedText('');
185
+ lastCueIdRef.current = null;
186
+ }
187
+ }, [enabled, currentTime, allCues]);
188
+
189
+ // Clean up expired cues
190
+ useEffect(() => {
191
+ const currentTimeMs = currentTime * 1000;
192
+
193
+ setLiveCues(prev => {
194
+ // Remove cues that are more than 30 seconds old
195
+ return prev.filter(cue => {
196
+ const endTime = cue.endTime === Infinity ? cue.startTime + 10000 : cue.endTime;
197
+ return endTime >= currentTimeMs - 30000;
198
+ });
199
+ });
200
+ }, [currentTime]);
201
+
202
+ if (!enabled || !displayedText) {
203
+ return null;
204
+ }
205
+
206
+ return (
207
+ <div
208
+ className={`fw-absolute fw-left-1/2 fw-transform fw--translate-x-1/2 fw-z-30 fw-text-center fw-pointer-events-none ${className}`}
209
+ style={{
210
+ bottom: style.bottom,
211
+ maxWidth: style.maxWidth,
212
+ }}
213
+ aria-live="polite"
214
+ role="region"
215
+ aria-label="Subtitles"
216
+ >
217
+ <span
218
+ className="fw-inline-block fw-whitespace-pre-wrap"
219
+ style={{
220
+ fontSize: style.fontSize,
221
+ fontFamily: style.fontFamily,
222
+ color: style.color,
223
+ backgroundColor: style.backgroundColor,
224
+ textShadow: style.textShadow,
225
+ padding: style.padding,
226
+ borderRadius: style.borderRadius,
227
+ }}
228
+ >
229
+ {displayedText}
230
+ </span>
231
+ </div>
232
+ );
233
+ };
234
+
235
+ export default SubtitleRenderer;
@@ -0,0 +1,90 @@
1
+ import React from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+ import { Button } from "../ui/button";
4
+ import type { ThumbnailOverlayProps } from "../types";
5
+
6
+ const ThumbnailOverlay: React.FC<ThumbnailOverlayProps> = ({
7
+ thumbnailUrl,
8
+ onPlay,
9
+ message,
10
+ showUnmuteMessage = false,
11
+ style,
12
+ className
13
+ }) => {
14
+ const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => {
15
+ e.stopPropagation();
16
+ onPlay?.();
17
+ };
18
+
19
+ return (
20
+ <div
21
+ role="button"
22
+ tabIndex={0}
23
+ onClick={handleClick}
24
+ onKeyDown={(event) => {
25
+ if (event.key === "Enter" || event.key === " ") {
26
+ event.preventDefault();
27
+ handleClick(event);
28
+ }
29
+ }}
30
+ style={style}
31
+ className={cn(
32
+ "fw-player-thumbnail relative flex h-full min-h-[280px] w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl bg-slate-950 text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
33
+ , className)}
34
+ >
35
+ {thumbnailUrl && (
36
+ <div
37
+ className="absolute inset-0 bg-cover bg-center"
38
+ style={{ backgroundImage: `url(${thumbnailUrl})` }}
39
+ />
40
+ )}
41
+
42
+ <div
43
+ className={cn(
44
+ "absolute inset-0 bg-slate-950/70",
45
+ !thumbnailUrl && "bg-gradient-to-br from-slate-900 via-slate-950 to-slate-900"
46
+ )}
47
+ />
48
+
49
+ <div className="relative z-10 flex max-w-[320px] flex-col items-center gap-4 px-6 text-center text-sm sm:gap-6">
50
+ {showUnmuteMessage ? (
51
+ <div className="w-full rounded-lg border border-white/15 bg-black/80 p-4 text-sm text-white shadow-lg backdrop-blur">
52
+ <div className="mb-1 flex items-center justify-center gap-2 text-base font-semibold text-primary">
53
+ <span aria-hidden="true">🔇</span> Click to unmute
54
+ </div>
55
+ <p className="text-xs text-white/80">Stream is playing muted — tap to enable sound.</p>
56
+ </div>
57
+ ) : (
58
+ <>
59
+ <Button
60
+ type="button"
61
+ size="icon"
62
+ variant="secondary"
63
+ className="h-20 w-20 rounded-full bg-primary/90 text-primary-foreground shadow-lg shadow-primary/40 transition hover:bg-primary focus-visible:bg-primary"
64
+ aria-label="Play stream"
65
+ >
66
+ <svg
67
+ viewBox="0 0 24 24"
68
+ fill="currentColor"
69
+ className="ml-0.5 h-8 w-8"
70
+ aria-hidden="true"
71
+ >
72
+ <path d="M8 5v14l11-7z" />
73
+ </svg>
74
+ </Button>
75
+ <div className="w-full rounded-lg border border-white/10 bg-black/70 p-5 text-white shadow-inner backdrop-blur">
76
+ <p className="text-base font-semibold text-primary">
77
+ {message ?? "Click to play"}
78
+ </p>
79
+ <p className="mt-1 text-xs text-white/70">
80
+ {message ? "Start streaming instantly" : "Jump into the live feed"}
81
+ </p>
82
+ </div>
83
+ </>
84
+ )}
85
+ </div>
86
+ </div>
87
+ );
88
+ };
89
+
90
+ export default ThumbnailOverlay;
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+
4
+ interface TitleOverlayProps {
5
+ title?: string | null;
6
+ description?: string | null;
7
+ isVisible: boolean;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Title/description overlay that appears at the top of the player.
13
+ * Visible on hover or when paused - controlled by parent via isVisible prop.
14
+ */
15
+ const TitleOverlay: React.FC<TitleOverlayProps> = ({
16
+ title,
17
+ description,
18
+ isVisible,
19
+ className,
20
+ }) => {
21
+ // Don't render if no content
22
+ if (!title && !description) return null;
23
+
24
+ return (
25
+ <div
26
+ className={cn(
27
+ "fw-title-overlay absolute inset-x-0 top-0 z-20 pointer-events-none",
28
+ "bg-gradient-to-b from-black/70 via-black/40 to-transparent",
29
+ "px-4 py-3 transition-opacity duration-300",
30
+ isVisible ? "opacity-100" : "opacity-0",
31
+ className
32
+ )}
33
+ >
34
+ {title && (
35
+ <h2 className="text-white text-sm font-medium truncate max-w-[80%]">
36
+ {title}
37
+ </h2>
38
+ )}
39
+ {description && (
40
+ <p className="text-white/70 text-xs mt-0.5 line-clamp-2 max-w-[70%]">
41
+ {description}
42
+ </p>
43
+ )}
44
+ </div>
45
+ );
46
+ };
47
+
48
+ export default TitleOverlay;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DASH.js Player - React Wrapper
3
+ *
4
+ * MPEG-DASH streaming via dash.js library.
5
+ * The implementation is in @livepeer-frameworks/player-core.
6
+ */
7
+
8
+ import React, { useEffect, useRef } from 'react';
9
+ import { DashJsPlayerImpl } from '@livepeer-frameworks/player-core';
10
+
11
+ // Re-export the implementation from core for backwards compatibility
12
+ export { DashJsPlayerImpl };
13
+
14
+ type Props = {
15
+ src: string;
16
+ muted?: boolean;
17
+ autoPlay?: boolean;
18
+ controls?: boolean;
19
+ onError?: (e: Error) => void;
20
+ };
21
+
22
+ // React component wrapper
23
+ const DashJsPlayer: React.FC<Props> = ({
24
+ src,
25
+ muted = true,
26
+ autoPlay = true,
27
+ controls = true,
28
+ onError
29
+ }) => {
30
+ const containerRef = useRef<HTMLDivElement>(null);
31
+ const playerRef = useRef<DashJsPlayerImpl | null>(null);
32
+
33
+ useEffect(() => {
34
+ if (!containerRef.current) return;
35
+
36
+ const player = new DashJsPlayerImpl();
37
+ playerRef.current = player;
38
+
39
+ player.initialize(
40
+ containerRef.current,
41
+ { url: src, type: 'dash/video/mp4' },
42
+ { autoplay: autoPlay, muted, controls }
43
+ ).catch((e) => {
44
+ onError?.(e instanceof Error ? e : new Error(String(e)));
45
+ });
46
+
47
+ return () => {
48
+ player.destroy();
49
+ playerRef.current = null;
50
+ };
51
+ }, [src, muted, autoPlay, controls, onError]);
52
+
53
+ return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
54
+ };
55
+
56
+ export default DashJsPlayer;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * HLS.js Player - React Wrapper
3
+ *
4
+ * Adaptive HLS streaming via hls.js library.
5
+ * The implementation is in @livepeer-frameworks/player-core.
6
+ */
7
+
8
+ import React, { useEffect, useRef } from 'react';
9
+ import { HlsJsPlayerImpl } from '@livepeer-frameworks/player-core';
10
+
11
+ // Re-export the implementation from core for backwards compatibility
12
+ export { HlsJsPlayerImpl };
13
+
14
+ type Props = {
15
+ src: string;
16
+ muted?: boolean;
17
+ autoPlay?: boolean;
18
+ controls?: boolean;
19
+ onError?: (e: Error) => void;
20
+ };
21
+
22
+ // React component wrapper
23
+ const HlsJsPlayer: React.FC<Props> = ({
24
+ src,
25
+ muted = true,
26
+ autoPlay = true,
27
+ controls = true,
28
+ onError
29
+ }) => {
30
+ const containerRef = useRef<HTMLDivElement>(null);
31
+ const playerRef = useRef<HlsJsPlayerImpl | null>(null);
32
+
33
+ useEffect(() => {
34
+ if (!containerRef.current) return;
35
+
36
+ const player = new HlsJsPlayerImpl();
37
+ playerRef.current = player;
38
+
39
+ player.initialize(
40
+ containerRef.current,
41
+ { url: src, type: 'html5/application/vnd.apple.mpegurl' },
42
+ { autoplay: autoPlay, muted, controls }
43
+ ).catch((e) => {
44
+ onError?.(e instanceof Error ? e : new Error(String(e)));
45
+ });
46
+
47
+ return () => {
48
+ player.destroy();
49
+ playerRef.current = null;
50
+ };
51
+ }, [src, muted, autoPlay, controls, onError]);
52
+
53
+ return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
54
+ };
55
+
56
+ export default HlsJsPlayer;