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 function bitrateDisplay(bitrate: number | undefined, divisor: number) {
164 if (!bitrate) return undefined;
165 return bitrate / divisor;
166 }
167
168 return (
169 <>
170 <dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
171 <div>
172 <div style={{ display: "flex" }}>
173 <div class="title">Metadata</div>
174 <div class="spacer" />
175 <button title="Close dialog" onClick={handleClose}>
176 <ClearSVG />
177 </button>
178 </div>
179 <hr />
180 <div class="metadata-list">
181 {MetadataEntry("Path", props.playerState.currentTrack?.path)}
182 {MetadataEntry("Title", props.playerState.currentTrack?.metadata.title)}
183 {MetadataEntry("Track number", props.playerState.currentTrack?.metadata.trackNumber)}
184 {MetadataEntry("Album", props.playerState.currentTrack?.metadata.album)}
185 {MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
186 {MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
187 {MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
188 {MetadataEntry("Container format", props.playerState.currentTrack?.metadata.container)}
189 {MetadataEntry("Audio codec", props.playerState.currentTrack?.metadata.audioCodec)}
190 {MetadataEntry(
191 "Audio bitrate",
192 bitrateDisplay(props.playerState.currentTrack?.metadata.audioBitrate, 1000),
193 " kbps",
194 )}
195 {MetadataEntry("Video codec", props.playerState.currentTrack?.metadata.videoCodec)}
196 {MetadataEntry(
197 "Video bitrate",
198 bitrateDisplay(props.playerState.currentTrack?.metadata.videoBitrate, 1000 * 1000),
199 " mbps",
200 )}
201 </div>
202 </div>
203 </dialog>
204 <div class="player-controls">
205 <div class="track-info-toggle-container">
206 <button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
207 {showPlayerControls() ? <DownSVG /> : <UpSVG />}
208 </button>
209
210 <div class="track-info">
211 <Show when={props.playerState.currentTrack}>
212 <button title="Show metadata" onClick={() => metadataDialogRef.showModal()}>
213 <InfoSVG />
214 </button>
215 </Show>
216 <span classList={{ "short-name": !showPlayerControls() }}>
217 {props.playerState.currentTrack
218 ? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay)
219 : "No track selected"}
220 </span>
221 {props.playerState.waitingForStart ? (
222 <div>
223 <LoadingSpinner center={true} />
224 </div>
225 ) : (
226 ""
227 )}
228 </div>
229 </div>
230 <div class="progress-container" onClick={progressClick} ref={progressContainerRef}>
231 <div
232 class="progress-bar"
233 style={{
234 cursor: controlsDisabled() ? "default" : undefined,
235 height: `${props.options.progressBarHeight}px`,
236 }}
237 >
238 <For each={props.playerState.buffered}>
239 {(bufferRange) => {
240 return (
241 <div
242 class="buffered-fill"
243 style={{
244 left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
245 right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
246 }}
247 />
248 );
249 }}
250 </For>
251 <div
252 class="progress-fill"
253 style={{
254 width: `${
255 props.playerState.currentTrack
256 ? Math.min(
257 100,
258 (props.playerState.currentTime /
259 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
260 100,
261 )
262 : 0
263 }%`,
264 }}
265 />
266 <div
267 class="progress-handle"
268 style={{
269 left: `${dragPos()}%`,
270 height: `${props.options.progressBarHeight * 2}px`,
271 width: `${props.options.progressBarHeight * 2}px`,
272 }}
273 onPointerDown={handlePointerDown}
274 ref={handleRef}
275 />
276 </div>
277 </div>
278 <Show when={showPlayerControls()}>
279 <div class="time-container">
280 <span>{formatTime(props.playerState.currentTime)}</span>/
281 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
282 </div>
283
284 <div class="control-buttons">
285 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
286 <PreviousTrackSVG disabled={controlsDisabled} />
287 </button>
288 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
289 {props.playerState.isPlaying ? (
290 <PauseSVG disabled={controlsDisabled} />
291 ) : (
292 <PlaySVG disabled={controlsDisabled} />
293 )}
294 </button>
295 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
296 <NextTrackSVG disabled={controlsDisabled} />
297 </button>
298
299 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
300 <RepeatSVG disabled={() => !props.isRepeated()} />
301 </button>
302
303 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
304 <ShuffleSVG disabled={() => !props.isShuffled()} />
305 </button>
306
307 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
308 <VolumeSVG />
309 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
310 </div>
311 <div>
312 <select
313 value={props.playerState.rate}
314 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
315 >
316 <option value={0.25}>0.25x</option>
317 <option value={0.5}>0.5x</option>
318 <option value={0.75}>0.75x</option>
319 <option value={1}>1x</option>
320 <option value={1.25}>1.25x</option>
321 <option value={1.5}>1.5x</option>
322 <option value={1.75}>1.75x</option>
323 <option value={2}>2x</option>
324 <option value={2.5}>2.5x</option>
325 <option value={3}>3x</option>
326 </select>
327 </div>
328 </div>
329 </Show>
330 </div>
331 </>
332 );
333}
334