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