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