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