blanchon commited on
Commit
ec62d48
·
1 Parent(s): 4c73fd1

Video player: cycle-through playback speed control (0.5/1/1.5/2)

Browse files

Adds 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}