@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,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;
|