@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.
Files changed (169) hide show
  1. package/dist/DevModePanel.svelte +650 -0
  2. package/dist/DevModePanel.svelte.d.ts +31 -0
  3. package/dist/DvdLogo.svelte +213 -0
  4. package/dist/DvdLogo.svelte.d.ts +7 -0
  5. package/dist/Icons.svelte +27 -0
  6. package/dist/Icons.svelte.d.ts +25 -0
  7. package/dist/IdleScreen.svelte +752 -0
  8. package/dist/IdleScreen.svelte.d.ts +11 -0
  9. package/dist/LoadingScreen.svelte +689 -0
  10. package/dist/LoadingScreen.svelte.d.ts +7 -0
  11. package/dist/Player.svelte +482 -0
  12. package/dist/Player.svelte.d.ts +26 -0
  13. package/dist/PlayerControls.svelte +739 -0
  14. package/dist/PlayerControls.svelte.d.ts +20 -0
  15. package/dist/SeekBar.svelte +274 -0
  16. package/dist/SeekBar.svelte.d.ts +25 -0
  17. package/dist/SkipIndicator.svelte +95 -0
  18. package/dist/SkipIndicator.svelte.d.ts +14 -0
  19. package/dist/SpeedIndicator.svelte +38 -0
  20. package/dist/SpeedIndicator.svelte.d.ts +8 -0
  21. package/dist/StatsPanel.svelte +155 -0
  22. package/dist/StatsPanel.svelte.d.ts +27 -0
  23. package/dist/StreamStateOverlay.svelte +266 -0
  24. package/dist/StreamStateOverlay.svelte.d.ts +18 -0
  25. package/dist/SubtitleRenderer.svelte +234 -0
  26. package/dist/SubtitleRenderer.svelte.d.ts +41 -0
  27. package/dist/ThumbnailOverlay.svelte +96 -0
  28. package/dist/ThumbnailOverlay.svelte.d.ts +11 -0
  29. package/dist/TitleOverlay.svelte +47 -0
  30. package/dist/TitleOverlay.svelte.d.ts +9 -0
  31. package/dist/assets/logomark.svg +56 -0
  32. package/dist/components/VolumeIcons.svelte +53 -0
  33. package/dist/components/VolumeIcons.svelte.d.ts +10 -0
  34. package/dist/global.d.ts +15 -0
  35. package/dist/icons/FullscreenExitIcon.svelte +33 -0
  36. package/dist/icons/FullscreenExitIcon.svelte.d.ts +8 -0
  37. package/dist/icons/FullscreenIcon.svelte +33 -0
  38. package/dist/icons/FullscreenIcon.svelte.d.ts +8 -0
  39. package/dist/icons/PauseIcon.svelte +28 -0
  40. package/dist/icons/PauseIcon.svelte.d.ts +8 -0
  41. package/dist/icons/PictureInPictureIcon.svelte +28 -0
  42. package/dist/icons/PictureInPictureIcon.svelte.d.ts +8 -0
  43. package/dist/icons/PlayIcon.svelte +27 -0
  44. package/dist/icons/PlayIcon.svelte.d.ts +8 -0
  45. package/dist/icons/SeekToLiveIcon.svelte +30 -0
  46. package/dist/icons/SeekToLiveIcon.svelte.d.ts +8 -0
  47. package/dist/icons/SettingsIcon.svelte +40 -0
  48. package/dist/icons/SettingsIcon.svelte.d.ts +8 -0
  49. package/dist/icons/SkipBackIcon.svelte +32 -0
  50. package/dist/icons/SkipBackIcon.svelte.d.ts +8 -0
  51. package/dist/icons/SkipForwardIcon.svelte +32 -0
  52. package/dist/icons/SkipForwardIcon.svelte.d.ts +8 -0
  53. package/dist/icons/StatsIcon.svelte +29 -0
  54. package/dist/icons/StatsIcon.svelte.d.ts +8 -0
  55. package/dist/icons/VolumeOffIcon.svelte +29 -0
  56. package/dist/icons/VolumeOffIcon.svelte.d.ts +8 -0
  57. package/dist/icons/VolumeUpIcon.svelte +34 -0
  58. package/dist/icons/VolumeUpIcon.svelte.d.ts +8 -0
  59. package/dist/icons/index.d.ts +17 -0
  60. package/dist/icons/index.js +17 -0
  61. package/dist/index.d.ts +50 -0
  62. package/dist/index.js +54 -0
  63. package/dist/player.css +2 -0
  64. package/dist/stores/index.d.ts +15 -0
  65. package/dist/stores/index.js +21 -0
  66. package/dist/stores/playbackQuality.d.ts +43 -0
  67. package/dist/stores/playbackQuality.js +107 -0
  68. package/dist/stores/playerContext.d.ts +73 -0
  69. package/dist/stores/playerContext.js +166 -0
  70. package/dist/stores/playerController.d.ts +178 -0
  71. package/dist/stores/playerController.js +358 -0
  72. package/dist/stores/playerSelection.d.ts +84 -0
  73. package/dist/stores/playerSelection.js +159 -0
  74. package/dist/stores/streamState.d.ts +44 -0
  75. package/dist/stores/streamState.js +314 -0
  76. package/dist/stores/viewerEndpoints.d.ts +48 -0
  77. package/dist/stores/viewerEndpoints.js +178 -0
  78. package/dist/types.d.ts +4 -0
  79. package/dist/types.js +4 -0
  80. package/dist/ui/Badge.svelte +21 -0
  81. package/dist/ui/Badge.svelte.d.ts +32 -0
  82. package/dist/ui/Button.svelte +42 -0
  83. package/dist/ui/Button.svelte.d.ts +35 -0
  84. package/dist/ui/Slider.svelte +100 -0
  85. package/dist/ui/Slider.svelte.d.ts +17 -0
  86. package/dist/ui/badge.d.ts +6 -0
  87. package/dist/ui/badge.js +10 -0
  88. package/dist/ui/button.d.ts +8 -0
  89. package/dist/ui/button.js +21 -0
  90. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
  91. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +31 -0
  92. package/dist/ui/context-menu/ContextMenuContent.svelte +17 -0
  93. package/dist/ui/context-menu/ContextMenuContent.svelte.d.ts +7 -0
  94. package/dist/ui/context-menu/ContextMenuItem.svelte +22 -0
  95. package/dist/ui/context-menu/ContextMenuItem.svelte.d.ts +8 -0
  96. package/dist/ui/context-menu/ContextMenuLabel.svelte +22 -0
  97. package/dist/ui/context-menu/ContextMenuLabel.svelte.d.ts +8 -0
  98. package/dist/ui/context-menu/ContextMenuPortal.svelte +11 -0
  99. package/dist/ui/context-menu/ContextMenuPortal.svelte.d.ts +6 -0
  100. package/dist/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
  101. package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +31 -0
  102. package/dist/ui/context-menu/ContextMenuSeparator.svelte +14 -0
  103. package/dist/ui/context-menu/ContextMenuSeparator.svelte.d.ts +6 -0
  104. package/dist/ui/context-menu/ContextMenuShortcut.svelte +19 -0
  105. package/dist/ui/context-menu/ContextMenuShortcut.svelte.d.ts +7 -0
  106. package/dist/ui/context-menu/ContextMenuSubContent.svelte +20 -0
  107. package/dist/ui/context-menu/ContextMenuSubContent.svelte.d.ts +7 -0
  108. package/dist/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
  109. package/dist/ui/context-menu/ContextMenuSubTrigger.svelte.d.ts +8 -0
  110. package/dist/ui/context-menu/index.d.ts +17 -0
  111. package/dist/ui/context-menu/index.js +17 -0
  112. package/package.json +51 -0
  113. package/src/DevModePanel.svelte +650 -0
  114. package/src/DvdLogo.svelte +213 -0
  115. package/src/Icons.svelte +27 -0
  116. package/src/IdleScreen.svelte +739 -0
  117. package/src/LoadingScreen.svelte +674 -0
  118. package/src/Player.svelte +483 -0
  119. package/src/PlayerControls.svelte +752 -0
  120. package/src/SeekBar.svelte +274 -0
  121. package/src/SkipIndicator.svelte +95 -0
  122. package/src/SpeedIndicator.svelte +37 -0
  123. package/src/StatsPanel.svelte +155 -0
  124. package/src/StreamStateOverlay.svelte +266 -0
  125. package/src/SubtitleRenderer.svelte +234 -0
  126. package/src/ThumbnailOverlay.svelte +96 -0
  127. package/src/TitleOverlay.svelte +47 -0
  128. package/src/assets/logomark.svg +56 -0
  129. package/src/components/VolumeIcons.svelte +53 -0
  130. package/src/global.d.ts +15 -0
  131. package/src/icons/FullscreenExitIcon.svelte +33 -0
  132. package/src/icons/FullscreenIcon.svelte +33 -0
  133. package/src/icons/PauseIcon.svelte +28 -0
  134. package/src/icons/PictureInPictureIcon.svelte +28 -0
  135. package/src/icons/PlayIcon.svelte +27 -0
  136. package/src/icons/SeekToLiveIcon.svelte +30 -0
  137. package/src/icons/SettingsIcon.svelte +40 -0
  138. package/src/icons/SkipBackIcon.svelte +32 -0
  139. package/src/icons/SkipForwardIcon.svelte +32 -0
  140. package/src/icons/StatsIcon.svelte +29 -0
  141. package/src/icons/VolumeOffIcon.svelte +29 -0
  142. package/src/icons/VolumeUpIcon.svelte +34 -0
  143. package/src/icons/index.ts +18 -0
  144. package/src/index.ts +84 -0
  145. package/src/player.css +2 -0
  146. package/src/stores/index.ts +88 -0
  147. package/src/stores/playbackQuality.ts +137 -0
  148. package/src/stores/playerContext.ts +221 -0
  149. package/src/stores/playerController.ts +568 -0
  150. package/src/stores/playerSelection.ts +216 -0
  151. package/src/stores/streamState.ts +367 -0
  152. package/src/stores/viewerEndpoints.ts +224 -0
  153. package/src/types.ts +6 -0
  154. package/src/ui/Badge.svelte +21 -0
  155. package/src/ui/Button.svelte +42 -0
  156. package/src/ui/Slider.svelte +100 -0
  157. package/src/ui/badge.ts +20 -0
  158. package/src/ui/button.ts +35 -0
  159. package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
  160. package/src/ui/context-menu/ContextMenuContent.svelte +17 -0
  161. package/src/ui/context-menu/ContextMenuItem.svelte +22 -0
  162. package/src/ui/context-menu/ContextMenuLabel.svelte +22 -0
  163. package/src/ui/context-menu/ContextMenuPortal.svelte +11 -0
  164. package/src/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
  165. package/src/ui/context-menu/ContextMenuSeparator.svelte +14 -0
  166. package/src/ui/context-menu/ContextMenuShortcut.svelte +19 -0
  167. package/src/ui/context-menu/ContextMenuSubContent.svelte +20 -0
  168. package/src/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
  169. 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}