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: add loading state to playercontrols
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: delete confirmation?
24//TODO: setting export/import
25//TODO: video support
26//TODO: virtual rendering for playlist
27//TODO: custom serialization?
28
29const App: Component = () => {
30 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
31 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
32 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
33 const [playerState, setPlayerState] = createStore<PlayerState>({
34 isPlaying: false,
35 currentTrack: null,
36 currentTime: 0,
37 volume: 0.5,
38 buffered: 0,
39 rate: 1,
40 waitingForStart: false,
41 });
42 const [showOptions, setShowOptions] = createSignal(false);
43 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
44 const [options, setOptions] = createStore<AppOptions>({
45 bitrate: 128000,
46 enableTranscoding: true,
47 disableChunkedTranscoding: false,
48 maxTranscodes: 4,
49 maxDownloads: 2,
50 rememberDir: false,
51 format: MediaFormat["opus/webm"],
52 hideVolume: false,
53 duplicateWarning: true,
54 fontResolution: 12,
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 = encodeURI(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 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
275 // Set initial audio element volume
276 audioElement.volume = playerState.volume;
277
278 // Set up audio element event handlers
279 audioElement.addEventListener("ended", playNext);
280 audioElement.addEventListener("timeupdate", updateProgress);
281 audioElement.addEventListener("durationchange", updateProgress);
282 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
283 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
284 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
285
286 window.addEventListener("hashchange", () => {
287 setCurrentDir(decodeURI(window.location.hash.slice(1)));
288 });
289
290 document.addEventListener("keydown", (e) => {
291 if (e.key === " ") togglePlay();
292 });
293 });
294
295 createEffect(() => {
296 audioElement.volume = playerState.volume;
297 audioElement.playbackRate = playerState.rate;
298 });
299
300 return (
301 <div class="app-container">
302 <div class="app-header">
303 <div style={{ "min-width": 0 }}>
304 <div class="title">Pico Pixel Player</div>
305 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
306 </div>
307
308 <div class="spacer" />
309 <div class="app-buttons">
310 <button
311 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
312 class="offline-button"
313 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
314 textContent={isOffline() ? "Go online" : "Go offline"}
315 onclick={() => setOffline(!isOffline())}
316 />
317 <div>
318 <button
319 title="Toggle File Browser"
320 class="filebrowser-toggle"
321 onClick={() => setShowFileBrowser((prev) => !prev)}
322 >
323 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
324 </button>
325 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
326 <PlaylistSVG />
327 </button>
328 <button
329 title="Toggle Fullscreen"
330 onClick={() =>
331 document.fullscreenElement
332 ? document.exitFullscreen()
333 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
334 }
335 >
336 <FullscreenSVG />
337 </button>
338 <button title="Settings" onClick={() => setShowOptions(true)}>
339 <CogSVG />
340 </button>
341 </div>
342 </div>
343 </div>
344
345 <div class="main-content">
346 <FileBrowser
347 breadcrumbs={breadcrumbs}
348 currentDir={currentDir}
349 setCurrentDir={setCurrentDir}
350 playTrack={playTrack}
351 playlist={playlist}
352 setPlaylist={setPlaylist}
353 showFileBrowser={showFileBrowser}
354 setShowFileBrowser={setShowFileBrowser}
355 options={options}
356 isOffline={isOffline}
357 files={files}
358 fetchFiles={fetchFiles}
359 />
360
361 <Playlist
362 currentDir={currentDir}
363 playlist={playlist}
364 setPlaylist={setPlaylist}
365 playerState={playerState}
366 setPlayerState={setPlayerState}
367 playTrack={playTrack}
368 playNext={playNext}
369 audioElement={audioElement}
370 options={options}
371 fetchFiles={fetchFiles}
372 />
373 </div>
374
375 <audio ref={audioElement} preload="auto" />
376
377 <PlayerControls
378 playerState={playerState}
379 setPlayerState={setPlayerState}
380 togglePlay={togglePlay}
381 playPrevious={playPrevious}
382 playNext={playNext}
383 isShuffled={isShuffled}
384 setShuffled={setShuffled}
385 isRepeated={isRepeated}
386 setRepeated={setRepeated}
387 audioElement={audioElement}
388 options={options}
389 />
390
391 <OptionsModal
392 showOptions={showOptions}
393 setShowOptions={setShowOptions}
394 options={options}
395 setOptions={setOptions}
396 />
397
398 <PlaylistManager
399 showPlaylistManager={showPlaylistManager}
400 setShowPlaylistManager={setShowPlaylistManager}
401 playlist={playlist}
402 setPlaylist={setPlaylist}
403 playTrack={playTrack}
404 />
405 </div>
406 );
407};
408
409export default App;
410