PlayerControls.tsx
⎇
Raw
1import "./PlayerControls.css";
2import { type AppOptions, getItemName, type PlayerState } from "../types";
3import { type Accessor, createEffect, createSignal, type Setter } from "solid-js";
4import { NextTrackSVG, PauseSVG, PlaySVG, PreviousTrackSVG, RepeatSVG, ShuffleSVG, VolumeSVG } from "./SVG";
5import { formatTime } from "../utils";
6import type { SetStoreFunction } from "solid-js/store";
7import { LoadingSpinner } from "./LoadingScreen";
8
9interface PlayerControlsProps {
10 playerState: PlayerState;
11 setPlayerState: SetStoreFunction<PlayerState>;
12 togglePlay: () => void;
13 playPrevious: () => void;
14 playNext: () => void;
15 isShuffled: Accessor<boolean>;
16 setShuffled: Setter<boolean>;
17 isRepeated: Accessor<boolean>;
18 setRepeated: Setter<boolean>;
19 options: AppOptions;
20 audioElement: HTMLAudioElement;
21}
22
23export default function PlayerControls(props: PlayerControlsProps) {
24 const [controlsDisabled, setControlsDisabled] = createSignal(false);
25 createEffect(() => {
26 setControlsDisabled(!props.playerState.currentTrack);
27 });
28
29 function setVolume(e: Event) {
30 const input = e.target as HTMLInputElement;
31 const volume = Number.parseFloat(input.value);
32 props.setPlayerState("volume", volume);
33
34 if (props.audioElement) {
35 props.audioElement.volume = volume;
36 }
37 }
38
39 function isLocalPlaying() {
40 return props.audioElement.src.startsWith("blob:");
41 }
42
43 // Seek to position
44 function seekTo(e: MouseEvent) {
45 const playerState = props.playerState;
46 if (!props.audioElement || !playerState.currentTrack) return;
47
48 const progressBar = e.currentTarget as HTMLDivElement;
49 const rect = progressBar.getBoundingClientRect();
50 const pos = (e.clientX - rect.left) / rect.width;
51
52 const duration = playerState.currentTrack.metadata.duration;
53 props.audioElement.currentTime = isLocalPlaying()
54 ? pos * duration
55 : Math.min(pos * duration, playerState.buffered - 1);
56 props.setPlayerState("currentTime", pos * duration);
57 }
58
59 return (
60 <div class="player-controls">
61 <div class="track-info">
62 {getItemName(props.playerState.currentTrack) || "No track selected"}
63 {props.playerState.waitingForStart ? (
64 <div>
65 <LoadingSpinner center={true} />
66 </div>
67 ) : (
68 ""
69 )}
70 </div>
71 <div class="progress-container">
72 <div class="progress-bar" onClick={seekTo} style={controlsDisabled() ? { cursor: "default" } : {}}>
73 <div
74 class="buffered-fill"
75 style={{
76 width: `${
77 props.playerState.currentTrack
78 ? isLocalPlaying()
79 ? 100
80 : Math.min(
81 100,
82 (props.playerState.buffered /
83 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
84 100,
85 )
86 : 0
87 }%`,
88 }}
89 />
90 <div
91 class="progress-fill"
92 style={{
93 width: `${
94 props.playerState.currentTrack
95 ? Math.min(
96 100,
97 (props.playerState.currentTime /
98 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
99 100,
100 )
101 : 0
102 }%`,
103 }}
104 />
105 </div>
106 </div>
107 <div class="time-container">
108 <span>{formatTime(props.playerState.currentTime)}</span>/
109 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
110 </div>
111
112 <div class="control-buttons">
113 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
114 <PreviousTrackSVG disabled={controlsDisabled} />
115 </button>
116 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
117 {props.playerState.isPlaying ? (
118 <PauseSVG disabled={controlsDisabled} />
119 ) : (
120 <PlaySVG disabled={controlsDisabled} />
121 )}
122 </button>
123 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
124 <NextTrackSVG disabled={controlsDisabled} />
125 </button>
126
127 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
128 <RepeatSVG disabled={() => !props.isRepeated()} />
129 </button>
130
131 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
132 <ShuffleSVG disabled={() => !props.isShuffled()} />
133 </button>
134
135 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
136 <VolumeSVG />
137 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
138 </div>
139 <div>
140 <select
141 value={props.playerState.rate}
142 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
143 >
144 <option value={0.25}>0.25x</option>
145 <option value={0.5}>0.5x</option>
146 <option value={0.75}>0.75x</option>
147 <option value={1}>1x</option>
148 <option value={1.25}>1.25x</option>
149 <option value={1.5}>1.5x</option>
150 <option value={1.75}>1.75x</option>
151 <option value={2}>2x</option>
152 <option value={2.5}>2.5x</option>
153 <option value={3}>3x</option>
154 </select>
155 </div>
156 </div>
157 </div>
158 );
159}
160