@livepeer-frameworks/player-svelte 0.1.1 → 0.1.2
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 +266 -127
- package/dist/DevModePanel.svelte.d.ts +1 -1
- package/dist/DvdLogo.svelte +17 -21
- package/dist/Icons.svelte +5 -3
- package/dist/Icons.svelte.d.ts +6 -19
- package/dist/IdleScreen.svelte +277 -186
- package/dist/IdleScreen.svelte.d.ts +1 -1
- package/dist/LoadingScreen.svelte +190 -162
- package/dist/Player.svelte +244 -111
- package/dist/Player.svelte.d.ts +1 -1
- package/dist/PlayerControls.svelte +263 -168
- package/dist/PlayerControls.svelte.d.ts +1 -1
- package/dist/SeekBar.svelte +61 -35
- package/dist/SkipIndicator.svelte +4 -4
- package/dist/SkipIndicator.svelte.d.ts +1 -1
- package/dist/SpeedIndicator.svelte +1 -1
- package/dist/StatsPanel.svelte +76 -57
- package/dist/StatsPanel.svelte.d.ts +1 -1
- package/dist/StreamStateOverlay.svelte +143 -107
- package/dist/StreamStateOverlay.svelte.d.ts +1 -1
- package/dist/SubtitleRenderer.svelte +46 -43
- package/dist/ThumbnailOverlay.svelte +22 -19
- package/dist/TitleOverlay.svelte +6 -11
- package/dist/components/VolumeIcons.svelte +12 -6
- package/dist/global.d.ts +3 -3
- package/dist/icons/FullscreenExitIcon.svelte +1 -5
- package/dist/icons/FullscreenIcon.svelte +1 -5
- package/dist/icons/PauseIcon.svelte +1 -5
- package/dist/icons/PictureInPictureIcon.svelte +12 -6
- package/dist/icons/PlayIcon.svelte +1 -5
- package/dist/icons/SeekToLiveIcon.svelte +1 -5
- package/dist/icons/SettingsIcon.svelte +1 -5
- package/dist/icons/SkipBackIcon.svelte +1 -5
- package/dist/icons/SkipForwardIcon.svelte +1 -5
- package/dist/icons/StatsIcon.svelte +1 -5
- package/dist/icons/VolumeOffIcon.svelte +1 -5
- package/dist/icons/VolumeUpIcon.svelte +1 -5
- package/dist/icons/index.d.ts +12 -12
- package/dist/icons/index.js +12 -12
- package/dist/index.d.ts +24 -24
- package/dist/index.js +21 -21
- package/dist/stores/index.d.ts +6 -6
- package/dist/stores/index.js +6 -6
- package/dist/stores/playbackQuality.d.ts +2 -2
- package/dist/stores/playbackQuality.js +7 -7
- package/dist/stores/playerContext.d.ts +2 -2
- package/dist/stores/playerContext.js +17 -17
- package/dist/stores/playerController.d.ts +13 -4
- package/dist/stores/playerController.js +80 -56
- package/dist/stores/playerSelection.d.ts +2 -2
- package/dist/stores/playerSelection.js +7 -7
- package/dist/stores/streamState.d.ts +2 -2
- package/dist/stores/streamState.js +56 -56
- package/dist/stores/viewerEndpoints.d.ts +3 -3
- package/dist/stores/viewerEndpoints.js +21 -21
- package/dist/types.d.ts +1 -1
- package/dist/ui/Badge.svelte +9 -10
- package/dist/ui/Badge.svelte.d.ts +8 -29
- package/dist/ui/Button.svelte +16 -16
- package/dist/ui/Button.svelte.d.ts +8 -29
- package/dist/ui/Slider.svelte +21 -55
- package/dist/ui/badge.js +1 -1
- package/dist/ui/button.js +2 -2
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +5 -7
- package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +6 -27
- package/dist/ui/context-menu/ContextMenuContent.svelte +2 -9
- package/dist/ui/context-menu/ContextMenuItem.svelte +1 -5
- package/dist/ui/context-menu/ContextMenuLabel.svelte +1 -5
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte +5 -7
- package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +6 -27
- package/dist/ui/context-menu/ContextMenuSeparator.svelte +2 -8
- package/dist/ui/context-menu/ContextMenuShortcut.svelte +2 -12
- package/dist/ui/context-menu/ContextMenuSubContent.svelte +1 -5
- package/package.json +15 -7
- package/src/DevModePanel.svelte +1 -0
- package/src/Icons.svelte +5 -3
- package/src/IdleScreen.svelte +21 -14
- package/src/LoadingScreen.svelte +20 -13
- package/src/Player.svelte +48 -2
- package/src/PlayerControls.svelte +36 -17
- package/src/SeekBar.svelte +33 -0
- package/src/StreamStateOverlay.svelte +2 -2
- package/src/stores/playerController.ts +39 -1
- package/src/stores/viewerEndpoints.ts +1 -1
- package/src/ui/Badge.svelte +7 -4
- package/src/ui/Button.svelte +13 -13
- package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +4 -2
- package/src/ui/context-menu/ContextMenuRadioItem.svelte +4 -2
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
cn,
|
|
4
|
+
globalPlayerManager,
|
|
5
|
+
type MistStreamInfo,
|
|
6
|
+
type PlaybackMode,
|
|
7
|
+
// Seeking utilities from core
|
|
8
|
+
SPEED_PRESETS,
|
|
9
|
+
isMediaStreamSource,
|
|
10
|
+
supportsPlaybackRate as coreSupportsPlaybackRate,
|
|
11
|
+
calculateSeekableRange,
|
|
12
|
+
canSeekStream,
|
|
13
|
+
calculateLiveThresholds,
|
|
14
|
+
calculateIsNearLive,
|
|
15
|
+
isLiveContent,
|
|
16
|
+
// Time formatting from core
|
|
17
|
+
formatTimeDisplay,
|
|
18
|
+
} from "@livepeer-frameworks/player-core";
|
|
19
|
+
import SeekBar from "./SeekBar.svelte";
|
|
20
|
+
import Slider from "./ui/Slider.svelte";
|
|
21
|
+
import VolumeIcons from "./components/VolumeIcons.svelte";
|
|
22
|
+
import {
|
|
23
|
+
StatsIcon,
|
|
24
|
+
SettingsIcon,
|
|
25
|
+
PlayIcon,
|
|
26
|
+
PauseIcon,
|
|
27
|
+
SkipBackIcon,
|
|
28
|
+
SkipForwardIcon,
|
|
29
|
+
FullscreenIcon,
|
|
30
|
+
FullscreenExitIcon,
|
|
31
|
+
SeekToLiveIcon,
|
|
32
|
+
} from "./icons";
|
|
33
33
|
|
|
34
34
|
// Props - aligned with React PlayerControls
|
|
35
35
|
interface Props {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
onseek = undefined,
|
|
59
59
|
mistStreamInfo = undefined,
|
|
60
60
|
disabled = false,
|
|
61
|
-
playbackMode =
|
|
61
|
+
playbackMode = "auto",
|
|
62
62
|
onModeChange = undefined,
|
|
63
63
|
sourceType = undefined,
|
|
64
64
|
showStatsButton = false,
|
|
@@ -75,8 +75,10 @@
|
|
|
75
75
|
function findVideoElement(): HTMLVideoElement | null {
|
|
76
76
|
const player = globalPlayerManager.getCurrentPlayer();
|
|
77
77
|
if (player?.getVideoElement?.()) return player.getVideoElement();
|
|
78
|
-
return
|
|
79
|
-
|
|
78
|
+
return (
|
|
79
|
+
(document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null) ??
|
|
80
|
+
(document.querySelector(".fw-player-container video") as HTMLVideoElement | null)
|
|
81
|
+
);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
$effect(() => {
|
|
@@ -118,8 +120,8 @@
|
|
|
118
120
|
let isNearLiveState = $state(true);
|
|
119
121
|
let buffered: TimeRanges | undefined = $state(undefined);
|
|
120
122
|
let _hasSeekToLive = false; // Track if we've auto-seeked to live
|
|
121
|
-
let qualityValue = $state(
|
|
122
|
-
let captionValue = $state(
|
|
123
|
+
let qualityValue = $state("auto");
|
|
124
|
+
let captionValue = $state("none");
|
|
123
125
|
|
|
124
126
|
// Text tracks from player
|
|
125
127
|
let textTracks = $derived.by(() => {
|
|
@@ -142,7 +144,7 @@
|
|
|
142
144
|
const mistTracks = mistStreamInfo?.meta?.tracks;
|
|
143
145
|
if (mistTracks) {
|
|
144
146
|
return Object.entries(mistTracks)
|
|
145
|
-
.filter(([, t]) => t.type ===
|
|
147
|
+
.filter(([, t]) => t.type === "video")
|
|
146
148
|
.map(([id, t]) => ({
|
|
147
149
|
id,
|
|
148
150
|
label: t.height ? `${t.height}p` : t.codec,
|
|
@@ -159,17 +161,20 @@
|
|
|
159
161
|
let isVolumeHovered = $state(false);
|
|
160
162
|
let isVolumeFocused = $state(false);
|
|
161
163
|
let isVolumeExpanded = $derived(isVolumeHovered || isVolumeFocused);
|
|
164
|
+
let volumeGroupRef: HTMLDivElement | null = $state(null);
|
|
162
165
|
|
|
163
166
|
// Derived values - using centralized core utilities
|
|
164
167
|
let isLive = $derived(isLiveContent(isContentLive, mistStreamInfo, duration));
|
|
165
168
|
let isWebRTC = $derived(isMediaStreamSource(video));
|
|
166
169
|
let supportsPlaybackRate = $derived(coreSupportsPlaybackRate(video));
|
|
167
|
-
function deriveBufferWindowMs(
|
|
170
|
+
function deriveBufferWindowMs(
|
|
171
|
+
tracks?: Record<string, { firstms?: number; lastms?: number }>
|
|
172
|
+
): number | undefined {
|
|
168
173
|
if (!tracks) return undefined;
|
|
169
174
|
const list = Object.values(tracks);
|
|
170
175
|
if (list.length === 0) return undefined;
|
|
171
|
-
const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
|
|
172
|
-
const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
|
|
176
|
+
const firstmsValues = list.map((t) => t.firstms).filter((v): v is number => v !== undefined);
|
|
177
|
+
const lastmsValues = list.map((t) => t.lastms).filter((v): v is number => v !== undefined);
|
|
173
178
|
if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
|
|
174
179
|
const firstms = Math.max(...firstmsValues);
|
|
175
180
|
const lastms = Math.min(...lastmsValues);
|
|
@@ -179,37 +184,59 @@
|
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
let bufferWindowMs = $derived(
|
|
182
|
-
mistStreamInfo?.meta?.buffer_window
|
|
183
|
-
|
|
187
|
+
mistStreamInfo?.meta?.buffer_window ??
|
|
188
|
+
deriveBufferWindowMs(
|
|
189
|
+
mistStreamInfo?.meta?.tracks as
|
|
190
|
+
| Record<string, { firstms?: number; lastms?: number }>
|
|
191
|
+
| undefined
|
|
192
|
+
)
|
|
184
193
|
);
|
|
185
194
|
|
|
186
195
|
function getPlayerSeekableRange(): { seekableStart: number; liveEdge: number } | null {
|
|
187
196
|
const player = globalPlayerManager.getCurrentPlayer();
|
|
188
|
-
if (player && typeof (player as any).getSeekableRange ===
|
|
197
|
+
if (player && typeof (player as any).getSeekableRange === "function") {
|
|
189
198
|
const range = (player as any).getSeekableRange();
|
|
190
|
-
if (
|
|
199
|
+
if (
|
|
200
|
+
range &&
|
|
201
|
+
Number.isFinite(range.start) &&
|
|
202
|
+
Number.isFinite(range.end) &&
|
|
203
|
+
range.end >= range.start
|
|
204
|
+
) {
|
|
191
205
|
return { seekableStart: range.start, liveEdge: range.end };
|
|
192
206
|
}
|
|
193
207
|
}
|
|
194
208
|
return null;
|
|
195
209
|
}
|
|
196
210
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
let allowMediaStreamDvr = $derived(
|
|
212
|
+
isMediaStreamSource(video) &&
|
|
213
|
+
bufferWindowMs !== undefined &&
|
|
214
|
+
bufferWindowMs > 0 &&
|
|
215
|
+
sourceType !== "whep" &&
|
|
216
|
+
sourceType !== "webrtc"
|
|
217
|
+
);
|
|
200
218
|
|
|
201
219
|
// Seekable range using core calculation (allow player override)
|
|
202
|
-
let seekableRange = $derived.by(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
let seekableRange = $derived.by(
|
|
221
|
+
() =>
|
|
222
|
+
getPlayerSeekableRange() ??
|
|
223
|
+
calculateSeekableRange({
|
|
224
|
+
isLive,
|
|
225
|
+
video,
|
|
226
|
+
mistStreamInfo,
|
|
227
|
+
currentTime,
|
|
228
|
+
duration,
|
|
229
|
+
allowMediaStreamDvr,
|
|
230
|
+
})
|
|
231
|
+
);
|
|
210
232
|
let seekableStart = $derived(seekableRange.seekableStart);
|
|
211
233
|
let liveEdge = $derived(seekableRange.liveEdge);
|
|
212
|
-
let hasDvrWindow = $derived(
|
|
234
|
+
let hasDvrWindow = $derived(
|
|
235
|
+
isLive &&
|
|
236
|
+
Number.isFinite(liveEdge) &&
|
|
237
|
+
Number.isFinite(seekableStart) &&
|
|
238
|
+
liveEdge > seekableStart
|
|
239
|
+
);
|
|
213
240
|
let commitOnRelease = $derived(isLive);
|
|
214
241
|
|
|
215
242
|
// Live thresholds with buffer window scaling
|
|
@@ -219,7 +246,7 @@
|
|
|
219
246
|
let baseCanSeek = $derived.by(() => {
|
|
220
247
|
// Check if current player has canSeek method
|
|
221
248
|
const player = globalPlayerManager.getCurrentPlayer();
|
|
222
|
-
if (player && typeof (player as any).canSeek ===
|
|
249
|
+
if (player && typeof (player as any).canSeek === "function") {
|
|
223
250
|
return (player as any).canSeek();
|
|
224
251
|
}
|
|
225
252
|
// Fallback to core utility logic
|
|
@@ -249,7 +276,9 @@
|
|
|
249
276
|
function updateFullscreenState() {
|
|
250
277
|
isFullscreen = !!document.fullscreenElement;
|
|
251
278
|
}
|
|
252
|
-
function updatePlaybackRate() {
|
|
279
|
+
function updatePlaybackRate() {
|
|
280
|
+
playbackRate = video!.playbackRate;
|
|
281
|
+
}
|
|
253
282
|
function updateBuffered() {
|
|
254
283
|
const player = globalPlayerManager.getCurrentPlayer();
|
|
255
284
|
buffered = player?.getBufferedRanges?.() ?? video!.buffered;
|
|
@@ -261,24 +290,24 @@
|
|
|
261
290
|
updatePlaybackRate();
|
|
262
291
|
updateBuffered();
|
|
263
292
|
|
|
264
|
-
video.addEventListener(
|
|
265
|
-
video.addEventListener(
|
|
266
|
-
video.addEventListener(
|
|
267
|
-
video.addEventListener(
|
|
268
|
-
video.addEventListener(
|
|
269
|
-
video.addEventListener(
|
|
270
|
-
video.addEventListener(
|
|
271
|
-
document.addEventListener(
|
|
293
|
+
video.addEventListener("play", updatePlayingState);
|
|
294
|
+
video.addEventListener("pause", updatePlayingState);
|
|
295
|
+
video.addEventListener("playing", updatePlayingState);
|
|
296
|
+
video.addEventListener("volumechange", updateMutedState);
|
|
297
|
+
video.addEventListener("ratechange", updatePlaybackRate);
|
|
298
|
+
video.addEventListener("progress", updateBuffered);
|
|
299
|
+
video.addEventListener("loadeddata", updateBuffered);
|
|
300
|
+
document.addEventListener("fullscreenchange", updateFullscreenState);
|
|
272
301
|
|
|
273
302
|
return () => {
|
|
274
|
-
video!.removeEventListener(
|
|
275
|
-
video!.removeEventListener(
|
|
276
|
-
video!.removeEventListener(
|
|
277
|
-
video!.removeEventListener(
|
|
278
|
-
video!.removeEventListener(
|
|
279
|
-
video!.removeEventListener(
|
|
280
|
-
video!.removeEventListener(
|
|
281
|
-
document.removeEventListener(
|
|
303
|
+
video!.removeEventListener("play", updatePlayingState);
|
|
304
|
+
video!.removeEventListener("pause", updatePlayingState);
|
|
305
|
+
video!.removeEventListener("playing", updatePlayingState);
|
|
306
|
+
video!.removeEventListener("volumechange", updateMutedState);
|
|
307
|
+
video!.removeEventListener("ratechange", updatePlaybackRate);
|
|
308
|
+
video!.removeEventListener("progress", updateBuffered);
|
|
309
|
+
video!.removeEventListener("loadeddata", updateBuffered);
|
|
310
|
+
document.removeEventListener("fullscreenchange", updateFullscreenState);
|
|
282
311
|
};
|
|
283
312
|
});
|
|
284
313
|
|
|
@@ -299,14 +328,16 @@
|
|
|
299
328
|
});
|
|
300
329
|
|
|
301
330
|
// Time display - using core formatTimeDisplay
|
|
302
|
-
let timeDisplay = $derived(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
331
|
+
let timeDisplay = $derived(
|
|
332
|
+
formatTimeDisplay({
|
|
333
|
+
isLive,
|
|
334
|
+
currentTime,
|
|
335
|
+
duration,
|
|
336
|
+
liveEdge,
|
|
337
|
+
seekableStart,
|
|
338
|
+
unixoffset: mistStreamInfo?.unixoffset,
|
|
339
|
+
})
|
|
340
|
+
);
|
|
310
341
|
|
|
311
342
|
// Seek value for slider
|
|
312
343
|
let _seekValue = $derived.by(() => {
|
|
@@ -358,36 +389,42 @@
|
|
|
358
389
|
function handleMute() {
|
|
359
390
|
if (disabled) return;
|
|
360
391
|
const player = globalPlayerManager.getCurrentPlayer();
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
player?.setMuted?.(nextMuted);
|
|
365
|
-
v.muted = nextMuted;
|
|
366
|
-
isMuted = nextMuted;
|
|
367
|
-
if (nextMuted) {
|
|
368
|
-
volumeValue = 0;
|
|
392
|
+
if (player?.setMuted) {
|
|
393
|
+
const currentlyMuted = player.isMuted?.() ?? video?.muted ?? false;
|
|
394
|
+
player.setMuted(!currentlyMuted);
|
|
369
395
|
} else {
|
|
370
|
-
|
|
396
|
+
// Fallback: direct video manipulation
|
|
397
|
+
const v = video;
|
|
398
|
+
if (!v) return;
|
|
399
|
+
v.muted = !v.muted;
|
|
371
400
|
}
|
|
372
401
|
}
|
|
373
402
|
|
|
374
403
|
function handleVolumeChange(val: number) {
|
|
375
404
|
if (disabled) return;
|
|
376
|
-
const player = globalPlayerManager.getCurrentPlayer();
|
|
377
|
-
const v = player?.getVideoElement?.() ?? video;
|
|
378
|
-
if (!v) return;
|
|
379
|
-
// Validate: clamp to 0-100, handle NaN/Infinity (matches React implementation)
|
|
380
405
|
const next = Math.max(0, Math.min(100, val ?? 100));
|
|
381
406
|
if (!Number.isFinite(next)) return;
|
|
382
|
-
|
|
383
|
-
|
|
407
|
+
|
|
408
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
409
|
+
if (player?.setVolume) {
|
|
410
|
+
// Use core controller which handles mute/unmute logic
|
|
411
|
+
player.setVolume(next / 100);
|
|
412
|
+
} else {
|
|
413
|
+
// Fallback: direct video manipulation
|
|
414
|
+
const v = video;
|
|
415
|
+
if (!v) return;
|
|
416
|
+
v.volume = next / 100;
|
|
417
|
+
v.muted = next === 0;
|
|
418
|
+
}
|
|
384
419
|
volumeValue = next;
|
|
385
420
|
isMuted = next === 0;
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
function handleFullscreen() {
|
|
389
424
|
if (disabled) return;
|
|
390
|
-
const container = document.querySelector(
|
|
425
|
+
const container = document.querySelector(
|
|
426
|
+
'[data-player-container="true"]'
|
|
427
|
+
) as HTMLElement | null;
|
|
391
428
|
if (!container) return;
|
|
392
429
|
if (document.fullscreenElement) {
|
|
393
430
|
document.exitFullscreen().catch(() => {});
|
|
@@ -424,7 +461,7 @@
|
|
|
424
461
|
function handleCaptionChange(value: string) {
|
|
425
462
|
if (disabled) return;
|
|
426
463
|
captionValue = value;
|
|
427
|
-
if (value ===
|
|
464
|
+
if (value === "none") {
|
|
428
465
|
globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(null);
|
|
429
466
|
} else {
|
|
430
467
|
globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(value);
|
|
@@ -432,35 +469,57 @@
|
|
|
432
469
|
showSettingsMenu = false;
|
|
433
470
|
}
|
|
434
471
|
|
|
472
|
+
// Non-passive wheel listener for volume control
|
|
473
|
+
$effect(() => {
|
|
474
|
+
if (!volumeGroupRef) return;
|
|
475
|
+
const handler = (e: WheelEvent) => {
|
|
476
|
+
if (disabled || !hasAudio) return;
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
const delta = e.deltaY < 0 ? 5 : -5;
|
|
479
|
+
handleVolumeChange(volumeValue + delta);
|
|
480
|
+
};
|
|
481
|
+
volumeGroupRef.addEventListener("wheel", handler, { passive: false });
|
|
482
|
+
return () => volumeGroupRef?.removeEventListener("wheel", handler);
|
|
483
|
+
});
|
|
484
|
+
|
|
435
485
|
// Close menu when clicking outside - with debounce to prevent immediate close from same click
|
|
436
486
|
$effect(() => {
|
|
437
487
|
if (!showSettingsMenu) return;
|
|
438
488
|
|
|
439
489
|
const handleClick = (e: MouseEvent) => {
|
|
440
490
|
const target = e.target as HTMLElement;
|
|
441
|
-
if (!target.closest(
|
|
491
|
+
if (!target.closest(".fw-settings-menu")) {
|
|
442
492
|
showSettingsMenu = false;
|
|
443
493
|
}
|
|
444
494
|
};
|
|
445
495
|
|
|
446
496
|
// Debounce to prevent immediate close from the same click that opened the menu
|
|
447
497
|
const timeout = setTimeout(() => {
|
|
448
|
-
window.addEventListener(
|
|
498
|
+
window.addEventListener("click", handleClick);
|
|
449
499
|
}, 0);
|
|
450
500
|
|
|
451
501
|
return () => {
|
|
452
502
|
clearTimeout(timeout);
|
|
453
|
-
window.removeEventListener(
|
|
503
|
+
window.removeEventListener("click", handleClick);
|
|
454
504
|
};
|
|
455
505
|
});
|
|
456
506
|
</script>
|
|
457
507
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
508
|
+
<div
|
|
509
|
+
class={cn(
|
|
510
|
+
"fw-player-surface fw-controls-wrapper",
|
|
511
|
+
isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
|
|
512
|
+
)}
|
|
513
|
+
>
|
|
514
|
+
<!-- Control bar -->
|
|
515
|
+
<div
|
|
516
|
+
class="fw-control-bar pointer-events-auto"
|
|
517
|
+
role="toolbar"
|
|
518
|
+
aria-label="Media controls"
|
|
519
|
+
tabindex="-1"
|
|
520
|
+
onclick={(e) => e.stopPropagation()}
|
|
521
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
522
|
+
>
|
|
464
523
|
<!-- Seek bar -->
|
|
465
524
|
{#if canSeek}
|
|
466
525
|
<div class="fw-seek-wrapper">
|
|
@@ -469,7 +528,7 @@
|
|
|
469
528
|
{duration}
|
|
470
529
|
{buffered}
|
|
471
530
|
{disabled}
|
|
472
|
-
|
|
531
|
+
{isLive}
|
|
473
532
|
{seekableStart}
|
|
474
533
|
{liveEdge}
|
|
475
534
|
{commitOnRelease}
|
|
@@ -485,11 +544,17 @@
|
|
|
485
544
|
{/if}
|
|
486
545
|
|
|
487
546
|
<!-- Control buttons -->
|
|
488
|
-
|
|
547
|
+
<div class="fw-controls-row">
|
|
489
548
|
<!-- Left: Play, Skip, Volume, Time, Live -->
|
|
490
549
|
<div class="fw-controls-left">
|
|
491
550
|
<div class="fw-control-group">
|
|
492
|
-
<button
|
|
551
|
+
<button
|
|
552
|
+
type="button"
|
|
553
|
+
class="fw-btn-flush"
|
|
554
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
555
|
+
onclick={handlePlayPause}
|
|
556
|
+
{disabled}
|
|
557
|
+
>
|
|
493
558
|
{#if isPlaying}
|
|
494
559
|
<PauseIcon size={18} />
|
|
495
560
|
{:else}
|
|
@@ -497,10 +562,22 @@
|
|
|
497
562
|
{/if}
|
|
498
563
|
</button>
|
|
499
564
|
{#if canSeek}
|
|
500
|
-
<button
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
class="fw-btn-flush hidden sm:flex"
|
|
568
|
+
aria-label="Skip back 10s"
|
|
569
|
+
onclick={handleSkipBack}
|
|
570
|
+
{disabled}
|
|
571
|
+
>
|
|
501
572
|
<SkipBackIcon size={16} />
|
|
502
573
|
</button>
|
|
503
|
-
<button
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
class="fw-btn-flush hidden sm:flex"
|
|
577
|
+
aria-label="Skip forward 10s"
|
|
578
|
+
onclick={handleSkipForward}
|
|
579
|
+
{disabled}
|
|
580
|
+
>
|
|
504
581
|
<SkipForwardIcon size={16} />
|
|
505
582
|
</button>
|
|
506
583
|
{/if}
|
|
@@ -508,38 +585,42 @@
|
|
|
508
585
|
|
|
509
586
|
<!-- Volume -->
|
|
510
587
|
<div
|
|
588
|
+
bind:this={volumeGroupRef}
|
|
511
589
|
class={cn(
|
|
512
|
-
|
|
513
|
-
isVolumeExpanded &&
|
|
514
|
-
!hasAudio &&
|
|
590
|
+
"fw-volume-group",
|
|
591
|
+
isVolumeExpanded && "fw-volume-group--expanded",
|
|
592
|
+
!hasAudio && "fw-volume-group--disabled"
|
|
515
593
|
)}
|
|
594
|
+
role="group"
|
|
595
|
+
aria-label="Volume controls"
|
|
516
596
|
onmouseenter={() => hasAudio && (isVolumeHovered = true)}
|
|
517
|
-
onmouseleave={() => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
|
|
597
|
+
onmouseleave={() => {
|
|
598
|
+
isVolumeHovered = false;
|
|
599
|
+
isVolumeFocused = false;
|
|
521
600
|
}}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
handleMute();
|
|
526
|
-
}
|
|
601
|
+
onfocusin={() => hasAudio && (isVolumeFocused = true)}
|
|
602
|
+
onfocusout={(e) => {
|
|
603
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
|
|
527
604
|
}}
|
|
528
605
|
>
|
|
529
606
|
<button
|
|
530
607
|
type="button"
|
|
531
608
|
class="fw-volume-btn"
|
|
532
|
-
aria-label={!hasAudio ?
|
|
533
|
-
title={!hasAudio ?
|
|
609
|
+
aria-label={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
|
|
610
|
+
title={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
|
|
534
611
|
onclick={handleMute}
|
|
535
612
|
disabled={!hasAudio}
|
|
536
613
|
>
|
|
537
|
-
<VolumeIcons
|
|
614
|
+
<VolumeIcons {isMuted} volume={volumeValue / 100} size={16} />
|
|
538
615
|
</button>
|
|
539
|
-
<div
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
616
|
+
<div
|
|
617
|
+
class={cn(
|
|
618
|
+
"fw-volume-slider-wrapper",
|
|
619
|
+
isVolumeExpanded
|
|
620
|
+
? "fw-volume-slider-wrapper--expanded"
|
|
621
|
+
: "fw-volume-slider-wrapper--collapsed"
|
|
622
|
+
)}
|
|
623
|
+
>
|
|
543
624
|
<Slider
|
|
544
625
|
min={0}
|
|
545
626
|
max={100}
|
|
@@ -567,10 +648,14 @@
|
|
|
567
648
|
onclick={handleGoLive}
|
|
568
649
|
disabled={!hasDvrWindow || isNearLiveState}
|
|
569
650
|
class={cn(
|
|
570
|
-
|
|
571
|
-
|
|
651
|
+
"fw-live-badge",
|
|
652
|
+
!hasDvrWindow || isNearLiveState ? "fw-live-badge--active" : "fw-live-badge--behind"
|
|
572
653
|
)}
|
|
573
|
-
title={!hasDvrWindow
|
|
654
|
+
title={!hasDvrWindow
|
|
655
|
+
? "Live only"
|
|
656
|
+
: isNearLiveState
|
|
657
|
+
? "At live edge"
|
|
658
|
+
: "Jump to live"}
|
|
574
659
|
>
|
|
575
660
|
LIVE
|
|
576
661
|
{#if !isNearLiveState && hasDvrWindow}
|
|
@@ -587,11 +672,11 @@
|
|
|
587
672
|
<div class="fw-control-group">
|
|
588
673
|
<button
|
|
589
674
|
type="button"
|
|
590
|
-
class={cn(
|
|
675
|
+
class={cn("fw-btn-flush", isStatsOpen && "fw-btn-flush--active")}
|
|
591
676
|
aria-label="Toggle stats"
|
|
592
677
|
title="Stats"
|
|
593
678
|
onclick={onStatsToggle}
|
|
594
|
-
|
|
679
|
+
{disabled}
|
|
595
680
|
>
|
|
596
681
|
<StatsIcon size={16} />
|
|
597
682
|
</button>
|
|
@@ -600,11 +685,11 @@
|
|
|
600
685
|
<div class="fw-control-group relative">
|
|
601
686
|
<button
|
|
602
687
|
type="button"
|
|
603
|
-
class={cn(
|
|
688
|
+
class={cn("fw-btn-flush group", showSettingsMenu && "fw-btn-flush--active")}
|
|
604
689
|
aria-label="Settings"
|
|
605
690
|
title="Settings"
|
|
606
|
-
onclick={() => showSettingsMenu = !showSettingsMenu}
|
|
607
|
-
|
|
691
|
+
onclick={() => (showSettingsMenu = !showSettingsMenu)}
|
|
692
|
+
{disabled}
|
|
608
693
|
>
|
|
609
694
|
<SettingsIcon size={16} class="transition-transform group-hover:rotate-90" />
|
|
610
695
|
</button>
|
|
@@ -616,16 +701,19 @@
|
|
|
616
701
|
<div class="fw-settings-section">
|
|
617
702
|
<div class="fw-settings-label">Mode</div>
|
|
618
703
|
<div class="fw-settings-options">
|
|
619
|
-
{#each [
|
|
704
|
+
{#each ["auto", "low-latency", "quality"] as mode}
|
|
620
705
|
<button
|
|
621
706
|
type="button"
|
|
622
707
|
class={cn(
|
|
623
|
-
|
|
624
|
-
playbackMode === mode &&
|
|
708
|
+
"fw-settings-btn",
|
|
709
|
+
playbackMode === mode && "fw-settings-btn--active"
|
|
625
710
|
)}
|
|
626
|
-
onclick={() => {
|
|
711
|
+
onclick={() => {
|
|
712
|
+
onModeChange(mode as PlaybackMode);
|
|
713
|
+
showSettingsMenu = false;
|
|
714
|
+
}}
|
|
627
715
|
>
|
|
628
|
-
{mode ===
|
|
716
|
+
{mode === "low-latency" ? "Fast" : mode === "quality" ? "Stable" : "Auto"}
|
|
629
717
|
</button>
|
|
630
718
|
{/each}
|
|
631
719
|
</div>
|
|
@@ -641,11 +729,11 @@
|
|
|
641
729
|
<button
|
|
642
730
|
type="button"
|
|
643
731
|
class={cn(
|
|
644
|
-
|
|
645
|
-
playbackRate === rate &&
|
|
732
|
+
"fw-settings-btn",
|
|
733
|
+
playbackRate === rate && "fw-settings-btn--active"
|
|
646
734
|
)}
|
|
647
735
|
onclick={() => handleSpeedSelect(rate)}
|
|
648
|
-
|
|
736
|
+
{disabled}
|
|
649
737
|
>
|
|
650
738
|
{rate}x
|
|
651
739
|
</button>
|
|
@@ -661,18 +749,18 @@
|
|
|
661
749
|
<div class="fw-settings-list">
|
|
662
750
|
<button
|
|
663
751
|
class={cn(
|
|
664
|
-
|
|
665
|
-
qualityValue ===
|
|
752
|
+
"fw-settings-list-item",
|
|
753
|
+
qualityValue === "auto" && "fw-settings-list-item--active"
|
|
666
754
|
)}
|
|
667
|
-
onclick={() => handleQualityChange(
|
|
755
|
+
onclick={() => handleQualityChange("auto")}
|
|
668
756
|
>
|
|
669
757
|
Auto
|
|
670
758
|
</button>
|
|
671
759
|
{#each qualities as q}
|
|
672
760
|
<button
|
|
673
761
|
class={cn(
|
|
674
|
-
|
|
675
|
-
qualityValue === q.id &&
|
|
762
|
+
"fw-settings-list-item",
|
|
763
|
+
qualityValue === q.id && "fw-settings-list-item--active"
|
|
676
764
|
)}
|
|
677
765
|
onclick={() => handleQualityChange(q.id)}
|
|
678
766
|
>
|
|
@@ -690,18 +778,18 @@
|
|
|
690
778
|
<div class="fw-settings-list">
|
|
691
779
|
<button
|
|
692
780
|
class={cn(
|
|
693
|
-
|
|
694
|
-
captionValue ===
|
|
781
|
+
"fw-settings-list-item",
|
|
782
|
+
captionValue === "none" && "fw-settings-list-item--active"
|
|
695
783
|
)}
|
|
696
|
-
onclick={() => handleCaptionChange(
|
|
784
|
+
onclick={() => handleCaptionChange("none")}
|
|
697
785
|
>
|
|
698
786
|
Off
|
|
699
787
|
</button>
|
|
700
788
|
{#each textTracks as t}
|
|
701
789
|
<button
|
|
702
790
|
class={cn(
|
|
703
|
-
|
|
704
|
-
captionValue === t.id &&
|
|
791
|
+
"fw-settings-list-item",
|
|
792
|
+
captionValue === t.id && "fw-settings-list-item--active"
|
|
705
793
|
)}
|
|
706
794
|
onclick={() => handleCaptionChange(t.id)}
|
|
707
795
|
>
|
|
@@ -715,15 +803,22 @@
|
|
|
715
803
|
{/if}
|
|
716
804
|
</div>
|
|
717
805
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
806
|
+
<div class="fw-control-group">
|
|
807
|
+
<button
|
|
808
|
+
type="button"
|
|
809
|
+
class="fw-btn-flush"
|
|
810
|
+
aria-label="Toggle fullscreen"
|
|
811
|
+
title="Fullscreen"
|
|
812
|
+
onclick={handleFullscreen}
|
|
813
|
+
{disabled}
|
|
814
|
+
>
|
|
815
|
+
{#if isFullscreen}
|
|
816
|
+
<FullscreenExitIcon size={16} />
|
|
817
|
+
{:else}
|
|
818
|
+
<FullscreenIcon size={16} />
|
|
819
|
+
{/if}
|
|
820
|
+
</button>
|
|
821
|
+
</div>
|
|
727
822
|
</div>
|
|
728
823
|
</div>
|
|
729
824
|
</div>
|