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: [],
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", []);
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 }
165 }
166
167 // Play next track
168 function playNext() {
169 const currentTrack = playerState.currentTrack;
170 if (!currentTrack) return;
171
172 let nextTrack: PlaylistItem | null = null;
173
174 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
175 if (isShuffled()) {
176 // Pick a different random track
177 if (playlist().length > 1) {
178 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
179 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
180 } else {
181 nextTrack = isRepeated() ? currentTrack : null;
182 }
183 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
184 nextTrack = playlist()[currentIndex + 1];
185 } else if (isRepeated()) {
186 nextTrack = playlist()[0];
187 }
188 if (nextTrack) {
189 playTrack(nextTrack);
190 } else {
191 stopPlaying();
192 }
193 }
194
195 // Play previous track
196 function playPrevious() {
197 const currentTrack = playerState.currentTrack;
198 if (!currentTrack) return;
199 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
200 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
201 }
202
203 // Toggle play/pause
204 function togglePlay() {
205 if (playerState.currentTrack === null) return;
206 const newIsPlaying = !playerState.isPlaying;
207 setPlayerState("isPlaying", newIsPlaying);
208
209 if (newIsPlaying) {
210 setPlayerState("waitingForStart", true);
211 audioElement
212 .play()
213 .catch((e) => {
214 setPlayerState("isPlaying", false);
215 setPlayerState("currentTime", 0);
216 handlePlayerError(e);
217 })
218 .finally(() => {
219 setPlayerState("waitingForStart", false);
220 });
221 } else {
222 audioElement.pause();
223 }
224 }
225
226 function timeRangesToArray(timeRanges: TimeRanges) {
227 const result = [];
228 for (let i = 0; i < timeRanges.length; i++) {
229 result.push({
230 start: timeRanges.start(i),
231 end: timeRanges.end(i),
232 });
233 }
234 return result;
235 }
236
237 // Update progress when the time updates
238 function updateProgress() {
239 setPlayerState("currentTime", audioElement.currentTime);
240 setPlayerState("buffered", timeRangesToArray(audioElement.buffered));
241 }
242
243 // Initialize app
244 onMount(async () => {
245 // Load from localStorage
246 try {
247 const savedOptions = localStorage.getItem(LocalStorageValues.options);
248 if (savedOptions) {
249 //keep default settings when no override exists
250 setOptions({ ...options, ...JSON.parse(savedOptions) });
251 }
252 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
253 if (savedVolume && !Number.isNaN(savedVolume)) {
254 setPlayerState("volume", savedVolume);
255 }
256 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
257 if (savedPlaylist) {
258 setPlaylist(JSON.parse(savedPlaylist));
259 }
260 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
261 setShuffled(savedShuffle === "true");
262 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
263 setRepeated(savedRepeat === "true");
264 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
265 setOffline(savedOffline === "true");
266 const savedPath = localStorage.getItem(LocalStorageValues.path);
267 if (options.rememberDir && savedPath !== null && !window.location.hash) {
268 setCurrentDir(savedPath);
269 }
270 } catch (e) {
271 toast("loading values from localStorage failed", "error", e);
272 localStorage.clear();
273 }
274 // Sync to localStorage
275 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
276 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
277 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
278 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
279 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
280 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
281 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
282
283 // Set initial audio element volume
284 audioElement.volume = playerState.volume;
285
286 // Set up audio element event handlers
287 audioElement.addEventListener("ended", playNext);
288 audioElement.addEventListener("timeupdate", updateProgress);
289 audioElement.addEventListener("durationchange", updateProgress);
290 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
291 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
292 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
293 audioElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
294
295 window.addEventListener("hashchange", () => {
296 setCurrentDir(decodePath(window.location.hash.slice(1)));
297 });
298
299 document.addEventListener("keydown", (e) => {
300 if (e.key === " ") togglePlay();
301 });
302 });
303
304 createEffect(() => {
305 audioElement.volume = playerState.volume;
306 audioElement.playbackRate = playerState.rate;
307 });
308
309 return (
310 <div class="app-container">
311 <div class="app-header">
312 <div style={{ "min-width": 0 }}>
313 <div class="title">Pico Pixel Player</div>
314 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
315 </div>
316
317 <div class="spacer" />
318 <div class="app-buttons">
319 <button
320 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
321 class="offline-button"
322 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
323 textContent={isOffline() ? "Go online" : "Go offline"}
324 onclick={() => setOffline(!isOffline())}
325 />
326 <div>
327 <button
328 title="Toggle File Browser"
329 class="filebrowser-toggle"
330 onClick={() => setShowFileBrowser((prev) => !prev)}
331 >
332 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
333 </button>
334 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
335 <PlaylistSVG />
336 </button>
337 <button
338 title="Toggle Fullscreen"
339 onClick={() =>
340 document.fullscreenElement
341 ? document.exitFullscreen()
342 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
343 }
344 >
345 <FullscreenSVG />
346 </button>
347 <button title="Settings" onClick={() => setShowOptions(true)}>
348 <CogSVG />
349 </button>
350 </div>
351 </div>
352 </div>
353
354 <div class="main-content">
355 <FileBrowser
356 breadcrumbs={breadcrumbs}
357 currentDir={currentDir}
358 setCurrentDir={setCurrentDir}
359 playTrack={playTrack}
360 playlist={playlist}
361 setPlaylist={setPlaylist}
362 showFileBrowser={showFileBrowser}
363 setShowFileBrowser={setShowFileBrowser}
364 options={options}
365 isOffline={isOffline}
366 files={files}
367 fetchFiles={fetchFiles}
368 />
369
370 <Playlist
371 currentDir={currentDir}
372 playlist={playlist}
373 setPlaylist={setPlaylist}
374 playerState={playerState}
375 setPlayerState={setPlayerState}
376 playTrack={playTrack}
377 playNext={playNext}
378 audioElement={audioElement}
379 options={options}
380 fetchFiles={fetchFiles}
381 />
382 </div>
383
384 <audio ref={audioElement} preload="auto" />
385
386 <PlayerControls
387 playerState={playerState}
388 setPlayerState={setPlayerState}
389 togglePlay={togglePlay}
390 playPrevious={playPrevious}
391 playNext={playNext}
392 isShuffled={isShuffled}
393 setShuffled={setShuffled}
394 isRepeated={isRepeated}
395 setRepeated={setRepeated}
396 audioElement={audioElement}
397 options={options}
398 />
399
400 <OptionsModal
401 showOptions={showOptions}
402 setShowOptions={setShowOptions}
403 options={options}
404 setOptions={setOptions}
405 />
406
407 <PlaylistManager
408 showPlaylistManager={showPlaylistManager}
409 setShowPlaylistManager={setShowPlaylistManager}
410 playlist={playlist}
411 setPlaylist={setPlaylist}
412 playTrack={playTrack}
413 />
414 </div>
415 );
416};
417
418export default App;
419