PlayerControls.tsx
⎇
Raw
1import "./PlayerControls.css";
2import type { AppOptions, PlayerState } from "../types";
3import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, type Setter, Show } from "solid-js";
4import {
5 ClearSVG,
6 DownSVG,
7 InfoSVG,
8 NextTrackSVG,
9 PauseSVG,
10 PlaySVG,
11 PreviousTrackSVG,
12 RepeatSVG,
13 ShuffleSVG,
14 UpSVG,
15 VolumeSVG,
16} from "./SVG";
17import { formatFilename, formatTime, LocalStorageValues } from "../utils";
18import type { SetStoreFunction } from "solid-js/store";
19import { LoadingSpinner } from "./LoadingScreen";
20
21interface PlayerControlsProps {
22 playerState: PlayerState;
23 setPlayerState: SetStoreFunction<PlayerState>;
24 togglePlay: () => void;
25 playPrevious: () => void;
26 playNext: () => void;
27 isShuffled: Accessor<boolean>;
28 setShuffled: Setter<boolean>;
29 isRepeated: Accessor<boolean>;
30 setRepeated: Setter<boolean>;
31 options: AppOptions;
32 audioElement: HTMLAudioElement;
33}
34
35export default function PlayerControls(props: PlayerControlsProps) {
36 const [controlsDisabled, setControlsDisabled] = createSignal(false);
37 createEffect(() => {
38 setControlsDisabled(!props.playerState.currentTrack);
39 });
40
41 function setVolume(e: Event) {
42 const input = e.target as HTMLInputElement;
43 const volume = Number.parseFloat(input.value);
44 props.setPlayerState("volume", volume);
45
46 if (props.audioElement) {
47 props.audioElement.volume = volume;
48 }
49 }
50
51 // Seek to position
52 function seekTo(e: MouseEvent) {
53 const playerState = props.playerState;
54 if (!props.audioElement || !playerState.currentTrack) return;
55
56 const progressBar = e.currentTarget as HTMLDivElement;
57 const rect = progressBar.getBoundingClientRect();
58 const pos = (e.clientX - rect.left) / rect.width;
59
60 const duration = playerState.currentTrack.metadata.duration;
61 props.setPlayerState("waitingForStart", true);
62 props.audioElement.currentTime = pos * duration;
63 props.setPlayerState("currentTime", pos * duration);
64 }
65
66 const [isDragging, setIsDragging] = createSignal(false);
67 const [dragPos, setDragPos] = createSignal(0);
68 createEffect(() => {
69 if (isDragging()) return;
70 setDragPos(
71 props.playerState.currentTrack
72 ? Math.min(
73 100,
74 (props.playerState.currentTime /
75 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
76 100,
77 )
78 : 0,
79 );
80 });
81 let progressContainerRef!: HTMLDivElement;
82 let handleRef!: HTMLDivElement;
83
84 //pointer instead drag events needed, because for some reason e.clientX is always 0 on firefox during the drag
85 function handlePointerDown(e: PointerEvent) {
86 if (e.button !== 0) return;
87 e.preventDefault();
88 handleRef.setPointerCapture(e.pointerId); //needed for mobile drag to work
89 setIsDragging(true);
90 handlePointerMove(e);
91 window.addEventListener("pointermove", handlePointerMove);
92 window.addEventListener("pointerup", handlePointerUp);
93 window.addEventListener("pointercancel", handlePointerUp); //just for safety
94 }
95
96 function handlePointerMove(e: PointerEvent) {
97 if (!isDragging() || !props.playerState.currentTrack) return;
98 e.preventDefault();
99 const rect = progressContainerRef.getBoundingClientRect();
100 const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
101 setDragPos(pos * 100);
102 }
103
104 function handlePointerUp(e: PointerEvent) {
105 if (!isDragging() || !props.playerState.currentTrack) return;
106 setIsDragging(false);
107 if (e.type !== "pointercancel") {
108 const rect = progressContainerRef.getBoundingClientRect();
109 const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
110 const duration = props.playerState.currentTrack.metadata.duration;
111 props.setPlayerState("waitingForStart", true);
112 props.audioElement.currentTime = pos * duration;
113 }
114 window.removeEventListener("pointermove", handlePointerMove);
115 window.removeEventListener("pointerup", handlePointerUp);
116 window.removeEventListener("pointercancel", handlePointerUp);
117 handleRef.releasePointerCapture(e.pointerId);
118 }
119
120 const [showPlayerControls, setShowPlayerControls] = createSignal(true);
121 onMount(() => {
122 const savePlayerControls = localStorage.getItem(LocalStorageValues.playerControls);
123 if (savePlayerControls !== null) setShowPlayerControls(savePlayerControls === "true");
124 createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
125 });
126
127 let metadataDialogRef!: HTMLDialogElement;
128
129 function handleClose() {
130 metadataDialogRef.close();
131 }
132
133 function MetadataEntry(label: string, value: unknown, suffix?: string) {
134 if (!value) return undefined;
135 return (
136 <div>
137 <strong>{label}</strong>: {value.toString()}
138 {suffix}
139 <br />
140 </div>
141 );
142 }
143
144 return (
145 <>
146 <dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
147 <div>
148 <div style={{ display: "flex" }}>
149 <div class="title">Metadata</div>
150 <div class="spacer" />
151 <button title="Close dialog" onClick={handleClose}>
152 <ClearSVG />
153 </button>
154 </div>
155 <hr />
156 <div class="metadata-list">
157 {MetadataEntry("Path", props.playerState.currentTrack?.path)}
158 {MetadataEntry("Title", props.playerState.currentTrack?.metadata.title)}
159 {MetadataEntry("Track number", props.playerState.currentTrack?.metadata.trackNumber)}
160 {MetadataEntry("Album", props.playerState.currentTrack?.metadata.album)}
161 {MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
162 {MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
163 {MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
164 </div>
165 </div>
166 </dialog>
167 <div class="player-controls">
168 <div class="track-info-toggle-container">
169 <button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
170 {showPlayerControls() ? <DownSVG /> : <UpSVG />}
171 </button>
172
173 <div class="track-info">
174 <Show when={props.playerState.currentTrack}>
175 <button title="Show metadata" onClick={() => metadataDialogRef.showModal()}>
176 <InfoSVG />
177 </button>
178 </Show>
179 <span classList={{ "short-name": !showPlayerControls() }}>
180 {props.playerState.currentTrack
181 ? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay)
182 : "No track selected"}
183 </span>
184 {props.playerState.waitingForStart ? (
185 <div>
186 <LoadingSpinner center={true} />
187 </div>
188 ) : (
189 ""
190 )}
191 </div>
192 </div>
193 <div class="progress-container" onClick={seekTo} ref={progressContainerRef}>
194 <div
195 class="progress-bar"
196 style={{
197 cursor: controlsDisabled() ? "default" : undefined,
198 height: `${props.options.progressBarHeight}px`,
199 }}
200 >
201 <For each={props.playerState.buffered}>
202 {(bufferRange) => {
203 return (
204 <div
205 class="buffered-fill"
206 style={{
207 left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
208 right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
209 }}
210 />
211 );
212 }}
213 </For>
214 <div
215 class="progress-fill"
216 style={{
217 width: `${
218 props.playerState.currentTrack
219 ? Math.min(
220 100,
221 (props.playerState.currentTime /
222 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
223 100,
224 )
225 : 0
226 }%`,
227 }}
228 />
229 <div
230 class="progress-handle"
231 style={{
232 left: `${dragPos()}%`,
233 height: `${props.options.progressBarHeight * 2}px`,
234 width: `${props.options.progressBarHeight * 2}px`,
235 }}
236 onPointerDown={handlePointerDown}
237 ref={handleRef}
238 />
239 </div>
240 </div>
241 <Show when={showPlayerControls()}>
242 <div class="time-container">
243 <span>{formatTime(props.playerState.currentTime)}</span>/
244 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
245 </div>
246
247 <div class="control-buttons">
248 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
249 <PreviousTrackSVG disabled={controlsDisabled} />
250 </button>
251 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
252 {props.playerState.isPlaying ? (
253 <PauseSVG disabled={controlsDisabled} />
254 ) : (
255 <PlaySVG disabled={controlsDisabled} />
256 )}
257 </button>
258 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
259 <NextTrackSVG disabled={controlsDisabled} />
260 </button>
261
262 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
263 <RepeatSVG disabled={() => !props.isRepeated()} />
264 </button>
265
266 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
267 <ShuffleSVG disabled={() => !props.isShuffled()} />
268 </button>
269
270 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
271 <VolumeSVG />
272 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
273 </div>
274 <div>
275 <select
276 value={props.playerState.rate}
277 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
278 >
279 <option value={0.25}>0.25x</option>
280 <option value={0.5}>0.5x</option>
281 <option value={0.75}>0.75x</option>
282 <option value={1}>1x</option>
283 <option value={1.25}>1.25x</option>
284 <option value={1.5}>1.5x</option>
285 <option value={1.75}>1.75x</option>
286 <option value={2}>2x</option>
287 <option value={2.5}>2.5x</option>
288 <option value={3}>3x</option>
289 </select>
290 </div>
291 </div>
292 </Show>
293 </div>
294 </>
295 );
296}
297