PlayerControls.tsx
⎇
Raw
1import "./PlayerControls.css";
2import type { AppOptions, PlayerState } from "../types";
3import { type Accessor, createEffect, createSignal, For, 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 [showPlayerControls, setShowPlayerControls] = createSignal(true);
67 onMount(() => {
68 const savePlayerControls = localStorage.getItem(LocalStorageValues.playerControls);
69 if (savePlayerControls !== null) setShowPlayerControls(savePlayerControls === "true");
70 createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
71 });
72
73 let metadataDialogRef!: HTMLDialogElement;
74
75 function handleClose() {
76 metadataDialogRef.close();
77 }
78
79 function MetadataEntry(label: string, value: unknown, suffix?: string) {
80 if (!value) return undefined;
81 return (
82 <div>
83 <strong>{label}</strong>: {value.toString()}
84 {suffix}
85 <br />
86 </div>
87 );
88 }
89
90 return (
91 <>
92 <dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
93 <div>
94 <div style={{ display: "flex" }}>
95 <div class="title">Metadata</div>
96 <div class="spacer" />
97 <button title="Close dialog" onClick={handleClose}>
98 <ClearSVG />
99 </button>
100 </div>
101 <hr />
102 <div class="metadata-list">
103 {MetadataEntry("Path", props.playerState.currentTrack?.path)}
104 {MetadataEntry("Title", props.playerState.currentTrack?.metadata.title)}
105 {MetadataEntry("Track number", props.playerState.currentTrack?.metadata.trackNumber)}
106 {MetadataEntry("Album", props.playerState.currentTrack?.metadata.album)}
107 {MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
108 {MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
109 {MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
110 </div>
111 </div>
112 </dialog>
113 <div class="player-controls">
114 <div class="track-info-toggle-container">
115 <button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
116 {showPlayerControls() ? <DownSVG /> : <UpSVG />}
117 </button>
118
119 <div class="track-info">
120 <Show when={props.playerState.currentTrack}>
121 <button title="Show metadata" onClick={() => metadataDialogRef.showModal()}>
122 <InfoSVG />
123 </button>
124 </Show>
125 <span classList={{ "short-name": !showPlayerControls() }}>
126 {props.playerState.currentTrack
127 ? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay)
128 : "No track selected"}
129 </span>
130 {props.playerState.waitingForStart ? (
131 <div>
132 <LoadingSpinner center={true} />
133 </div>
134 ) : (
135 ""
136 )}
137 </div>
138 </div>
139 <div class="progress-container" onClick={seekTo}>
140 <div class="progress-bar" style={controlsDisabled() ? { cursor: "default" } : {}}>
141 <For each={props.playerState.buffered}>
142 {(bufferRange) => {
143 return (
144 <div
145 class="buffered-fill"
146 style={{
147 left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
148 right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
149 }}
150 />
151 );
152 }}
153 </For>
154 <div
155 class="progress-fill"
156 style={{
157 width: `${
158 props.playerState.currentTrack
159 ? Math.min(
160 100,
161 (props.playerState.currentTime /
162 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
163 100,
164 )
165 : 0
166 }%`,
167 }}
168 />
169 </div>
170 </div>
171 <Show when={showPlayerControls()}>
172 <div class="time-container">
173 <span>{formatTime(props.playerState.currentTime)}</span>/
174 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
175 </div>
176
177 <div class="control-buttons">
178 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
179 <PreviousTrackSVG disabled={controlsDisabled} />
180 </button>
181 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
182 {props.playerState.isPlaying ? (
183 <PauseSVG disabled={controlsDisabled} />
184 ) : (
185 <PlaySVG disabled={controlsDisabled} />
186 )}
187 </button>
188 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
189 <NextTrackSVG disabled={controlsDisabled} />
190 </button>
191
192 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
193 <RepeatSVG disabled={() => !props.isRepeated()} />
194 </button>
195
196 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
197 <ShuffleSVG disabled={() => !props.isShuffled()} />
198 </button>
199
200 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
201 <VolumeSVG />
202 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
203 </div>
204 <div>
205 <select
206 value={props.playerState.rate}
207 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
208 >
209 <option value={0.25}>0.25x</option>
210 <option value={0.5}>0.5x</option>
211 <option value={0.75}>0.75x</option>
212 <option value={1}>1x</option>
213 <option value={1.25}>1.25x</option>
214 <option value={1.5}>1.5x</option>
215 <option value={1.75}>1.75x</option>
216 <option value={2}>2x</option>
217 <option value={2.5}>2.5x</option>
218 <option value={3}>3x</option>
219 </select>
220 </div>
221 </div>
222 </Show>
223 </div>
224 </>
225 );
226}
227