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