File size: 4,477 Bytes
b2f2f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import { useEffect, useState, type RefObject } from "react";
import clsx from "clsx";
import type { AudioEngine } from "../audio";
import { IconPlay, IconPause } from "./icons";

export function Controls(props: {
  audio: AudioEngine;
  /** Attached to the time clock; updated imperatively by the rAF loop. */
  clockRef: RefObject<HTMLSpanElement | null>;
  mix: number;
  onMixChange: (v: number) => void;
  stereo: boolean;
  onStereoChange: (v: boolean) => void;
  /** Whether the roll auto-follows the playhead (toggled off by manual scrolling). */
  following: boolean;
  onToggleFollow: () => void;
}) {
  const { audio, clockRef, mix, onMixChange, stereo, onStereoChange, following, onToggleFollow } =
    props;
  // The transport's state isn't React state (and it can auto-stop at the end),
  // so poll it each frame to keep the toggle button's label in sync.
  const [playing, setPlaying] = useState(false);
  useEffect(() => {
    let raf = 0;
    const tick = () => {
      setPlaying(audio.state === "started");
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [audio]);

  return (
    <div className="col-span-full flex flex-wrap items-center gap-2.5 rounded-card border border-line bg-surface px-3.5 py-3 animate-rise [animation-delay:0.06s]">
      <button
        className={clsx(
          "inline-flex items-center gap-2",
          playing && "border-accent bg-accent text-white hover:border-accent hover:bg-accent",
        )}
        onClick={(e) => {
          e.currentTarget.blur();
          playing ? audio.pause() : audio.play();
        }}
      >
        {playing ? <IconPause /> : <IconPlay />}
        {playing ? "Pause" : "Play"}
      </button>
      <button
        className={clsx("text-content", following && "border-accent hover:border-accent")}
        aria-pressed={following}
        title={following ? "Stop following the playhead" : "Scroll along with the playhead"}
        onClick={(e) => {
          e.currentTarget.blur();
          onToggleFollow();
        }}
      >
        Follow playhead
      </button>
      <span
        className="rounded-md border border-line bg-bg px-2.5 py-1 font-mono text-sm tabular-nums text-muted"
        ref={clockRef}
      >
        0.0s
      </span>
      <label
        className={clsx(
          "ml-auto inline-flex items-center gap-2.5 text-sm text-muted max-[760px]:ml-0",
          stereo && "opacity-40",
        )}
      >
        <span
          className={clsx(
            "min-w-8 text-center transition-colors",
            !stereo && "cursor-pointer hover:text-content",
          )}
          onClick={() => !stereo && onMixChange(0)}
        >
          Original
        </span>
        <input
          className="mix-slider"
          type="range"
          min="0"
          max="1"
          step="0.01"
          value={mix}
          disabled={stereo}
          onChange={(e) => onMixChange(parseFloat(e.target.value))}
          // Drop focus once the drag ends so Space keeps toggling play/pause
          // (the global handler ignores Space while an input is focused).
          // Range inputs implicitly capture the pointer, so this fires even
          // when the drag is released outside the slider.
          onPointerUp={(e) => e.currentTarget.blur()}
          // Clicks on the label's Original/MIDI spans focus the slider via a
          // forwarded click with no pointer event, so blur on click too.
          onClick={(e) => e.currentTarget.blur()}
        />
        <span
          className={clsx(
            "min-w-8 text-center transition-colors",
            !stereo && "cursor-pointer hover:text-content",
          )}
          onClick={() => !stereo && onMixChange(1)}
        >
          MIDI
        </span>
      </label>
      <label className="inline-flex cursor-pointer select-none items-center gap-1.5 text-sm text-muted px-3">
        <input
          className="cursor-pointer accent-accent"
          type="checkbox"
          checked={stereo}
          onChange={(e) => onStereoChange(e.target.checked)}
          // Same as the slider: keep Space bound to play/pause after clicking.
          // click (not pointerup) also catches clicks on the wrapping label,
          // which the browser forwards to the checkbox.
          onClick={(e) => e.currentTarget.blur()}
        />
        <span>Stereo</span>
      </label>
    </div>
  );
}