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