App.tsx
⎇
Raw
1import { createSignal, createEffect, onMount, on } from "solid-js";
2import type { Component } from "solid-js";
3import type { PlaylistItem, PlayerState, AppOptions } from "./types";
4import OptionsModal from "./components/OptionsModal";
5import PlayerControls from "./components/PlayerControls";
6import FileBrowser from "./components/FileBrowser";
7import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG";
8import { LocalStorageValues, toast } from "./utils";
9import Playlist from "./components/Playlist";
10import { getSrc } from "./offline";
11import { MediaFormat } from "music-server-shared/types";
12import PlaylistManager from "./components/PlaylistManager";
13import { BUILD_HASH } from "./build-info";
14
15//TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[]
16//TODO: search
17//TODO: version display
18//TODO: remember scroll position for parent directories
19//TODO: better touch controls?
20//TODO: Add documentation to functions
21//TODO: allow syncing playlists to server
22//TODO: button to show metadata
23//TODO: show cover of playing item
24//TODO: delete confirmation?
25//TODO: setting export/import
26//TODO: video support
27//TODO: virtual rendering for playlist
28
29const App: Component = () => {
30 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
31 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
32 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
33 const [playerState, setPlayerState] = createSignal<PlayerState>({
34 isPlaying: false,
35 currentTrack: null,
36 currentTime: 0,
37 volume: 0.5,
38 buffered: 0,
39 });
40 const [showOptions, setShowOptions] = createSignal(false);
41 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
42 const [options, setOptions] = createSignal<AppOptions>({
43 bitrate: 128000,
44 enableTranscoding: true,
45 maxTranscodes: 4,
46 maxDownloads: 2,
47 rememberDir: false,
48 format: MediaFormat["opus/ogg"],
49 });
50 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
51 const [isShuffled, setShuffled] = createSignal(false);
52 const [isRepeated, setRepeated] = createSignal(false);
53 const [isOffline, setOffline] = createSignal(true);
54
55 createEffect(() => {
56 const parts = currentDir().split("/").filter(Boolean);
57 setBreadcrumbs(["root", ...parts]);
58 // Update URL fragment when directory changes
59 window.location.hash = encodeURI(currentDir());
60 });
61
62 function stopPlaying() {
63 setPlayerState({
64 ...playerState(),
65 currentTrack: null,
66 isPlaying: false,
67 currentTime: 0,
68 });
69 audioElement.pause();
70 audioElement.srcObject = null;
71 }
72
73 createEffect(
74 on(playlist, () => {
75 if (playlist().length === 0) {
76 stopPlaying();
77 }
78 }),
79 );
80
81 let audioElement!: HTMLAudioElement;
82
83 function handlePlayerError(e: unknown) {
84 if (e instanceof Error && e.name === "NotSupportedError") {
85 toast(
86 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
87 "error",
88 );
89 } else {
90 toast("Playing track failed", "error", e);
91 }
92 }
93
94 let playRequested = false; //solves errors about aborting playback
95 // Play a track from the playlist
96 async function playTrack(track: PlaylistItem) {
97 if (playRequested) return;
98 playRequested = true;
99 try {
100 const src = await getSrc(track.path, options(), isOffline());
101 if (!src) return;
102 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
103
104 //need both of these lines to work without logging an error
105 //just removeAttribute doesn't work at all
106 //just srcObject = null works, but logs a weird csp error
107 audioElement.removeAttribute("src");
108 audioElement.srcObject = null;
109 audioElement.src = src;
110 setPlayerState({
111 ...playerState(),
112 currentTrack: track,
113 isPlaying: true,
114 currentTime: 0,
115 });
116 await audioElement.play();
117 } catch (e) {
118 setPlayerState({
119 ...playerState(),
120 isPlaying: false,
121 currentTime: 0,
122 });
123 handlePlayerError(e);
124 } finally {
125 playRequested = false;
126 }
127 }
128
129 // Play next track
130 function playNext() {
131 const currentTrack = playerState().currentTrack;
132 if (!currentTrack) return;
133
134 let nextTrack: PlaylistItem | null = null;
135 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
136 if (isShuffled()) {
137 // Pick a different random track
138 if (playlist().length > 1) {
139 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
140 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
141 } else {
142 nextTrack = currentTrack;
143 }
144 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
145 nextTrack = playlist()[currentIndex + 1];
146 } else if (isRepeated()) {
147 nextTrack = playlist()[0];
148 }
149
150 if (nextTrack) {
151 playTrack(nextTrack);
152 } else {
153 stopPlaying();
154 }
155 }
156
157 // Play previous track
158 function playPrevious() {
159 const currentTrack = playerState().currentTrack;
160 if (!currentTrack) return;
161 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
162 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
163 }
164
165 // Toggle play/pause
166 function togglePlay() {
167 if (playerState().currentTrack === null) return;
168 const newIsPlaying = !playerState().isPlaying;
169 setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
170
171 if (newIsPlaying) {
172 audioElement.play().catch((e) => {
173 setPlayerState({
174 ...playerState(),
175 isPlaying: false,
176 currentTime: 0,
177 });
178 handlePlayerError(e);
179 });
180 } else {
181 audioElement.pause();
182 }
183 }
184
185 // Update progress when the time updates
186 function updateProgress() {
187 setPlayerState({
188 ...playerState(),
189 currentTime: audioElement.currentTime,
190 buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
191 });
192 }
193
194 // Initialize app
195 onMount(async () => {
196 // Load from localStorage
197 try {
198 const savedOptions = localStorage.getItem(LocalStorageValues.options);
199 if (savedOptions) {
200 setOptions(JSON.parse(savedOptions));
201 }
202 const savedVolume = localStorage.getItem(LocalStorageValues.volume);
203 if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) {
204 setPlayerState({ ...playerState(), volume: Number.parseFloat(savedVolume) });
205 }
206 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
207 if (savedPlaylist) {
208 setPlaylist(JSON.parse(savedPlaylist));
209 }
210 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
211 if (savedShuffle !== null) {
212 setShuffled(savedShuffle === "true");
213 }
214 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
215 if (savedRepeat !== null) {
216 setRepeated(savedRepeat === "true");
217 }
218 const savedPath = localStorage.getItem(LocalStorageValues.path);
219 if (options().rememberDir && savedPath !== null && !window.location.hash) {
220 setCurrentDir(savedPath);
221 }
222 } catch (e) {
223 toast("loading values from localStorage failed", "error", e);
224 localStorage.clear();
225 }
226 // Sync to localStorage
227 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options())));
228 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState().volume.toString()));
229 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
230 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
231 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
232 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
233
234 // Set initial audio element volume
235 audioElement.volume = playerState().volume;
236
237 // Set up audio element event handlers
238 audioElement.addEventListener("ended", playNext);
239 audioElement.addEventListener("timeupdate", updateProgress);
240 audioElement.addEventListener("durationchange", updateProgress);
241 audioElement.addEventListener("pause", () =>
242 setPlayerState({
243 ...playerState(),
244 isPlaying: false,
245 }),
246 );
247 audioElement.addEventListener("play", () =>
248 setPlayerState({
249 ...playerState(),
250 isPlaying: true,
251 }),
252 );
253 audioElement.addEventListener("volumechange", () =>
254 setPlayerState({
255 ...playerState(),
256 volume: audioElement.volume,
257 }),
258 );
259
260 window.addEventListener("hashchange", () => {
261 setCurrentDir(decodeURI(window.location.hash.slice(1)));
262 });
263
264 document.addEventListener("keydown", (e) => {
265 if (e.key === " ") togglePlay();
266 });
267 });
268
269 createEffect(() => {
270 // Update audio element when volume changes
271 audioElement.volume = playerState().volume;
272 });
273
274 return (
275 <div class="app-container">
276 <div class="header">
277 <div style={{ "min-width": 0 }}>
278 <div class="title">Pico Pixel Player</div>
279 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
280 </div>
281 <div class="spacer" />
282 <button
283 title="Toggle File Browser"
284 class="filebrowser-toggle"
285 onClick={() => setShowFileBrowser((prev) => !prev)}
286 >
287 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
288 </button>
289 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
290 <PlaylistSVG />
291 </button>
292 <button
293 title="Toggle Fullscreen"
294 onClick={() =>
295 document.fullscreenElement
296 ? document.exitFullscreen()
297 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
298 }
299 >
300 <FullscreenSVG />
301 </button>
302 <button title="Settings" onClick={() => setShowOptions(true)}>
303 <CogSVG />
304 </button>
305 </div>
306
307 <div class="main-content">
308 <FileBrowser
309 breadcrumbs={breadcrumbs}
310 currentDir={currentDir}
311 setCurrentDir={setCurrentDir}
312 playTrack={playTrack}
313 playlist={playlist}
314 setPlaylist={setPlaylist}
315 showFileBrowser={showFileBrowser}
316 setShowFileBrowser={setShowFileBrowser}
317 options={options}
318 isOffline={isOffline}
319 setOffline={setOffline}
320 />
321
322 <Playlist
323 currentDir={currentDir}
324 playlist={playlist}
325 setPlaylist={setPlaylist}
326 playerState={playerState}
327 setPlayerState={setPlayerState}
328 playTrack={playTrack}
329 audioElement={audioElement}
330 />
331 </div>
332
333 <audio ref={audioElement} preload="auto" />
334
335 <PlayerControls
336 playerState={playerState}
337 setPlayerState={setPlayerState}
338 togglePlay={togglePlay}
339 playPrevious={playPrevious}
340 playNext={playNext}
341 isShuffled={isShuffled}
342 setShuffled={setShuffled}
343 isRepeated={isRepeated}
344 setRepeated={setRepeated}
345 audioElement={audioElement}
346 />
347
348 <OptionsModal
349 showOptions={showOptions}
350 setShowOptions={setShowOptions}
351 options={options}
352 setOptions={setOptions}
353 />
354
355 <PlaylistManager
356 showPlaylistManager={showPlaylistManager}
357 setShowPlaylistManager={setShowPlaylistManager}
358 playlist={playlist}
359 setPlaylist={setPlaylist}
360 playTrack={playTrack}
361 />
362 </div>
363 );
364};
365
366export default App;
367