Spaces:
Running
Running
Video player: cycle-through playback speed control (0.5/1/1.5/2)
Browse filesAdds a single button next to mute that cycles through the four rates;
state lifted to the match page so it persists across rounds, player
swaps, and single/grid mode toggles. The browser resets playbackRate
to 1 on each MSE source swap, so both video-player and grid-tile
re-apply it on chunk change.
src/lib/components/grid-tile.svelte
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
isLeader: boolean;
|
| 15 |
isCurrent: boolean;
|
| 16 |
isMuted: boolean;
|
|
|
|
| 17 |
available: boolean;
|
| 18 |
duration: number;
|
| 19 |
player: number;
|
|
@@ -31,6 +32,7 @@
|
|
| 31 |
isLeader,
|
| 32 |
isCurrent,
|
| 33 |
isMuted,
|
|
|
|
| 34 |
available,
|
| 35 |
duration,
|
| 36 |
player,
|
|
@@ -98,6 +100,13 @@
|
|
| 98 |
if (videoEl) videoEl.muted = isMuted;
|
| 99 |
});
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
function onTimeUpdate() {
|
| 102 |
if (videoEl && isLeader) {
|
| 103 |
onLeaderTime?.(videoEl.currentTime);
|
|
|
|
| 14 |
isLeader: boolean;
|
| 15 |
isCurrent: boolean;
|
| 16 |
isMuted: boolean;
|
| 17 |
+
playbackRate?: number;
|
| 18 |
available: boolean;
|
| 19 |
duration: number;
|
| 20 |
player: number;
|
|
|
|
| 32 |
isLeader,
|
| 33 |
isCurrent,
|
| 34 |
isMuted,
|
| 35 |
+
playbackRate = 1,
|
| 36 |
available,
|
| 37 |
duration,
|
| 38 |
player,
|
|
|
|
| 100 |
if (videoEl) videoEl.muted = isMuted;
|
| 101 |
});
|
| 102 |
|
| 103 |
+
// Re-apply on src swap (chunks change) — browsers reset playbackRate on load.
|
| 104 |
+
$effect(() => {
|
| 105 |
+
if (!videoEl) return;
|
| 106 |
+
void videoSrc;
|
| 107 |
+
videoEl.playbackRate = playbackRate;
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
function onTimeUpdate() {
|
| 111 |
if (videoEl && isLeader) {
|
| 112 |
onLeaderTime?.(videoEl.currentTime);
|
src/lib/components/perspective-grid.svelte
CHANGED
|
@@ -13,6 +13,7 @@
|
|
| 13 |
virtualTime: number;
|
| 14 |
paused?: boolean;
|
| 15 |
muted?: boolean;
|
|
|
|
| 16 |
onPausedChange?: (paused: boolean) => void;
|
| 17 |
onSelect: (player: number) => void;
|
| 18 |
onTimeUpdate?: (t: number) => void;
|
|
@@ -28,6 +29,7 @@
|
|
| 28 |
virtualTime,
|
| 29 |
paused = false,
|
| 30 |
muted = false,
|
|
|
|
| 31 |
onSelect,
|
| 32 |
onTimeUpdate,
|
| 33 |
onLeaderEnded,
|
|
@@ -141,6 +143,7 @@
|
|
| 141 |
isLeader={player.player === currentPlayer}
|
| 142 |
isCurrent={player.player === currentPlayer}
|
| 143 |
isMuted={muted || player.player !== currentPlayer}
|
|
|
|
| 144 |
available={availablePlayers.has(player.player)}
|
| 145 |
duration={player.duration}
|
| 146 |
player={player.player}
|
|
@@ -180,6 +183,7 @@
|
|
| 180 |
isLeader={player.player === currentPlayer}
|
| 181 |
isCurrent={player.player === currentPlayer}
|
| 182 |
isMuted={muted || player.player !== currentPlayer}
|
|
|
|
| 183 |
available={availablePlayers.has(player.player)}
|
| 184 |
duration={player.duration}
|
| 185 |
player={player.player}
|
|
|
|
| 13 |
virtualTime: number;
|
| 14 |
paused?: boolean;
|
| 15 |
muted?: boolean;
|
| 16 |
+
playbackRate?: number;
|
| 17 |
onPausedChange?: (paused: boolean) => void;
|
| 18 |
onSelect: (player: number) => void;
|
| 19 |
onTimeUpdate?: (t: number) => void;
|
|
|
|
| 29 |
virtualTime,
|
| 30 |
paused = false,
|
| 31 |
muted = false,
|
| 32 |
+
playbackRate = 1,
|
| 33 |
onSelect,
|
| 34 |
onTimeUpdate,
|
| 35 |
onLeaderEnded,
|
|
|
|
| 143 |
isLeader={player.player === currentPlayer}
|
| 144 |
isCurrent={player.player === currentPlayer}
|
| 145 |
isMuted={muted || player.player !== currentPlayer}
|
| 146 |
+
{playbackRate}
|
| 147 |
available={availablePlayers.has(player.player)}
|
| 148 |
duration={player.duration}
|
| 149 |
player={player.player}
|
|
|
|
| 183 |
isLeader={player.player === currentPlayer}
|
| 184 |
isCurrent={player.player === currentPlayer}
|
| 185 |
isMuted={muted || player.player !== currentPlayer}
|
| 186 |
+
{playbackRate}
|
| 187 |
available={availablePlayers.has(player.player)}
|
| 188 |
duration={player.duration}
|
| 189 |
player={player.player}
|
src/lib/components/timeline-bar.svelte
CHANGED
|
@@ -14,10 +14,12 @@
|
|
| 14 |
duration: number;
|
| 15 |
paused: boolean;
|
| 16 |
muted: boolean;
|
|
|
|
| 17 |
bufferedRanges?: BufferedRange[];
|
| 18 |
disabled?: boolean;
|
| 19 |
onTogglePlay: () => void;
|
| 20 |
onToggleMute: () => void;
|
|
|
|
| 21 |
onSeek: (t: number) => void;
|
| 22 |
onScrubStart?: () => void;
|
| 23 |
onScrubEnd?: () => void;
|
|
@@ -28,14 +30,20 @@
|
|
| 28 |
duration,
|
| 29 |
paused,
|
| 30 |
muted,
|
|
|
|
| 31 |
bufferedRanges = [],
|
| 32 |
disabled = false,
|
| 33 |
onTogglePlay,
|
| 34 |
onToggleMute,
|
|
|
|
| 35 |
onSeek,
|
| 36 |
onScrubStart,
|
| 37 |
onScrubEnd
|
| 38 |
}: Props = $props();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</script>
|
| 40 |
|
| 41 |
<div
|
|
@@ -85,4 +93,18 @@
|
|
| 85 |
<SpeakerHighIcon weight="duotone" />
|
| 86 |
{/if}
|
| 87 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
|
|
|
| 14 |
duration: number;
|
| 15 |
paused: boolean;
|
| 16 |
muted: boolean;
|
| 17 |
+
playbackRate?: number;
|
| 18 |
bufferedRanges?: BufferedRange[];
|
| 19 |
disabled?: boolean;
|
| 20 |
onTogglePlay: () => void;
|
| 21 |
onToggleMute: () => void;
|
| 22 |
+
onCyclePlaybackRate?: () => void;
|
| 23 |
onSeek: (t: number) => void;
|
| 24 |
onScrubStart?: () => void;
|
| 25 |
onScrubEnd?: () => void;
|
|
|
|
| 30 |
duration,
|
| 31 |
paused,
|
| 32 |
muted,
|
| 33 |
+
playbackRate = 1,
|
| 34 |
bufferedRanges = [],
|
| 35 |
disabled = false,
|
| 36 |
onTogglePlay,
|
| 37 |
onToggleMute,
|
| 38 |
+
onCyclePlaybackRate,
|
| 39 |
onSeek,
|
| 40 |
onScrubStart,
|
| 41 |
onScrubEnd
|
| 42 |
}: Props = $props();
|
| 43 |
+
|
| 44 |
+
const rateLabel = $derived(
|
| 45 |
+
Number.isInteger(playbackRate) ? `${playbackRate}×` : `${playbackRate.toFixed(1)}×`
|
| 46 |
+
);
|
| 47 |
</script>
|
| 48 |
|
| 49 |
<div
|
|
|
|
| 93 |
<SpeakerHighIcon weight="duotone" />
|
| 94 |
{/if}
|
| 95 |
</Button>
|
| 96 |
+
|
| 97 |
+
{#if onCyclePlaybackRate}
|
| 98 |
+
<Button
|
| 99 |
+
variant="ghost"
|
| 100 |
+
size="sm"
|
| 101 |
+
{disabled}
|
| 102 |
+
onclick={onCyclePlaybackRate}
|
| 103 |
+
aria-label={`Playback speed ${rateLabel} — click to change`}
|
| 104 |
+
title={`Playback speed (${rateLabel})`}
|
| 105 |
+
class="h-6 w-11 px-0 font-mono text-[11px] tabular-nums"
|
| 106 |
+
>
|
| 107 |
+
{rateLabel}
|
| 108 |
+
</Button>
|
| 109 |
+
{/if}
|
| 110 |
</div>
|
src/lib/components/video-player/video-player.svelte
CHANGED
|
@@ -21,6 +21,7 @@
|
|
| 21 |
virtualTime?: number;
|
| 22 |
paused?: boolean;
|
| 23 |
muted?: boolean;
|
|
|
|
| 24 |
onPausedChange?: (paused: boolean) => void;
|
| 25 |
onChunkChange?: (index: number) => void;
|
| 26 |
onTimeUpdate?: (virtualTime: number) => void;
|
|
@@ -34,6 +35,7 @@
|
|
| 34 |
virtualTime,
|
| 35 |
paused = false,
|
| 36 |
muted = false,
|
|
|
|
| 37 |
onPausedChange,
|
| 38 |
onChunkChange,
|
| 39 |
onTimeUpdate,
|
|
@@ -215,6 +217,15 @@
|
|
| 215 |
if (videoEl) videoEl.muted = muted;
|
| 216 |
});
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
function onPlaying() {
|
| 219 |
isBuffering = false;
|
| 220 |
clearFreezeFrame();
|
|
|
|
| 21 |
virtualTime?: number;
|
| 22 |
paused?: boolean;
|
| 23 |
muted?: boolean;
|
| 24 |
+
playbackRate?: number;
|
| 25 |
onPausedChange?: (paused: boolean) => void;
|
| 26 |
onChunkChange?: (index: number) => void;
|
| 27 |
onTimeUpdate?: (virtualTime: number) => void;
|
|
|
|
| 35 |
virtualTime,
|
| 36 |
paused = false,
|
| 37 |
muted = false,
|
| 38 |
+
playbackRate = 1,
|
| 39 |
onPausedChange,
|
| 40 |
onChunkChange,
|
| 41 |
onTimeUpdate,
|
|
|
|
| 217 |
if (videoEl) videoEl.muted = muted;
|
| 218 |
});
|
| 219 |
|
| 220 |
+
// Re-apply on every chunk swap — the browser resets playbackRate to 1
|
| 221 |
+
// when the underlying source changes.
|
| 222 |
+
$effect(() => {
|
| 223 |
+
if (!videoEl) return;
|
| 224 |
+
void isVideoData;
|
| 225 |
+
void videoSrc;
|
| 226 |
+
videoEl.playbackRate = playbackRate;
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
function onPlaying() {
|
| 230 |
isBuffering = false;
|
| 231 |
clearFreezeFrame();
|
src/lib/components/video-stage.svelte
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
virtualTime?: number;
|
| 19 |
paused?: boolean;
|
| 20 |
muted?: boolean;
|
|
|
|
| 21 |
outOfFrame?: boolean;
|
| 22 |
onPausedChange?: (paused: boolean) => void;
|
| 23 |
onTimeUpdate?: (t: number) => void;
|
|
@@ -32,6 +33,7 @@
|
|
| 32 |
virtualTime,
|
| 33 |
paused = false,
|
| 34 |
muted = false,
|
|
|
|
| 35 |
outOfFrame = false,
|
| 36 |
onPausedChange,
|
| 37 |
onTimeUpdate,
|
|
@@ -115,6 +117,7 @@
|
|
| 115 |
{virtualTime}
|
| 116 |
{paused}
|
| 117 |
{muted}
|
|
|
|
| 118 |
{onPausedChange}
|
| 119 |
{onTimeUpdate}
|
| 120 |
{onEnded}
|
|
|
|
| 18 |
virtualTime?: number;
|
| 19 |
paused?: boolean;
|
| 20 |
muted?: boolean;
|
| 21 |
+
playbackRate?: number;
|
| 22 |
outOfFrame?: boolean;
|
| 23 |
onPausedChange?: (paused: boolean) => void;
|
| 24 |
onTimeUpdate?: (t: number) => void;
|
|
|
|
| 33 |
virtualTime,
|
| 34 |
paused = false,
|
| 35 |
muted = false,
|
| 36 |
+
playbackRate = 1,
|
| 37 |
outOfFrame = false,
|
| 38 |
onPausedChange,
|
| 39 |
onTimeUpdate,
|
|
|
|
| 117 |
{virtualTime}
|
| 118 |
{paused}
|
| 119 |
{muted}
|
| 120 |
+
{playbackRate}
|
| 121 |
{onPausedChange}
|
| 122 |
{onTimeUpdate}
|
| 123 |
{onEnded}
|
src/routes/match/[matchId]/[mapName]/+page.svelte
CHANGED
|
@@ -57,6 +57,12 @@
|
|
| 57 |
// Lifted so player/mode/round changes don't reset the user's intent.
|
| 58 |
let masterPaused = $state(false);
|
| 59 |
let masterMuted = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
// True once the active stage's clock-driving video has reached its own
|
| 62 |
// end. From there a wall-clock RAF advances `virtualTime` until the
|
|
@@ -495,6 +501,7 @@
|
|
| 495 |
{virtualTime}
|
| 496 |
paused={masterPaused}
|
| 497 |
muted={masterMuted}
|
|
|
|
| 498 |
outOfFrame={activePlayerOutOfFrame && playerChunks.length > 0}
|
| 499 |
onPausedChange={(p) => (masterPaused = p)}
|
| 500 |
onTimeUpdate={handleTimeUpdate}
|
|
@@ -517,6 +524,7 @@
|
|
| 517 |
{virtualTime}
|
| 518 |
paused={masterPaused}
|
| 519 |
muted={masterMuted}
|
|
|
|
| 520 |
onPausedChange={(p) => (masterPaused = p)}
|
| 521 |
onSelect={(p) => {
|
| 522 |
selectPlayer(p);
|
|
@@ -535,10 +543,12 @@
|
|
| 535 |
duration={roundDuration}
|
| 536 |
paused={masterPaused}
|
| 537 |
muted={masterMuted}
|
|
|
|
| 538 |
{bufferedRanges}
|
| 539 |
disabled={previewsLoading || roundDuration <= 0}
|
| 540 |
onTogglePlay={togglePlay}
|
| 541 |
onToggleMute={() => (masterMuted = !masterMuted)}
|
|
|
|
| 542 |
onSeek={handleSeek}
|
| 543 |
/>
|
| 544 |
{/if}
|
|
|
|
| 57 |
// Lifted so player/mode/round changes don't reset the user's intent.
|
| 58 |
let masterPaused = $state(false);
|
| 59 |
let masterMuted = $state(false);
|
| 60 |
+
let masterPlaybackRate = $state(1);
|
| 61 |
+
const PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const;
|
| 62 |
+
function cyclePlaybackRate() {
|
| 63 |
+
const i = PLAYBACK_RATES.indexOf(masterPlaybackRate as (typeof PLAYBACK_RATES)[number]);
|
| 64 |
+
masterPlaybackRate = PLAYBACK_RATES[(i + 1) % PLAYBACK_RATES.length];
|
| 65 |
+
}
|
| 66 |
|
| 67 |
// True once the active stage's clock-driving video has reached its own
|
| 68 |
// end. From there a wall-clock RAF advances `virtualTime` until the
|
|
|
|
| 501 |
{virtualTime}
|
| 502 |
paused={masterPaused}
|
| 503 |
muted={masterMuted}
|
| 504 |
+
playbackRate={masterPlaybackRate}
|
| 505 |
outOfFrame={activePlayerOutOfFrame && playerChunks.length > 0}
|
| 506 |
onPausedChange={(p) => (masterPaused = p)}
|
| 507 |
onTimeUpdate={handleTimeUpdate}
|
|
|
|
| 524 |
{virtualTime}
|
| 525 |
paused={masterPaused}
|
| 526 |
muted={masterMuted}
|
| 527 |
+
playbackRate={masterPlaybackRate}
|
| 528 |
onPausedChange={(p) => (masterPaused = p)}
|
| 529 |
onSelect={(p) => {
|
| 530 |
selectPlayer(p);
|
|
|
|
| 543 |
duration={roundDuration}
|
| 544 |
paused={masterPaused}
|
| 545 |
muted={masterMuted}
|
| 546 |
+
playbackRate={masterPlaybackRate}
|
| 547 |
{bufferedRanges}
|
| 548 |
disabled={previewsLoading || roundDuration <= 0}
|
| 549 |
onTogglePlay={togglePlay}
|
| 550 |
onToggleMute={() => (masterMuted = !masterMuted)}
|
| 551 |
+
onCyclePlaybackRate={cyclePlaybackRate}
|
| 552 |
onSeek={handleSeek}
|
| 553 |
/>
|
| 554 |
{/if}
|