@livepeer-frameworks/player-svelte 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/DevModePanel.svelte +650 -0
- package/dist/DevModePanel.svelte.d.ts +31 -0
- package/dist/DvdLogo.svelte +213 -0
- package/dist/DvdLogo.svelte.d.ts +7 -0
- package/dist/Icons.svelte +27 -0
- package/dist/Icons.svelte.d.ts +25 -0
- package/dist/IdleScreen.svelte +752 -0
- package/dist/IdleScreen.svelte.d.ts +11 -0
- package/dist/LoadingScreen.svelte +689 -0
- package/dist/LoadingScreen.svelte.d.ts +7 -0
- package/dist/Player.svelte +482 -0
- package/dist/Player.svelte.d.ts +26 -0
- package/dist/PlayerControls.svelte +739 -0
- package/dist/PlayerControls.svelte.d.ts +20 -0
- package/dist/SeekBar.svelte +274 -0
- package/dist/SeekBar.svelte.d.ts +25 -0
- package/dist/SkipIndicator.svelte +95 -0
- package/dist/SkipIndicator.svelte.d.ts +14 -0
- package/dist/SpeedIndicator.svelte +38 -0
- package/dist/SpeedIndicator.svelte.d.ts +8 -0
- package/dist/StatsPanel.svelte +155 -0
- package/dist/StatsPanel.svelte.d.ts +27 -0
- package/dist/StreamStateOverlay.svelte +266 -0
- package/dist/StreamStateOverlay.svelte.d.ts +18 -0
- package/dist/SubtitleRenderer.svelte +234 -0
- package/dist/SubtitleRenderer.svelte.d.ts +41 -0
- package/dist/ThumbnailOverlay.svelte +96 -0
- package/dist/ThumbnailOverlay.svelte.d.ts +11 -0
- package/dist/TitleOverlay.svelte +47 -0
- package/dist/TitleOverlay.svelte.d.ts +9 -0
- package/dist/assets/logomark.svg +56 -0
- package/dist/components/VolumeIcons.svelte +53 -0
- package/dist/components/VolumeIcons.svelte.d.ts +10 -0
- package/dist/global.d.ts +15 -0
- package/dist/icons/FullscreenExitIcon.svelte +33 -0
- package/dist/icons/FullscreenExitIcon.svelte.d.ts +8 -0
- package/dist/icons/FullscreenIcon.svelte +33 -0
- package/dist/icons/FullscreenIcon.svelte.d.ts +8 -0
- package/dist/icons/PauseIcon.svelte +28 -0
- package/dist/icons/PauseIcon.svelte.d.ts +8 -0
- package/dist/icons/PictureInPictureIcon.svelte +28 -0
- package/dist/icons/PictureInPictureIcon.svelte.d.ts +8 -0
- package/dist/icons/PlayIcon.svelte +27 -0
- package/dist/icons/PlayIcon.svelte.d.ts +8 -0
- package/dist/icons/SeekToLiveIcon.svelte +30 -0
- package/dist/icons/SeekToLiveIcon.svelte.d.ts +8 -0
- package/dist/icons/SettingsIcon.svelte +40 -0
- package/dist/icons/SettingsIcon.svelte.d.ts +8 -0
- package/dist/icons/SkipBackIcon.svelte +32 -0
- package/dist/icons/SkipBackIcon.svelte.d.ts +8 -0
- package/dist/icons/SkipForwardIcon.svelte +32 -0
- package/dist/icons/SkipForwardIcon.svelte.d.ts +8 -0
- package/dist/icons/StatsIcon.svelte +29 -0
- package/dist/icons/StatsIcon.svelte.d.ts +8 -0
- package/dist/icons/VolumeOffIcon.svelte +29 -0
- package/dist/icons/VolumeOffIcon.svelte.d.ts +8 -0
- package/dist/icons/VolumeUpIcon.svelte +34 -0
- package/dist/icons/VolumeUpIcon.svelte.d.ts +8 -0
- package/dist/icons/index.d.ts +17 -0
- package/dist/icons/index.js +17 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +54 -0
- package/dist/player.css +2 -0
- package/dist/stores/index.d.ts +15 -0
- package/dist/stores/index.js +21 -0
- package/dist/stores/playbackQuality.d.ts +43 -0
- package/dist/stores/playbackQuality.js +107 -0
- package/dist/stores/playerContext.d.ts +73 -0
- package/dist/stores/playerContext.js +166 -0
- package/dist/stores/playerController.d.ts +178 -0
- package/dist/stores/playerController.js +358 -0
- package/dist/stores/playerSelection.d.ts +84 -0
- package/dist/stores/playerSelection.js +159 -0
- package/dist/stores/streamState.d.ts +44 -0
- package/dist/stores/streamState.js +314 -0
- package/dist/stores/viewerEndpoints.d.ts +48 -0
- package/dist/stores/viewerEndpoints.js +178 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +4 -0
- package/dist/ui/Badge.svelte +21 -0
- package/dist/ui/Badge.svelte.d.ts +32 -0
- package/dist/ui/Button.svelte +42 -0
- package/dist/ui/Button.svelte.d.ts +35 -0
- package/dist/ui/Slider.svelte +100 -0
- package/dist/ui/Slider.svelte.d.ts +17 -0
- package/dist/ui/badge.d.ts +6 -0
- package/dist/ui/badge.js +10 -0
- package/dist/ui/button.d.ts +8 -0
- package/dist/ui/button.js +21 -0
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +31 -0
- package/dist/ui/context-menu/ContextMenuContent.svelte +17 -0
- package/dist/ui/context-menu/ContextMenuContent.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuItem.svelte +22 -0
- package/dist/ui/context-menu/ContextMenuItem.svelte.d.ts +8 -0
- package/dist/ui/context-menu/ContextMenuLabel.svelte +22 -0
- package/dist/ui/context-menu/ContextMenuLabel.svelte.d.ts +8 -0
- package/dist/ui/context-menu/ContextMenuPortal.svelte +11 -0
- package/dist/ui/context-menu/ContextMenuPortal.svelte.d.ts +6 -0
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +31 -0
- package/dist/ui/context-menu/ContextMenuSeparator.svelte +14 -0
- package/dist/ui/context-menu/ContextMenuSeparator.svelte.d.ts +6 -0
- package/dist/ui/context-menu/ContextMenuShortcut.svelte +19 -0
- package/dist/ui/context-menu/ContextMenuShortcut.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuSubContent.svelte +20 -0
- package/dist/ui/context-menu/ContextMenuSubContent.svelte.d.ts +7 -0
- package/dist/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
- package/dist/ui/context-menu/ContextMenuSubTrigger.svelte.d.ts +8 -0
- package/dist/ui/context-menu/index.d.ts +17 -0
- package/dist/ui/context-menu/index.js +17 -0
- package/package.json +51 -0
- package/src/DevModePanel.svelte +650 -0
- package/src/DvdLogo.svelte +213 -0
- package/src/Icons.svelte +27 -0
- package/src/IdleScreen.svelte +739 -0
- package/src/LoadingScreen.svelte +674 -0
- package/src/Player.svelte +483 -0
- package/src/PlayerControls.svelte +752 -0
- package/src/SeekBar.svelte +274 -0
- package/src/SkipIndicator.svelte +95 -0
- package/src/SpeedIndicator.svelte +37 -0
- package/src/StatsPanel.svelte +155 -0
- package/src/StreamStateOverlay.svelte +266 -0
- package/src/SubtitleRenderer.svelte +234 -0
- package/src/ThumbnailOverlay.svelte +96 -0
- package/src/TitleOverlay.svelte +47 -0
- package/src/assets/logomark.svg +56 -0
- package/src/components/VolumeIcons.svelte +53 -0
- package/src/global.d.ts +15 -0
- package/src/icons/FullscreenExitIcon.svelte +33 -0
- package/src/icons/FullscreenIcon.svelte +33 -0
- package/src/icons/PauseIcon.svelte +28 -0
- package/src/icons/PictureInPictureIcon.svelte +28 -0
- package/src/icons/PlayIcon.svelte +27 -0
- package/src/icons/SeekToLiveIcon.svelte +30 -0
- package/src/icons/SettingsIcon.svelte +40 -0
- package/src/icons/SkipBackIcon.svelte +32 -0
- package/src/icons/SkipForwardIcon.svelte +32 -0
- package/src/icons/StatsIcon.svelte +29 -0
- package/src/icons/VolumeOffIcon.svelte +29 -0
- package/src/icons/VolumeUpIcon.svelte +34 -0
- package/src/icons/index.ts +18 -0
- package/src/index.ts +84 -0
- package/src/player.css +2 -0
- package/src/stores/index.ts +88 -0
- package/src/stores/playbackQuality.ts +137 -0
- package/src/stores/playerContext.ts +221 -0
- package/src/stores/playerController.ts +568 -0
- package/src/stores/playerSelection.ts +216 -0
- package/src/stores/streamState.ts +367 -0
- package/src/stores/viewerEndpoints.ts +224 -0
- package/src/types.ts +6 -0
- package/src/ui/Badge.svelte +21 -0
- package/src/ui/Button.svelte +42 -0
- package/src/ui/Slider.svelte +100 -0
- package/src/ui/badge.ts +20 -0
- package/src/ui/button.ts +35 -0
- package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
- package/src/ui/context-menu/ContextMenuContent.svelte +17 -0
- package/src/ui/context-menu/ContextMenuItem.svelte +22 -0
- package/src/ui/context-menu/ContextMenuLabel.svelte +22 -0
- package/src/ui/context-menu/ContextMenuPortal.svelte +11 -0
- package/src/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
- package/src/ui/context-menu/ContextMenuSeparator.svelte +14 -0
- package/src/ui/context-menu/ContextMenuShortcut.svelte +19 -0
- package/src/ui/context-menu/ContextMenuSubContent.svelte +20 -0
- package/src/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
- package/src/ui/context-menu/index.ts +36 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SeekBar.svelte - Industry-standard video seek bar
|
|
3
|
+
Port of src/components/SeekBar.tsx
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { cn } from '@livepeer-frameworks/player-core';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Current playback time in seconds */
|
|
10
|
+
currentTime: number;
|
|
11
|
+
/** Total duration in seconds */
|
|
12
|
+
duration: number;
|
|
13
|
+
/** Buffered time ranges from video element */
|
|
14
|
+
buffered?: TimeRanges;
|
|
15
|
+
/** Whether seeking is allowed */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Called when user seeks to a new time */
|
|
18
|
+
onseek?: (time: number) => void;
|
|
19
|
+
/** Additional class names */
|
|
20
|
+
class?: string;
|
|
21
|
+
/** Whether this is a live stream */
|
|
22
|
+
isLive?: boolean;
|
|
23
|
+
/** For live: start of seekable DVR window (seconds) */
|
|
24
|
+
seekableStart?: number;
|
|
25
|
+
/** For live: current live edge position (seconds) */
|
|
26
|
+
liveEdge?: number;
|
|
27
|
+
/** Defer seeking until drag release */
|
|
28
|
+
commitOnRelease?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
currentTime,
|
|
33
|
+
duration,
|
|
34
|
+
buffered = undefined,
|
|
35
|
+
disabled = false,
|
|
36
|
+
onseek = undefined,
|
|
37
|
+
class: className = '',
|
|
38
|
+
isLive = false,
|
|
39
|
+
seekableStart = 0,
|
|
40
|
+
liveEdge = undefined,
|
|
41
|
+
commitOnRelease = false,
|
|
42
|
+
}: Props = $props();
|
|
43
|
+
|
|
44
|
+
// Refs
|
|
45
|
+
let trackRef: HTMLDivElement | undefined = $state();
|
|
46
|
+
|
|
47
|
+
// Local state
|
|
48
|
+
let isHovering = $state(false);
|
|
49
|
+
let isDragging = $state(false);
|
|
50
|
+
let dragTime = $state<number | null>(null);
|
|
51
|
+
let hoverPosition = $state(0);
|
|
52
|
+
let hoverTime = $state(0);
|
|
53
|
+
|
|
54
|
+
// Effective live edge (use provided or fall back to duration)
|
|
55
|
+
let effectiveLiveEdge = $derived(liveEdge ?? duration);
|
|
56
|
+
|
|
57
|
+
// Seekable window size
|
|
58
|
+
let seekableWindow = $derived(effectiveLiveEdge - seekableStart);
|
|
59
|
+
|
|
60
|
+
// Calculate progress percentage
|
|
61
|
+
let displayTime = $derived(dragTime ?? currentTime);
|
|
62
|
+
let progressPercent = $derived.by(() => {
|
|
63
|
+
if (isLive && seekableWindow > 0) {
|
|
64
|
+
const positionInWindow = displayTime - seekableStart;
|
|
65
|
+
return Math.min(100, Math.max(0, (positionInWindow / seekableWindow) * 100));
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isFinite(duration) || duration <= 0) return 0;
|
|
68
|
+
return Math.min(100, Math.max(0, (displayTime / duration) * 100));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Calculate buffered segments as array of {start%, end%}
|
|
72
|
+
let bufferedSegments = $derived.by(() => {
|
|
73
|
+
if (!buffered || buffered.length === 0) return [];
|
|
74
|
+
|
|
75
|
+
const rangeEnd = isLive ? effectiveLiveEdge : duration;
|
|
76
|
+
const rangeStart = isLive ? seekableStart : 0;
|
|
77
|
+
const rangeSize = rangeEnd - rangeStart;
|
|
78
|
+
|
|
79
|
+
if (!Number.isFinite(rangeSize) || rangeSize <= 0) return [];
|
|
80
|
+
|
|
81
|
+
const segments: Array<{ startPercent: number; endPercent: number }> = [];
|
|
82
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
83
|
+
const start = buffered.start(i);
|
|
84
|
+
const end = buffered.end(i);
|
|
85
|
+
|
|
86
|
+
const relativeStart = start - rangeStart;
|
|
87
|
+
const relativeEnd = end - rangeStart;
|
|
88
|
+
|
|
89
|
+
segments.push({
|
|
90
|
+
startPercent: Math.min(100, Math.max(0, (relativeStart / rangeSize) * 100)),
|
|
91
|
+
endPercent: Math.min(100, Math.max(0, (relativeEnd / rangeSize) * 100)),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return segments;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Format time as MM:SS or HH:MM:SS
|
|
98
|
+
function formatTime(seconds: number): string {
|
|
99
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
|
100
|
+
const total = Math.floor(seconds);
|
|
101
|
+
const hours = Math.floor(total / 3600);
|
|
102
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
103
|
+
const secs = total % 60;
|
|
104
|
+
if (hours > 0) {
|
|
105
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
106
|
+
}
|
|
107
|
+
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Format relative time for live streams
|
|
111
|
+
function formatLiveTime(seconds: number, edge: number): string {
|
|
112
|
+
const behindSeconds = edge - seconds;
|
|
113
|
+
if (behindSeconds < 1) return 'LIVE';
|
|
114
|
+
const total = Math.floor(behindSeconds);
|
|
115
|
+
const hours = Math.floor(total / 3600);
|
|
116
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
117
|
+
const secs = total % 60;
|
|
118
|
+
if (hours > 0) {
|
|
119
|
+
return `-${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
120
|
+
}
|
|
121
|
+
return `-${minutes}:${String(secs).padStart(2, '0')}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Calculate time from mouse position
|
|
125
|
+
function getTimeFromPosition(clientX: number): number {
|
|
126
|
+
if (!trackRef) return 0;
|
|
127
|
+
const rect = trackRef.getBoundingClientRect();
|
|
128
|
+
const x = clientX - rect.left;
|
|
129
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
130
|
+
|
|
131
|
+
// Live with valid seekable window
|
|
132
|
+
if (isLive && Number.isFinite(seekableWindow) && seekableWindow > 0) {
|
|
133
|
+
return seekableStart + (percent * seekableWindow);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// VOD with finite duration
|
|
137
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
138
|
+
return percent * duration;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fallback: If we have liveEdge, use it even if not marked as live
|
|
142
|
+
// This handles cases where duration is Infinity but we have valid seekable data
|
|
143
|
+
if (liveEdge !== undefined && Number.isFinite(liveEdge) && liveEdge > 0) {
|
|
144
|
+
const start = Number.isFinite(seekableStart) ? seekableStart : 0;
|
|
145
|
+
const window = liveEdge - start;
|
|
146
|
+
if (window > 0) {
|
|
147
|
+
return start + (percent * window);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Last resort: use currentTime as a baseline
|
|
152
|
+
return percent * (currentTime || 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle mouse move for hover preview
|
|
156
|
+
function handleMouseMove(e: MouseEvent) {
|
|
157
|
+
if (!trackRef || disabled) return;
|
|
158
|
+
const rect = trackRef.getBoundingClientRect();
|
|
159
|
+
const x = e.clientX - rect.left;
|
|
160
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
161
|
+
hoverPosition = percent * 100;
|
|
162
|
+
hoverTime = getTimeFromPosition(e.clientX);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle click to seek
|
|
166
|
+
function handleClick(e: MouseEvent) {
|
|
167
|
+
if (disabled) return;
|
|
168
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
169
|
+
const time = getTimeFromPosition(e.clientX);
|
|
170
|
+
onseek?.(time);
|
|
171
|
+
dragTime = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Handle drag start
|
|
175
|
+
function handleMouseDown(e: MouseEvent) {
|
|
176
|
+
if (disabled) return;
|
|
177
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
isDragging = true;
|
|
180
|
+
|
|
181
|
+
const handleDragMove = (moveEvent: MouseEvent) => {
|
|
182
|
+
const time = getTimeFromPosition(moveEvent.clientX);
|
|
183
|
+
if (commitOnRelease) {
|
|
184
|
+
dragTime = time;
|
|
185
|
+
} else {
|
|
186
|
+
onseek?.(time);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleDragEnd = () => {
|
|
191
|
+
isDragging = false;
|
|
192
|
+
document.removeEventListener('mousemove', handleDragMove);
|
|
193
|
+
document.removeEventListener('mouseup', handleDragEnd);
|
|
194
|
+
if (commitOnRelease && dragTime !== null) {
|
|
195
|
+
onseek?.(dragTime);
|
|
196
|
+
dragTime = null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
document.addEventListener('mousemove', handleDragMove);
|
|
201
|
+
document.addEventListener('mouseup', handleDragEnd);
|
|
202
|
+
|
|
203
|
+
// Initial seek
|
|
204
|
+
const time = getTimeFromPosition(e.clientX);
|
|
205
|
+
if (commitOnRelease) {
|
|
206
|
+
dragTime = time;
|
|
207
|
+
} else {
|
|
208
|
+
onseek?.(time);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let showThumb = $derived(isHovering || isDragging);
|
|
213
|
+
let canShowTooltip = $derived(isLive ? seekableWindow > 0 : Number.isFinite(duration));
|
|
214
|
+
let ariaValueText = $derived(isLive ? formatLiveTime(displayTime, effectiveLiveEdge) : formatTime(displayTime));
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<div
|
|
218
|
+
bind:this={trackRef}
|
|
219
|
+
class={cn(
|
|
220
|
+
'group relative w-full h-6 flex items-center cursor-pointer',
|
|
221
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
222
|
+
className
|
|
223
|
+
)}
|
|
224
|
+
onmouseenter={() => !disabled && (isHovering = true)}
|
|
225
|
+
onmouseleave={() => { isHovering = false; isDragging = false; }}
|
|
226
|
+
onmousemove={handleMouseMove}
|
|
227
|
+
onclick={handleClick}
|
|
228
|
+
onmousedown={handleMouseDown}
|
|
229
|
+
role="slider"
|
|
230
|
+
aria-label="Seek"
|
|
231
|
+
aria-valuemin={isLive ? seekableStart : 0}
|
|
232
|
+
aria-valuemax={isLive ? effectiveLiveEdge : (duration || 100)}
|
|
233
|
+
aria-valuenow={displayTime}
|
|
234
|
+
aria-valuetext={ariaValueText}
|
|
235
|
+
tabindex={disabled ? -1 : 0}
|
|
236
|
+
>
|
|
237
|
+
<!-- Track background -->
|
|
238
|
+
<div class={cn(
|
|
239
|
+
'fw-seek-track',
|
|
240
|
+
isDragging && 'fw-seek-track--active'
|
|
241
|
+
)}>
|
|
242
|
+
<!-- Buffered segments -->
|
|
243
|
+
{#each bufferedSegments as segment, index}
|
|
244
|
+
<div
|
|
245
|
+
class="fw-seek-buffered"
|
|
246
|
+
style="left: {segment.startPercent}%; width: {segment.endPercent - segment.startPercent}%;"
|
|
247
|
+
></div>
|
|
248
|
+
{/each}
|
|
249
|
+
<!-- Playback progress -->
|
|
250
|
+
<div
|
|
251
|
+
class="fw-seek-progress"
|
|
252
|
+
style="width: {progressPercent}%;"
|
|
253
|
+
></div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- Thumb -->
|
|
257
|
+
<div
|
|
258
|
+
class={cn(
|
|
259
|
+
'fw-seek-thumb',
|
|
260
|
+
showThumb ? 'fw-seek-thumb--active' : 'fw-seek-thumb--hidden'
|
|
261
|
+
)}
|
|
262
|
+
style="left: {progressPercent}%;"
|
|
263
|
+
></div>
|
|
264
|
+
|
|
265
|
+
<!-- Hover time tooltip -->
|
|
266
|
+
{#if isHovering && !isDragging && canShowTooltip}
|
|
267
|
+
<div
|
|
268
|
+
class="fw-seek-tooltip"
|
|
269
|
+
style="left: {hoverPosition}%;"
|
|
270
|
+
>
|
|
271
|
+
{isLive ? formatLiveTime(hoverTime, effectiveLiveEdge) : formatTime(hoverTime)}
|
|
272
|
+
</div>
|
|
273
|
+
{/if}
|
|
274
|
+
</div>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Skip indicator overlay that appears when double-tapping to skip.
|
|
6
|
+
* Shows the skip direction and amount (e.g., "-10s" or "+10s") with a ripple effect.
|
|
7
|
+
*/
|
|
8
|
+
export type SkipDirection = 'back' | 'forward' | null;
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
direction = null as SkipDirection,
|
|
12
|
+
seconds = 10,
|
|
13
|
+
class: className = '',
|
|
14
|
+
onhide = undefined as (() => void) | undefined,
|
|
15
|
+
}: {
|
|
16
|
+
direction: SkipDirection;
|
|
17
|
+
seconds?: number;
|
|
18
|
+
class?: string;
|
|
19
|
+
onhide?: () => void;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let isAnimating = $state(false);
|
|
23
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
|
|
25
|
+
// Trigger animation when direction changes
|
|
26
|
+
$effect(() => {
|
|
27
|
+
if (direction) {
|
|
28
|
+
isAnimating = true;
|
|
29
|
+
|
|
30
|
+
if (hideTimer) {
|
|
31
|
+
clearTimeout(hideTimer);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hideTimer = setTimeout(() => {
|
|
35
|
+
isAnimating = false;
|
|
36
|
+
onhide?.();
|
|
37
|
+
}, 600);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
onMount(() => {
|
|
42
|
+
return () => {
|
|
43
|
+
if (hideTimer) {
|
|
44
|
+
clearTimeout(hideTimer);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let isBack = $derived(direction === 'back');
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
{#if direction}
|
|
53
|
+
<div
|
|
54
|
+
class="fw-skip-indicator absolute inset-0 z-30 pointer-events-none flex items-center
|
|
55
|
+
{isBack ? 'justify-start pl-8' : 'justify-end pr-8'}
|
|
56
|
+
{className}"
|
|
57
|
+
>
|
|
58
|
+
<!-- Ripple background -->
|
|
59
|
+
<div
|
|
60
|
+
class="absolute top-0 bottom-0 w-1/3 bg-white/10
|
|
61
|
+
{isBack ? 'left-0' : 'right-0'}
|
|
62
|
+
{isAnimating ? 'animate-pulse' : ''}"
|
|
63
|
+
></div>
|
|
64
|
+
|
|
65
|
+
<!-- Skip content -->
|
|
66
|
+
<div
|
|
67
|
+
class="relative flex flex-col items-center gap-1 text-white transition-all duration-200
|
|
68
|
+
{isAnimating ? 'opacity-100 scale-100' : 'opacity-0 scale-75'}"
|
|
69
|
+
>
|
|
70
|
+
<!-- Icon -->
|
|
71
|
+
<div class="flex">
|
|
72
|
+
{#if isBack}
|
|
73
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8" aria-hidden="true">
|
|
74
|
+
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
|
75
|
+
</svg>
|
|
76
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 -ml-4" aria-hidden="true">
|
|
77
|
+
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
|
78
|
+
</svg>
|
|
79
|
+
{:else}
|
|
80
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8" aria-hidden="true">
|
|
81
|
+
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
82
|
+
</svg>
|
|
83
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 -ml-4" aria-hidden="true">
|
|
84
|
+
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
85
|
+
</svg>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Text -->
|
|
90
|
+
<span class="text-sm font-semibold tabular-nums">
|
|
91
|
+
{isBack ? `-${seconds}s` : `+${seconds}s`}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{/if}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Speed indicator overlay that appears when holding for fast-forward.
|
|
4
|
+
* Shows the current playback speed (e.g., "2x") in a pill overlay.
|
|
5
|
+
*/
|
|
6
|
+
let {
|
|
7
|
+
isVisible = false,
|
|
8
|
+
speed = 2,
|
|
9
|
+
class: className = '',
|
|
10
|
+
}: {
|
|
11
|
+
isVisible: boolean;
|
|
12
|
+
speed: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
} = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div
|
|
18
|
+
class="fw-speed-indicator absolute top-3 right-3 z-30 pointer-events-none
|
|
19
|
+
transition-opacity duration-150
|
|
20
|
+
{isVisible ? 'opacity-100' : 'opacity-0'}
|
|
21
|
+
{className}"
|
|
22
|
+
>
|
|
23
|
+
<div
|
|
24
|
+
class="bg-black/60 text-white px-2.5 py-1 rounded-md
|
|
25
|
+
text-xs font-semibold tabular-nums
|
|
26
|
+
flex items-center gap-2
|
|
27
|
+
border border-white/15
|
|
28
|
+
transform transition-transform duration-150
|
|
29
|
+
{isVisible ? 'scale-100' : 'scale-90'}"
|
|
30
|
+
>
|
|
31
|
+
<!-- Fast-forward icon -->
|
|
32
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4" aria-hidden="true">
|
|
33
|
+
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
34
|
+
</svg>
|
|
35
|
+
<span>{speed}x</span>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StatsPanel.svelte - "Stats for nerds" debug panel
|
|
3
|
+
Port of src/components/StatsPanel.tsx
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { cn, type ContentMetadata, type PlaybackQuality } from '@livepeer-frameworks/player-core';
|
|
7
|
+
import Button from './ui/Button.svelte';
|
|
8
|
+
|
|
9
|
+
interface StreamStateInfo {
|
|
10
|
+
status?: string;
|
|
11
|
+
viewers?: number;
|
|
12
|
+
tracks?: Array<{
|
|
13
|
+
type: string;
|
|
14
|
+
codec: string;
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
bps?: number;
|
|
18
|
+
channels?: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
metadata?: ContentMetadata | null;
|
|
26
|
+
streamState?: StreamStateInfo | null;
|
|
27
|
+
quality?: PlaybackQuality | null;
|
|
28
|
+
videoElement?: HTMLVideoElement | null;
|
|
29
|
+
protocol?: string;
|
|
30
|
+
nodeId?: string;
|
|
31
|
+
geoDistance?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
isOpen,
|
|
36
|
+
onClose,
|
|
37
|
+
metadata = null,
|
|
38
|
+
streamState = null,
|
|
39
|
+
quality = null,
|
|
40
|
+
videoElement = null,
|
|
41
|
+
protocol = undefined,
|
|
42
|
+
nodeId = undefined,
|
|
43
|
+
geoDistance = undefined,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
// Video element stats (reactive)
|
|
47
|
+
let currentRes = $derived(videoElement ? `${videoElement.videoWidth}x${videoElement.videoHeight}` : '—');
|
|
48
|
+
let buffered = $derived.by(() => {
|
|
49
|
+
if (!videoElement || videoElement.buffered.length === 0) return '—';
|
|
50
|
+
return (videoElement.buffered.end(videoElement.buffered.length - 1) - videoElement.currentTime).toFixed(1);
|
|
51
|
+
});
|
|
52
|
+
let playbackRate = $derived(videoElement?.playbackRate?.toFixed(2) ?? '1.00');
|
|
53
|
+
|
|
54
|
+
// Quality monitor stats
|
|
55
|
+
let qualityScore = $derived(quality?.score?.toFixed(0) ?? '—');
|
|
56
|
+
let bitrateKbps = $derived(quality?.bitrate ? `${(quality.bitrate / 1000).toFixed(0)} kbps` : '—');
|
|
57
|
+
let frameDropRate = $derived(quality?.frameDropRate?.toFixed(1) ?? '—');
|
|
58
|
+
let stallCount = $derived(quality?.stallCount ?? 0);
|
|
59
|
+
let latency = $derived(quality?.latency ? `${Math.round(quality.latency)} ms` : '—');
|
|
60
|
+
|
|
61
|
+
// Stream state stats
|
|
62
|
+
let viewers = $derived(streamState?.viewers ?? metadata?.viewers ?? '—');
|
|
63
|
+
let streamStatus = $derived(streamState?.status ?? metadata?.status ?? '—');
|
|
64
|
+
|
|
65
|
+
// Format track info
|
|
66
|
+
function formatTracks(): string {
|
|
67
|
+
if (!streamState?.tracks?.length) return '—';
|
|
68
|
+
return streamState.tracks.map(t => {
|
|
69
|
+
if (t.type === 'video') {
|
|
70
|
+
return `${t.codec} ${t.width}x${t.height}@${t.bps ? Math.round(t.bps / 1000) + 'kbps' : '?'}`;
|
|
71
|
+
}
|
|
72
|
+
return `${t.codec} ${t.channels}ch`;
|
|
73
|
+
}).join(', ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build stats array
|
|
77
|
+
let stats = $derived.by(() => {
|
|
78
|
+
const result: Array<{ label: string; value: string }> = [];
|
|
79
|
+
|
|
80
|
+
if (metadata?.title) {
|
|
81
|
+
result.push({ label: 'Title', value: metadata.title });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result.push(
|
|
85
|
+
{ label: 'Resolution', value: currentRes },
|
|
86
|
+
{ label: 'Buffer', value: `${buffered}s` },
|
|
87
|
+
{ label: 'Latency', value: latency },
|
|
88
|
+
{ label: 'Bitrate', value: bitrateKbps },
|
|
89
|
+
{ label: 'Quality Score', value: `${qualityScore}/100` },
|
|
90
|
+
{ label: 'Frame Drop Rate', value: `${frameDropRate}%` },
|
|
91
|
+
{ label: 'Stalls', value: String(stallCount) },
|
|
92
|
+
{ label: 'Playback Rate', value: `${playbackRate}x` },
|
|
93
|
+
{ label: 'Protocol', value: protocol ?? '—' },
|
|
94
|
+
{ label: 'Node', value: nodeId ?? '—' },
|
|
95
|
+
{ label: 'Geo Distance', value: geoDistance ? `${geoDistance.toFixed(0)} km` : '—' },
|
|
96
|
+
{ label: 'Viewers', value: String(viewers) },
|
|
97
|
+
{ label: 'Status', value: streamStatus },
|
|
98
|
+
{ label: 'Tracks', value: formatTracks() },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (metadata?.durationSeconds) {
|
|
102
|
+
const mins = Math.floor(metadata.durationSeconds / 60);
|
|
103
|
+
const secs = metadata.durationSeconds % 60;
|
|
104
|
+
result.push({ label: 'Duration', value: `${mins}:${String(secs).padStart(2, '0')}` });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (metadata?.recordingSizeBytes) {
|
|
108
|
+
const mb = (metadata.recordingSizeBytes / (1024 * 1024)).toFixed(1);
|
|
109
|
+
result.push({ label: 'Size', value: `${mb} MB` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result;
|
|
113
|
+
});
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
{#if isOpen}
|
|
117
|
+
<div
|
|
118
|
+
class={cn(
|
|
119
|
+
'fw-stats-panel absolute top-2 right-2 z-30',
|
|
120
|
+
'bg-black border border-white/10 rounded',
|
|
121
|
+
'text-white text-xs font-mono',
|
|
122
|
+
'max-w-[320px] max-h-[80%] overflow-auto',
|
|
123
|
+
'shadow-lg'
|
|
124
|
+
)}
|
|
125
|
+
style="background-color: #000000;"
|
|
126
|
+
>
|
|
127
|
+
<!-- Header -->
|
|
128
|
+
<div class="flex items-center justify-between px-3 py-2 border-b border-white/10">
|
|
129
|
+
<span class="text-white/70 text-[10px] uppercase tracking-wider">
|
|
130
|
+
Stats Overlay
|
|
131
|
+
</span>
|
|
132
|
+
<Button
|
|
133
|
+
type="button"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
onclick={onClose}
|
|
136
|
+
class="text-white/50 hover:text-white transition-colors p-1 -mr-1 h-auto w-auto min-w-0"
|
|
137
|
+
aria-label="Close stats panel"
|
|
138
|
+
>
|
|
139
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
140
|
+
<path d="M2 2l8 8M10 2l-8 8" />
|
|
141
|
+
</svg>
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Stats grid -->
|
|
146
|
+
<div class="px-3 py-2 space-y-1">
|
|
147
|
+
{#each stats as { label, value }}
|
|
148
|
+
<div class="flex justify-between gap-4">
|
|
149
|
+
<span class="text-white/50 shrink-0">{label}</span>
|
|
150
|
+
<span class="text-white/90 truncate text-right">{value}</span>
|
|
151
|
+
</div>
|
|
152
|
+
{/each}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|