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