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