PlayerControls.tsx
⎇
Raw
1import "./PlayerControls.css";
2import { type AppOptions, getItemName, type PlayerState } from "../types";
3import { type Accessor, createEffect, createSignal, For, onMount, type Setter, Show } from "solid-js";
4import {
5 DownSVG,
6 NextTrackSVG,
7 PauseSVG,
8 PlaySVG,
9 PreviousTrackSVG,
10 RepeatSVG,
11 ShuffleSVG,
12 UpSVG,
13 VolumeSVG,
14} from "./SVG";
15import { formatTime, LocalStorageValues } from "../utils";
16import type { SetStoreFunction } from "solid-js/store";
17import { LoadingSpinner } from "./LoadingScreen";
18
19interface PlayerControlsProps {
20 playerState: PlayerState;
21 setPlayerState: SetStoreFunction<PlayerState>;
22 togglePlay: () => void;
23 playPrevious: () => void;
24 playNext: () => void;
25 isShuffled: Accessor<boolean>;
26 setShuffled: Setter<boolean>;
27 isRepeated: Accessor<boolean>;
28 setRepeated: Setter<boolean>;
29 options: AppOptions;
30 audioElement: HTMLAudioElement;
31}
32
33export default function PlayerControls(props: PlayerControlsProps) {
34 const [controlsDisabled, setControlsDisabled] = createSignal(false);
35 createEffect(() => {
36 setControlsDisabled(!props.playerState.currentTrack);
37 });
38
39 function setVolume(e: Event) {
40 const input = e.target as HTMLInputElement;
41 const volume = Number.parseFloat(input.value);
42 props.setPlayerState("volume", volume);
43
44 if (props.audioElement) {
45 props.audioElement.volume = volume;
46 }
47 }
48
49 // Seek to position
50 function seekTo(e: MouseEvent) {
51 const playerState = props.playerState;
52 if (!props.audioElement || !playerState.currentTrack) return;
53
54 const progressBar = e.currentTarget as HTMLDivElement;
55 const rect = progressBar.getBoundingClientRect();
56 const pos = (e.clientX - rect.left) / rect.width;
57
58 const duration = playerState.currentTrack.metadata.duration;
59 props.setPlayerState("waitingForStart", true);
60 props.audioElement.currentTime = pos * duration;
61 props.setPlayerState("currentTime", pos * duration);
62 }
63
64 const [showPlayerControls, setShowPlayerControls] = createSignal(true);
65 onMount(() => {
66 const savePlayerControls = localStorage.getItem(LocalStorageValues.playerControls);
67 if (savePlayerControls !== null) setShowPlayerControls(savePlayerControls === "true");
68 createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
69 });
70
71 return (
72 <div class="player-controls">
73 <div class="track-info-toggle-container">
74 <button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
75 {showPlayerControls() ? <DownSVG /> : <UpSVG />}
76 </button>
77
78 <div class="track-info">
79 <span classList={{ "short-name": !showPlayerControls() }}>
80 {getItemName(props.playerState.currentTrack) || "No track selected"}
81 </span>
82 {props.playerState.waitingForStart ? (
83 <div>
84 <LoadingSpinner center={true} />
85 </div>
86 ) : (
87 ""
88 )}
89 </div>
90 </div>
91 <div class="progress-container" onClick={seekTo}>
92 <div class="progress-bar" style={controlsDisabled() ? { cursor: "default" } : {}}>
93 <For each={props.playerState.buffered}>
94 {(bufferRange) => {
95 return (
96 <div
97 class="buffered-fill"
98 style={{
99 left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
100 right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
101 }}
102 />
103 );
104 }}
105 </For>
106 <div
107 class="progress-fill"
108 style={{
109 width: `${
110 props.playerState.currentTrack
111 ? Math.min(
112 100,
113 (props.playerState.currentTime /
114 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
115 100,
116 )
117 : 0
118 }%`,
119 }}
120 />
121 </div>
122 </div>
123 <Show when={showPlayerControls()}>
124 <div class="time-container">
125 <span>{formatTime(props.playerState.currentTime)}</span>/
126 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
127 </div>
128
129 <div class="control-buttons">
130 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
131 <PreviousTrackSVG disabled={controlsDisabled} />
132 </button>
133 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
134 {props.playerState.isPlaying ? (
135 <PauseSVG disabled={controlsDisabled} />
136 ) : (
137 <PlaySVG disabled={controlsDisabled} />
138 )}
139 </button>
140 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
141 <NextTrackSVG disabled={controlsDisabled} />
142 </button>
143
144 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
145 <RepeatSVG disabled={() => !props.isRepeated()} />
146 </button>
147
148 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
149 <ShuffleSVG disabled={() => !props.isShuffled()} />
150 </button>
151
152 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
153 <VolumeSVG />
154 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
155 </div>
156 <div>
157 <select
158 value={props.playerState.rate}
159 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
160 >
161 <option value={0.25}>0.25x</option>
162 <option value={0.5}>0.5x</option>
163 <option value={0.75}>0.75x</option>
164 <option value={1}>1x</option>
165 <option value={1.25}>1.25x</option>
166 <option value={1.5}>1.5x</option>
167 <option value={1.75}>1.75x</option>
168 <option value={2}>2x</option>
169 <option value={2.5}>2.5x</option>
170 <option value={3}>3x</option>
171 </select>
172 </div>
173 </div>
174 </Show>
175 </div>
176 );
177}
178