App.tsx
⎇
Raw
1import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js";
2import type { Component } from "solid-js";
3import { type PlaylistItem, type PlayerState, type AppOptions, FileDisplay } from "./types";
4import OptionsModal from "./components/OptionsModal";
5import PlayerControls from "./components/PlayerControls";
6import FileBrowser from "./components/FileBrowser";
7import {
8 ClosedFolderSVG,
9 CogSVG,
10 DownloadManagerSVG,
11 FullscreenSVG,
12 OpenFolderSVG,
13 PlaylistSVG,
14} from "./components/SVG";
15import { formatFilename, LocalStorageValues, toast } from "./utils";
16import Playlist from "./components/Playlist";
17import { getCover, getSrc, listFiles } from "./offline";
18import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
19import PlaylistManager from "./components/PlaylistManager";
20import { BUILD_HASH } from "./build-info";
21import { createStore, reconcile } from "solid-js/store";
22import { decodePath, encodePath } from "music-server-shared/utils";
23import DownloadManager from "./components/DownloadManager";
24
25//TODO: add bitrate+format to metadata
26//TODO: more file display options
27//TODO: keep synced-aborted in downloadmanager
28//TODO: bypass music-metadata if it fails
29//TODO: Add documentation to functions
30//TODO: allow syncing playlists to server
31//TODO: video support
32//TODO: virtual rendering for playlist
33//TODO: custom serialization?
34
35export type FlatFileListing = [string, Directory | MediaFile][];
36
37const App: Component = () => {
38 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
39 const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1)));
40 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
41 const [playerState, setPlayerState] = createStore<PlayerState>({
42 isPlaying: false,
43 currentTrack: null,
44 currentTime: 0,
45 volume: 0.5,
46 buffered: [],
47 rate: 1,
48 waitingForStart: false,
49 });
50 const [showOptions, setShowOptions] = createSignal(false);
51 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
52 const [showDownloadManager, setShowDownloadManager] = createSignal(false);
53 const [options, setOptions] = createStore<AppOptions>({
54 //default settings
55 enableTranscoding: true,
56 disableChunkedTranscoding: false,
57 bitrate: 128000,
58 format: MediaFormat["opus/webm"],
59 maxTranscodes: 4,
60 maxDownloads: 2,
61 transcodeCovers: false,
62
63 hideVolume: false,
64 hideCover: false,
65 hidePlaylistCover: false,
66 fontResolution: 12,
67 filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
68 playlistDisplay: FileDisplay.TRACKNO_TITLE,
69 controlsDisplay: FileDisplay.TITLE,
70 progressBarHeight: 8,
71
72 rememberDir: false,
73 duplicateWarning: true,
74 });
75 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
76 const [isShuffled, setShuffled] = createSignal(false);
77 const [isRepeated, setRepeated] = createSignal(false);
78 const [isOffline, setOffline] = createSignal(true);
79 const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
80 () => ({ dir: currentDir(), offline: isOffline() }),
81 async (args) => {
82 return Array.from(await listFiles(args.dir, args.offline, false));
83 },
84 { initialValue: [] },
85 );
86
87 createEffect(() => {
88 const parts = currentDir().split("/").filter(Boolean);
89 setBreadcrumbs(["root", ...parts]);
90 // Update URL fragment when directory changes
91 window.location.hash = encodePath(currentDir());
92 });
93
94 createEffect(
95 on(currentDir, () => {
96 setShowFileBrowser(true);
97 }),
98 );
99
100 createEffect(() => {
101 const new_font = new FontFace(
102 "ark-latin",
103 `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
104 );
105 document.fonts.clear();
106 document.fonts.add(new_font);
107 });
108
109 function stopPlaying() {
110 batch(() => {
111 setPlayerState("isPlaying", false);
112 setPlayerState("currentTime", 0);
113 setPlayerState("currentTrack", null);
114 });
115 audioElement.pause();
116 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
117 audioElement.srcObject = null;
118 }
119
120 createEffect(
121 on(playlist, () => {
122 if (playlist().length === 0) {
123 stopPlaying();
124 }
125 }),
126 );
127
128 if ("mediaSession" in navigator) {
129 createEffect(async () => {
130 if (!playerState.currentTrack) {
131 navigator.mediaSession.metadata = new MediaMetadata();
132 navigator.mediaSession.setActionHandler("pause", null);
133 navigator.mediaSession.setActionHandler("play", null);
134 navigator.mediaSession.setActionHandler("stop", null);
135 navigator.mediaSession.setActionHandler("previoustrack", null);
136 navigator.mediaSession.setActionHandler("nexttrack", null);
137 return;
138 }
139 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
140 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
141 const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline());
142 navigator.mediaSession.metadata = new MediaMetadata({
143 title: formatFilename(playerState.currentTrack, options.controlsDisplay),
144 album: playerState.currentTrack.metadata.album,
145 artist: playerState.currentTrack.metadata.artist,
146 artwork: [
147 {
148 src: artwork ?? "",
149 },
150 ],
151 });
152
153 navigator.mediaSession.setActionHandler("nexttrack", playNext);
154 navigator.mediaSession.setActionHandler("previoustrack", playPrevious);
155 navigator.mediaSession.setActionHandler("stop", stopPlaying);
156 });
157 createEffect(() => {
158 if (!playerState.currentTrack) {
159 navigator.mediaSession.setActionHandler("pause", null);
160 navigator.mediaSession.setActionHandler("play", null);
161 return;
162 }
163 if (playerState.isPlaying) {
164 navigator.mediaSession.setActionHandler("pause", togglePlay);
165 navigator.mediaSession.setActionHandler("play", null);
166 } else {
167 navigator.mediaSession.setActionHandler("pause", null);
168 navigator.mediaSession.setActionHandler("play", togglePlay);
169 }
170 });
171 }
172
173 let audioElement!: HTMLAudioElement;
174
175 function handlePlayerError(e: unknown) {
176 if (e instanceof Error && e.name === "AbortError") return;
177 if (e instanceof Error && e.name === "NotSupportedError") {
178 toast(
179 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
180 "error",
181 );
182 } else {
183 toast("Playing track failed", "error", e);
184 }
185 }
186
187 // Play a track from the playlist
188 async function playTrack(track: PlaylistItem) {
189 try {
190 const src = await getSrc(track.path, options, isOffline());
191 if (!src) return;
192 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
193
194 //need both of these lines to work without logging an error
195 //just removeAttribute doesn't work at all
196 //just srcObject = null works, but logs a weird csp error
197 audioElement.removeAttribute("src");
198 audioElement.srcObject = null;
199 audioElement.src = src;
200 batch(() => {
201 setPlayerState("isPlaying", true);
202 setPlayerState("currentTime", 0);
203 setPlayerState("buffered", []);
204 //clone, otherwise it shares the reference
205 setPlayerState("currentTrack", { ...track });
206 setPlayerState("waitingForStart", true);
207 });
208
209 await audioElement.play();
210 } catch (e) {
211 setPlayerState("isPlaying", false);
212 handlePlayerError(e);
213 }
214 }
215
216 // Play next track
217 function playNext() {
218 const currentTrack = playerState.currentTrack;
219 if (!currentTrack) return;
220
221 let nextTrack: PlaylistItem | null = null;
222
223 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
224 if (isShuffled()) {
225 // Pick a different random track
226 if (playlist().length > 1) {
227 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
228 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
229 } else {
230 nextTrack = isRepeated() ? currentTrack : null;
231 }
232 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
233 nextTrack = playlist()[currentIndex + 1];
234 } else if (isRepeated()) {
235 nextTrack = playlist()[0];
236 }
237 if (nextTrack) {
238 playTrack(nextTrack);
239 } else {
240 stopPlaying();
241 }
242 }
243
244 // Play previous track
245 function playPrevious() {
246 const currentTrack = playerState.currentTrack;
247 if (!currentTrack) return;
248 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
249 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
250 }
251
252 // Toggle play/pause
253 function togglePlay() {
254 if (playerState.currentTrack === null) return;
255 const newIsPlaying = !playerState.isPlaying;
256 setPlayerState("isPlaying", newIsPlaying);
257
258 if (newIsPlaying) {
259 setPlayerState("waitingForStart", true);
260 audioElement
261 .play()
262 .catch((e) => {
263 setPlayerState("isPlaying", false);
264 setPlayerState("currentTime", 0);
265 handlePlayerError(e);
266 })
267 .finally(() => {
268 setPlayerState("waitingForStart", false);
269 });
270 } else {
271 audioElement.pause();
272 }
273 }
274
275 function timeRangesToArray(timeRanges: TimeRanges) {
276 const result = [];
277 for (let i = 0; i < timeRanges.length; i++) {
278 result.push({
279 start: timeRanges.start(i),
280 end: timeRanges.end(i),
281 });
282 }
283 return result;
284 }
285
286 // Update progress when the time updates
287 function updateProgress() {
288 setPlayerState("currentTime", audioElement.currentTime);
289 setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
290 }
291
292 // Initialize app
293 onMount(async () => {
294 // Load from localStorage
295 try {
296 const savedOptions = localStorage.getItem(LocalStorageValues.options);
297 if (savedOptions) {
298 //keep default settings when no override exists
299 setOptions({ ...options, ...JSON.parse(savedOptions) });
300 }
301 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
302 if (savedVolume && !Number.isNaN(savedVolume)) {
303 setPlayerState("volume", savedVolume);
304 }
305 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
306 if (savedPlaylist) {
307 setPlaylist(JSON.parse(savedPlaylist));
308 }
309 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
310 setShuffled(savedShuffle === "true");
311 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
312 setRepeated(savedRepeat === "true");
313 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
314 setOffline(savedOffline === "true");
315 const savedPath = localStorage.getItem(LocalStorageValues.path);
316 if (options.rememberDir && savedPath !== null && !window.location.hash) {
317 setCurrentDir(savedPath);
318 }
319 } catch (e) {
320 toast("loading values from localStorage failed", "error", e);
321 localStorage.clear();
322 }
323 // Sync to localStorage
324 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
325 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
326 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
327 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
328 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
329 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
330 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
331
332 // Set initial audio element volume
333 audioElement.volume = playerState.volume;
334
335 // Set up audio element event handlers
336 audioElement.addEventListener("ended", playNext);
337 audioElement.addEventListener("timeupdate", updateProgress);
338 // audioElement.addEventListener("durationchange", updateProgress);
339 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
340 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
341 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
342 audioElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
343
344 window.addEventListener("hashchange", () => {
345 setCurrentDir(decodePath(window.location.hash.slice(1)));
346 });
347
348 document.addEventListener("keydown", (e) => {
349 const activeElementTag = document.activeElement?.tagName.toLowerCase();
350 if (activeElementTag === "input") {
351 return;
352 }
353 if (e.key === " ") {
354 togglePlay();
355 e.preventDefault();
356 }
357 });
358 });
359
360 createEffect(() => {
361 audioElement.volume = playerState.volume;
362 audioElement.playbackRate = playerState.rate;
363 });
364
365 return (
366 <div class="app-container">
367 <div class="app-header">
368 <div style={{ "min-width": 0 }}>
369 <div class="title">Pico Pixel Player</div>
370 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
371 </div>
372
373 <div class="spacer" />
374 <div class="app-buttons">
375 <button
376 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
377 class="offline-button"
378 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
379 textContent={isOffline() ? "Go online" : "Go offline"}
380 onclick={() => setOffline(!isOffline())}
381 />
382 <div>
383 <button
384 title="Toggle File Browser"
385 class="filebrowser-toggle"
386 onClick={() => setShowFileBrowser((prev) => !prev)}
387 >
388 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
389 </button>
390 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
391 <DownloadManagerSVG />
392 </button>
393 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
394 <PlaylistSVG />
395 </button>
396 <button
397 title="Toggle Fullscreen"
398 onClick={() =>
399 document.fullscreenElement
400 ? document.exitFullscreen()
401 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
402 }
403 >
404 <FullscreenSVG />
405 </button>
406 <button title="Settings" onClick={() => setShowOptions(true)}>
407 <CogSVG />
408 </button>
409 </div>
410 </div>
411 </div>
412
413 <div class="main-content">
414 <FileBrowser
415 breadcrumbs={breadcrumbs}
416 currentDir={currentDir}
417 setCurrentDir={setCurrentDir}
418 playTrack={playTrack}
419 playlist={playlist}
420 setPlaylist={setPlaylist}
421 showFileBrowser={showFileBrowser}
422 setShowFileBrowser={setShowFileBrowser}
423 options={options}
424 isOffline={isOffline}
425 files={files}
426 fetchFiles={fetchFiles}
427 />
428
429 <Playlist
430 playlist={playlist}
431 setPlaylist={setPlaylist}
432 playerState={playerState}
433 setPlayerState={setPlayerState}
434 playTrack={playTrack}
435 playNext={playNext}
436 audioElement={audioElement}
437 options={options}
438 fetchFiles={fetchFiles}
439 isOffline={isOffline}
440 />
441 </div>
442
443 <audio ref={audioElement} preload="auto" />
444
445 <PlayerControls
446 playerState={playerState}
447 setPlayerState={setPlayerState}
448 togglePlay={togglePlay}
449 playPrevious={playPrevious}
450 playNext={playNext}
451 isShuffled={isShuffled}
452 setShuffled={setShuffled}
453 isRepeated={isRepeated}
454 setRepeated={setRepeated}
455 audioElement={audioElement}
456 options={options}
457 />
458
459 <OptionsModal
460 showOptions={showOptions}
461 setShowOptions={setShowOptions}
462 options={options}
463 setOptions={setOptions}
464 />
465
466 <PlaylistManager
467 showPlaylistManager={showPlaylistManager}
468 setShowPlaylistManager={setShowPlaylistManager}
469 playlist={playlist}
470 setPlaylist={setPlaylist}
471 playTrack={playTrack}
472 options={options}
473 />
474 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
475 </div>
476 );
477};
478
479export default App;
480