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