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