@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,70 @@
|
|
|
1
|
+
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
|
2
|
+
import { Button } from '../ui/button';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
8
|
+
onRetry?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface State {
|
|
12
|
+
hasError: boolean;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error boundary to catch and handle player errors gracefully.
|
|
18
|
+
* Prevents player errors from crashing the parent application.
|
|
19
|
+
*/
|
|
20
|
+
class PlayerErrorBoundary extends Component<Props, State> {
|
|
21
|
+
constructor(props: Props) {
|
|
22
|
+
super(props);
|
|
23
|
+
this.state = { hasError: false, error: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static getDerivedStateFromError(error: Error): State {
|
|
27
|
+
return { hasError: true, error };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
31
|
+
console.error('[PlayerErrorBoundary] Caught error:', error, errorInfo);
|
|
32
|
+
this.props.onError?.(error, errorInfo);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
handleRetry = (): void => {
|
|
36
|
+
this.setState({ hasError: false, error: null });
|
|
37
|
+
this.props.onRetry?.();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
render(): ReactNode {
|
|
41
|
+
if (this.state.hasError) {
|
|
42
|
+
if (this.props.fallback) {
|
|
43
|
+
return this.props.fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="fw-player-error flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-xl bg-slate-950 p-6 text-center text-white">
|
|
48
|
+
<div className="text-lg font-semibold text-red-400">
|
|
49
|
+
Playback Error
|
|
50
|
+
</div>
|
|
51
|
+
<p className="max-w-sm text-sm text-slate-400">
|
|
52
|
+
{this.state.error?.message || 'An unexpected error occurred while loading the player.'}
|
|
53
|
+
</p>
|
|
54
|
+
<Button
|
|
55
|
+
type="button"
|
|
56
|
+
variant="secondary"
|
|
57
|
+
onClick={this.handleRetry}
|
|
58
|
+
className="mt-2"
|
|
59
|
+
>
|
|
60
|
+
Try Again
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this.props.children;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default PlayerErrorBoundary;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { cn } from "@livepeer-frameworks/player-core";
|
|
3
|
+
|
|
4
|
+
interface SeekBarProps {
|
|
5
|
+
/** Current playback time in seconds */
|
|
6
|
+
currentTime: number;
|
|
7
|
+
/** Total duration in seconds */
|
|
8
|
+
duration: number;
|
|
9
|
+
/** Buffered time ranges from video element */
|
|
10
|
+
buffered?: TimeRanges;
|
|
11
|
+
/** Whether seeking is allowed */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Called when user seeks to a new time */
|
|
14
|
+
onSeek?: (time: number) => void;
|
|
15
|
+
/** Additional class names */
|
|
16
|
+
className?: string;
|
|
17
|
+
/** Whether this is a live stream */
|
|
18
|
+
isLive?: boolean;
|
|
19
|
+
/** For live: start of seekable DVR window (seconds) */
|
|
20
|
+
seekableStart?: number;
|
|
21
|
+
/** For live: current live edge position (seconds) */
|
|
22
|
+
liveEdge?: number;
|
|
23
|
+
/** Defer seeking until drag release */
|
|
24
|
+
commitOnRelease?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Industry-standard video seek bar with:
|
|
29
|
+
* - Track background
|
|
30
|
+
* - Buffer progress indicator
|
|
31
|
+
* - Playback progress indicator
|
|
32
|
+
* - Thumb on hover
|
|
33
|
+
* - Time tooltip on hover (relative for live: "-5:30")
|
|
34
|
+
*/
|
|
35
|
+
const SeekBar: React.FC<SeekBarProps> = ({
|
|
36
|
+
currentTime,
|
|
37
|
+
duration,
|
|
38
|
+
buffered,
|
|
39
|
+
disabled = false,
|
|
40
|
+
onSeek,
|
|
41
|
+
className,
|
|
42
|
+
isLive = false,
|
|
43
|
+
seekableStart = 0,
|
|
44
|
+
liveEdge,
|
|
45
|
+
commitOnRelease = false,
|
|
46
|
+
}) => {
|
|
47
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
49
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
50
|
+
const [dragTime, setDragTime] = useState<number | null>(null);
|
|
51
|
+
const dragTimeRef = useRef<number | null>(null);
|
|
52
|
+
const [hoverPosition, setHoverPosition] = useState(0);
|
|
53
|
+
const [hoverTime, setHoverTime] = useState(0);
|
|
54
|
+
|
|
55
|
+
// Effective live edge (use provided or fall back to duration)
|
|
56
|
+
const effectiveLiveEdge = liveEdge ?? duration;
|
|
57
|
+
|
|
58
|
+
// Seekable window size
|
|
59
|
+
const seekableWindow = effectiveLiveEdge - seekableStart;
|
|
60
|
+
|
|
61
|
+
// Calculate progress percentage
|
|
62
|
+
// For live streams: position within the DVR window
|
|
63
|
+
// For VOD: position within total duration
|
|
64
|
+
const displayTime = dragTime ?? currentTime;
|
|
65
|
+
const progressPercent = useMemo(() => {
|
|
66
|
+
if (isLive && seekableWindow > 0) {
|
|
67
|
+
const positionInWindow = displayTime - seekableStart;
|
|
68
|
+
return Math.min(100, Math.max(0, (positionInWindow / seekableWindow) * 100));
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isFinite(duration) || duration <= 0) return 0;
|
|
71
|
+
return Math.min(100, Math.max(0, (displayTime / duration) * 100));
|
|
72
|
+
}, [displayTime, duration, isLive, seekableStart, seekableWindow]);
|
|
73
|
+
|
|
74
|
+
// Calculate buffered segments as array of {start%, end%} for accurate display
|
|
75
|
+
const bufferedSegments = useMemo(() => {
|
|
76
|
+
if (!buffered || buffered.length === 0) return [];
|
|
77
|
+
|
|
78
|
+
const rangeEnd = isLive ? effectiveLiveEdge : duration;
|
|
79
|
+
const rangeStart = isLive ? seekableStart : 0;
|
|
80
|
+
const rangeSize = rangeEnd - rangeStart;
|
|
81
|
+
|
|
82
|
+
if (!Number.isFinite(rangeSize) || rangeSize <= 0) return [];
|
|
83
|
+
|
|
84
|
+
const segments: Array<{ startPercent: number; endPercent: number }> = [];
|
|
85
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
86
|
+
const start = buffered.start(i);
|
|
87
|
+
const end = buffered.end(i);
|
|
88
|
+
|
|
89
|
+
// Calculate position relative to the seekable range
|
|
90
|
+
const relativeStart = start - rangeStart;
|
|
91
|
+
const relativeEnd = end - rangeStart;
|
|
92
|
+
|
|
93
|
+
segments.push({
|
|
94
|
+
startPercent: Math.min(100, Math.max(0, (relativeStart / rangeSize) * 100)),
|
|
95
|
+
endPercent: Math.min(100, Math.max(0, (relativeEnd / rangeSize) * 100)),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return segments;
|
|
99
|
+
}, [buffered, duration, isLive, seekableStart, effectiveLiveEdge]);
|
|
100
|
+
|
|
101
|
+
// Format time as MM:SS or HH:MM:SS (for VOD)
|
|
102
|
+
const formatTime = useCallback((seconds: number): string => {
|
|
103
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
|
104
|
+
const total = Math.floor(seconds);
|
|
105
|
+
const hours = Math.floor(total / 3600);
|
|
106
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
107
|
+
const secs = total % 60;
|
|
108
|
+
if (hours > 0) {
|
|
109
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
|
110
|
+
}
|
|
111
|
+
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Format relative time for live streams (e.g., "-5:30" = 5:30 behind live edge)
|
|
115
|
+
const formatLiveTime = useCallback((seconds: number, edge: number): string => {
|
|
116
|
+
const behindSeconds = edge - seconds;
|
|
117
|
+
if (behindSeconds < 1) return "LIVE";
|
|
118
|
+
const total = Math.floor(behindSeconds);
|
|
119
|
+
const hours = Math.floor(total / 3600);
|
|
120
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
121
|
+
const secs = total % 60;
|
|
122
|
+
if (hours > 0) {
|
|
123
|
+
return `-${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
|
124
|
+
}
|
|
125
|
+
return `-${minutes}:${String(secs).padStart(2, "0")}`;
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
// Calculate time from mouse position
|
|
129
|
+
// For live: maps position to time within DVR window
|
|
130
|
+
const getTimeFromPosition = useCallback((clientX: number): number => {
|
|
131
|
+
if (!trackRef.current) return 0;
|
|
132
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
133
|
+
const x = clientX - rect.left;
|
|
134
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
135
|
+
|
|
136
|
+
// Live with valid seekable window
|
|
137
|
+
if (isLive && Number.isFinite(seekableWindow) && seekableWindow > 0) {
|
|
138
|
+
return seekableStart + (percent * seekableWindow);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// VOD with finite duration
|
|
142
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
143
|
+
return percent * duration;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fallback: If we have liveEdge, use it even if not marked as live
|
|
147
|
+
// This handles cases where duration is Infinity but we have valid seekable data
|
|
148
|
+
if (Number.isFinite(liveEdge) && liveEdge > 0) {
|
|
149
|
+
const start = Number.isFinite(seekableStart) ? seekableStart : 0;
|
|
150
|
+
const window = liveEdge - start;
|
|
151
|
+
if (window > 0) {
|
|
152
|
+
return start + (percent * window);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Last resort: use currentTime as a baseline
|
|
157
|
+
return percent * (currentTime || 1);
|
|
158
|
+
}, [duration, isLive, seekableStart, seekableWindow, liveEdge, currentTime]);
|
|
159
|
+
|
|
160
|
+
// Handle mouse move for hover preview
|
|
161
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
162
|
+
if (!trackRef.current || disabled) return;
|
|
163
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
164
|
+
const x = e.clientX - rect.left;
|
|
165
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
166
|
+
setHoverPosition(percent * 100);
|
|
167
|
+
setHoverTime(getTimeFromPosition(e.clientX));
|
|
168
|
+
}, [disabled, getTimeFromPosition]);
|
|
169
|
+
|
|
170
|
+
// Handle click to seek
|
|
171
|
+
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
172
|
+
if (disabled) return;
|
|
173
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
174
|
+
const time = getTimeFromPosition(e.clientX);
|
|
175
|
+
onSeek?.(time);
|
|
176
|
+
setDragTime(null);
|
|
177
|
+
dragTimeRef.current = null;
|
|
178
|
+
}, [disabled, duration, isLive, getTimeFromPosition, onSeek]);
|
|
179
|
+
|
|
180
|
+
// Handle drag start
|
|
181
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
182
|
+
if (disabled) return;
|
|
183
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
setIsDragging(true);
|
|
186
|
+
|
|
187
|
+
const handleDragMove = (moveEvent: MouseEvent) => {
|
|
188
|
+
const time = getTimeFromPosition(moveEvent.clientX);
|
|
189
|
+
if (commitOnRelease) {
|
|
190
|
+
setDragTime(time);
|
|
191
|
+
dragTimeRef.current = time;
|
|
192
|
+
} else {
|
|
193
|
+
onSeek?.(time);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleDragEnd = () => {
|
|
198
|
+
setIsDragging(false);
|
|
199
|
+
document.removeEventListener("mousemove", handleDragMove);
|
|
200
|
+
document.removeEventListener("mouseup", handleDragEnd);
|
|
201
|
+
const pending = dragTimeRef.current;
|
|
202
|
+
if (commitOnRelease && pending !== null) {
|
|
203
|
+
onSeek?.(pending);
|
|
204
|
+
setDragTime(null);
|
|
205
|
+
dragTimeRef.current = null;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
document.addEventListener("mousemove", handleDragMove);
|
|
210
|
+
document.addEventListener("mouseup", handleDragEnd);
|
|
211
|
+
|
|
212
|
+
// Initial seek
|
|
213
|
+
const time = getTimeFromPosition(e.clientX);
|
|
214
|
+
if (commitOnRelease) {
|
|
215
|
+
setDragTime(time);
|
|
216
|
+
dragTimeRef.current = time;
|
|
217
|
+
} else {
|
|
218
|
+
onSeek?.(time);
|
|
219
|
+
}
|
|
220
|
+
}, [disabled, duration, isLive, getTimeFromPosition, onSeek, commitOnRelease]);
|
|
221
|
+
|
|
222
|
+
const showThumb = isHovering || isDragging;
|
|
223
|
+
const canShowTooltip = isLive ? seekableWindow > 0 : Number.isFinite(duration);
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div
|
|
227
|
+
ref={trackRef}
|
|
228
|
+
className={cn(
|
|
229
|
+
"group relative w-full h-6 flex items-center cursor-pointer",
|
|
230
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
231
|
+
className
|
|
232
|
+
)}
|
|
233
|
+
onMouseEnter={() => !disabled && setIsHovering(true)}
|
|
234
|
+
onMouseLeave={() => { setIsHovering(false); setIsDragging(false); }}
|
|
235
|
+
onMouseMove={handleMouseMove}
|
|
236
|
+
onClick={handleClick}
|
|
237
|
+
onMouseDown={handleMouseDown}
|
|
238
|
+
role="slider"
|
|
239
|
+
aria-label="Seek"
|
|
240
|
+
aria-valuemin={isLive ? seekableStart : 0}
|
|
241
|
+
aria-valuemax={isLive ? effectiveLiveEdge : (duration || 100)}
|
|
242
|
+
aria-valuenow={displayTime}
|
|
243
|
+
aria-valuetext={isLive ? formatLiveTime(displayTime, effectiveLiveEdge) : formatTime(displayTime)}
|
|
244
|
+
tabIndex={disabled ? -1 : 0}
|
|
245
|
+
>
|
|
246
|
+
{/* Track background */}
|
|
247
|
+
<div className={cn(
|
|
248
|
+
"fw-seek-track",
|
|
249
|
+
isDragging && "fw-seek-track--active"
|
|
250
|
+
)}>
|
|
251
|
+
{/* Buffered segments - show actual buffered ranges */}
|
|
252
|
+
{bufferedSegments.map((segment, index) => (
|
|
253
|
+
<div
|
|
254
|
+
key={index}
|
|
255
|
+
className="fw-seek-buffered"
|
|
256
|
+
style={{
|
|
257
|
+
left: `${segment.startPercent}%`,
|
|
258
|
+
width: `${segment.endPercent - segment.startPercent}%`,
|
|
259
|
+
}}
|
|
260
|
+
/>
|
|
261
|
+
))}
|
|
262
|
+
{/* Playback progress */}
|
|
263
|
+
<div
|
|
264
|
+
className="fw-seek-progress"
|
|
265
|
+
style={{ width: `${progressPercent}%` }}
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Thumb */}
|
|
270
|
+
<div
|
|
271
|
+
className={cn(
|
|
272
|
+
"fw-seek-thumb",
|
|
273
|
+
showThumb ? "fw-seek-thumb--active" : "fw-seek-thumb--hidden"
|
|
274
|
+
)}
|
|
275
|
+
style={{ left: `${progressPercent}%` }}
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
{/* Hover time tooltip */}
|
|
279
|
+
{isHovering && !isDragging && canShowTooltip && (
|
|
280
|
+
<div
|
|
281
|
+
className="fw-seek-tooltip"
|
|
282
|
+
style={{ left: `${hoverPosition}%` }}
|
|
283
|
+
>
|
|
284
|
+
{isLive ? formatLiveTime(hoverTime, effectiveLiveEdge) : formatTime(hoverTime)}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export default SeekBar;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { cn } from "@livepeer-frameworks/player-core";
|
|
3
|
+
|
|
4
|
+
export type SkipDirection = "back" | "forward" | null;
|
|
5
|
+
|
|
6
|
+
interface SkipIndicatorProps {
|
|
7
|
+
direction: SkipDirection;
|
|
8
|
+
seconds?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
onHide?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Skip indicator overlay that appears when double-tapping to skip.
|
|
15
|
+
* Shows the skip direction and amount (e.g., "-10s" or "+10s") with a ripple effect.
|
|
16
|
+
*/
|
|
17
|
+
const SkipIndicator: React.FC<SkipIndicatorProps> = ({
|
|
18
|
+
direction,
|
|
19
|
+
seconds = 10,
|
|
20
|
+
className,
|
|
21
|
+
onHide,
|
|
22
|
+
}) => {
|
|
23
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (direction) {
|
|
27
|
+
setIsAnimating(true);
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
setIsAnimating(false);
|
|
30
|
+
onHide?.();
|
|
31
|
+
}, 600);
|
|
32
|
+
return () => clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}, [direction, onHide]);
|
|
35
|
+
|
|
36
|
+
if (!direction) return null;
|
|
37
|
+
|
|
38
|
+
const isBack = direction === "back";
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"fw-skip-indicator absolute inset-0 z-30 pointer-events-none",
|
|
44
|
+
"flex items-center",
|
|
45
|
+
isBack ? "justify-start pl-8" : "justify-end pr-8",
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{/* Ripple background */}
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"absolute top-0 bottom-0 w-1/3",
|
|
53
|
+
isBack ? "left-0" : "right-0",
|
|
54
|
+
"bg-white/10",
|
|
55
|
+
isAnimating && "animate-pulse"
|
|
56
|
+
)}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
{/* Skip content */}
|
|
60
|
+
<div
|
|
61
|
+
className={cn(
|
|
62
|
+
"relative flex flex-col items-center gap-1 text-white",
|
|
63
|
+
"transition-all duration-200",
|
|
64
|
+
isAnimating ? "opacity-100 scale-100" : "opacity-0 scale-75"
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{/* Icon */}
|
|
68
|
+
<div className="flex">
|
|
69
|
+
{isBack ? (
|
|
70
|
+
<>
|
|
71
|
+
<RewindIcon className="w-8 h-8" />
|
|
72
|
+
<RewindIcon className="w-8 h-8 -ml-4" />
|
|
73
|
+
</>
|
|
74
|
+
) : (
|
|
75
|
+
<>
|
|
76
|
+
<FastForwardIcon className="w-8 h-8" />
|
|
77
|
+
<FastForwardIcon className="w-8 h-8 -ml-4" />
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Text */}
|
|
83
|
+
<span className="text-sm font-semibold tabular-nums">
|
|
84
|
+
{isBack ? `-${seconds}s` : `+${seconds}s`}
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const RewindIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
92
|
+
<svg
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
fill="currentColor"
|
|
95
|
+
className={className}
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
>
|
|
98
|
+
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
|
99
|
+
</svg>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const FastForwardIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
103
|
+
<svg
|
|
104
|
+
viewBox="0 0 24 24"
|
|
105
|
+
fill="currentColor"
|
|
106
|
+
className={className}
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
>
|
|
109
|
+
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
110
|
+
</svg>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
export default SkipIndicator;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@livepeer-frameworks/player-core";
|
|
3
|
+
|
|
4
|
+
interface SpeedIndicatorProps {
|
|
5
|
+
isVisible: boolean;
|
|
6
|
+
speed: number;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Speed indicator overlay that appears when holding for fast-forward.
|
|
12
|
+
* Shows the current playback speed (e.g., "2x") in a pill overlay.
|
|
13
|
+
*/
|
|
14
|
+
const SpeedIndicator: React.FC<SpeedIndicatorProps> = ({
|
|
15
|
+
isVisible,
|
|
16
|
+
speed,
|
|
17
|
+
className,
|
|
18
|
+
}) => {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
"fw-speed-indicator absolute top-3 right-3 z-30 pointer-events-none",
|
|
23
|
+
"transition-opacity duration-150",
|
|
24
|
+
isVisible ? "opacity-100" : "opacity-0",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
<div
|
|
29
|
+
className={cn(
|
|
30
|
+
"bg-black/60 text-white px-2.5 py-1 rounded-md",
|
|
31
|
+
"text-xs font-semibold tabular-nums",
|
|
32
|
+
"flex items-center gap-2",
|
|
33
|
+
"border border-white/15",
|
|
34
|
+
"transform transition-transform duration-150",
|
|
35
|
+
isVisible ? "scale-100" : "scale-90"
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<FastForwardIcon className="w-4 h-4" />
|
|
39
|
+
<span>{speed}x</span>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Simple fast-forward icon
|
|
46
|
+
const FastForwardIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
47
|
+
<svg
|
|
48
|
+
viewBox="0 0 24 24"
|
|
49
|
+
fill="currentColor"
|
|
50
|
+
className={className}
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
>
|
|
53
|
+
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export default SpeedIndicator;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn, type ContentMetadata, type PlaybackQuality } from "@livepeer-frameworks/player-core";
|
|
3
|
+
|
|
4
|
+
interface StreamStateInfo {
|
|
5
|
+
status?: string;
|
|
6
|
+
viewers?: number;
|
|
7
|
+
tracks?: Array<{
|
|
8
|
+
type: string;
|
|
9
|
+
codec: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
bps?: number;
|
|
13
|
+
channels?: number;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StatsPanelProps {
|
|
18
|
+
isOpen: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
metadata?: ContentMetadata | null;
|
|
21
|
+
streamState?: StreamStateInfo | null;
|
|
22
|
+
quality?: PlaybackQuality | null;
|
|
23
|
+
videoElement?: HTMLVideoElement | null;
|
|
24
|
+
protocol?: string;
|
|
25
|
+
nodeId?: string;
|
|
26
|
+
geoDistance?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* "Stats for nerds" panel showing detailed playback information.
|
|
31
|
+
* Toggleable overlay with technical details about the stream.
|
|
32
|
+
*/
|
|
33
|
+
const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
34
|
+
isOpen,
|
|
35
|
+
onClose,
|
|
36
|
+
metadata,
|
|
37
|
+
streamState,
|
|
38
|
+
quality,
|
|
39
|
+
videoElement,
|
|
40
|
+
protocol,
|
|
41
|
+
nodeId,
|
|
42
|
+
geoDistance,
|
|
43
|
+
}) => {
|
|
44
|
+
if (!isOpen) return null;
|
|
45
|
+
|
|
46
|
+
// Video element stats
|
|
47
|
+
const video = videoElement;
|
|
48
|
+
const currentRes = video ? `${video.videoWidth}x${video.videoHeight}` : "—";
|
|
49
|
+
const buffered = video && video.buffered.length > 0
|
|
50
|
+
? (video.buffered.end(video.buffered.length - 1) - video.currentTime).toFixed(1)
|
|
51
|
+
: "—";
|
|
52
|
+
const playbackRate = video?.playbackRate?.toFixed(2) ?? "1.00";
|
|
53
|
+
|
|
54
|
+
// Quality monitor stats
|
|
55
|
+
const qualityScore = quality?.score?.toFixed(0) ?? "—";
|
|
56
|
+
const bitrateKbps = quality?.bitrate
|
|
57
|
+
? `${(quality.bitrate / 1000).toFixed(0)} kbps`
|
|
58
|
+
: "—";
|
|
59
|
+
const frameDropRate = quality?.frameDropRate?.toFixed(1) ?? "—";
|
|
60
|
+
const stallCount = quality?.stallCount ?? 0;
|
|
61
|
+
const latency = quality?.latency ? `${Math.round(quality.latency)} ms` : "—";
|
|
62
|
+
|
|
63
|
+
// Stream state stats
|
|
64
|
+
const viewers = streamState?.viewers ?? metadata?.viewers ?? "—";
|
|
65
|
+
const streamStatus = streamState?.status ?? metadata?.status ?? "—";
|
|
66
|
+
|
|
67
|
+
// Format track info from metadata
|
|
68
|
+
const formatTracks = () => {
|
|
69
|
+
if (!streamState?.tracks?.length) return "—";
|
|
70
|
+
return streamState.tracks.map(t => {
|
|
71
|
+
if (t.type === "video") {
|
|
72
|
+
return `${t.codec} ${t.width}x${t.height}@${t.bps ? Math.round(t.bps / 1000) + "kbps" : "?"}`;
|
|
73
|
+
}
|
|
74
|
+
return `${t.codec} ${t.channels}ch`;
|
|
75
|
+
}).join(", ");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const stats = [
|
|
79
|
+
{ label: "Resolution", value: currentRes },
|
|
80
|
+
{ label: "Buffer", value: `${buffered}s` },
|
|
81
|
+
{ label: "Latency", value: latency },
|
|
82
|
+
{ label: "Bitrate", value: bitrateKbps },
|
|
83
|
+
{ label: "Quality Score", value: `${qualityScore}/100` },
|
|
84
|
+
{ label: "Frame Drop Rate", value: `${frameDropRate}%` },
|
|
85
|
+
{ label: "Stalls", value: String(stallCount) },
|
|
86
|
+
{ label: "Playback Rate", value: `${playbackRate}x` },
|
|
87
|
+
{ label: "Protocol", value: protocol ?? "—" },
|
|
88
|
+
{ label: "Node", value: nodeId ?? "—" },
|
|
89
|
+
{ label: "Geo Distance", value: geoDistance ? `${geoDistance.toFixed(0)} km` : "—" },
|
|
90
|
+
{ label: "Viewers", value: String(viewers) },
|
|
91
|
+
{ label: "Status", value: streamStatus },
|
|
92
|
+
{ label: "Tracks", value: formatTracks() },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Add metadata fields if available
|
|
96
|
+
if (metadata?.title) {
|
|
97
|
+
stats.unshift({ label: "Title", value: metadata.title });
|
|
98
|
+
}
|
|
99
|
+
if (metadata?.durationSeconds) {
|
|
100
|
+
const mins = Math.floor(metadata.durationSeconds / 60);
|
|
101
|
+
const secs = metadata.durationSeconds % 60;
|
|
102
|
+
stats.push({ label: "Duration", value: `${mins}:${String(secs).padStart(2, "0")}` });
|
|
103
|
+
}
|
|
104
|
+
if (metadata?.recordingSizeBytes) {
|
|
105
|
+
const mb = (metadata.recordingSizeBytes / (1024 * 1024)).toFixed(1);
|
|
106
|
+
stats.push({ label: "Size", value: `${mb} MB` });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className={cn(
|
|
112
|
+
"fw-stats-panel absolute top-2 right-2 z-30",
|
|
113
|
+
"bg-black border border-white/10 rounded",
|
|
114
|
+
"text-white text-xs font-mono",
|
|
115
|
+
"max-w-[320px] max-h-[80%] overflow-auto",
|
|
116
|
+
"shadow-lg"
|
|
117
|
+
)}
|
|
118
|
+
style={{ backgroundColor: '#000000' }} // Inline fallback for opaque background
|
|
119
|
+
>
|
|
120
|
+
{/* Header */}
|
|
121
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-white/10">
|
|
122
|
+
<span className="text-white/70 text-[10px] uppercase tracking-wider">
|
|
123
|
+
Stats Overlay
|
|
124
|
+
</span>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={onClose}
|
|
128
|
+
className="text-white/50 hover:text-white transition-colors p-1 -mr-1"
|
|
129
|
+
aria-label="Close stats panel"
|
|
130
|
+
>
|
|
131
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
132
|
+
<path d="M2 2l8 8M10 2l-8 8" />
|
|
133
|
+
</svg>
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Stats grid */}
|
|
138
|
+
<div className="px-3 py-2 space-y-1">
|
|
139
|
+
{stats.map(({ label, value }) => (
|
|
140
|
+
<div key={label} className="flex justify-between gap-4">
|
|
141
|
+
<span className="text-white/50 shrink-0">{label}</span>
|
|
142
|
+
<span className="text-white/90 truncate text-right">{value}</span>
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export default StatsPanel;
|