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