App.tsx
⎇
Raw
1import { createSignal, createEffect, onMount, on } 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 } from "./offline";
11import { MediaFormat } from "music-server-shared/types";
12import PlaylistManager from "./components/PlaylistManager";
13
14//TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[]
15//TODO: remember scroll position for parent directories
16//TODO: better touch controls?
17//TODO: Add documentation to functions
18//TODO: allow syncing playlists to server
19//TODO: button to show metadata
20//TODO: show cover of playing item
21//TODO: delete confirmation?
22//TODO: nicer errors when song can't be played because server is down
23//TODO: setting export/import
24//TODO: video support
25
26const App: Component = () => {
27 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
28 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
29 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
30 const [playerState, setPlayerState] = createSignal<PlayerState>({
31 isPlaying: false,
32 currentTrack: null,
33 currentTime: 0,
34 volume: 0.5,
35 buffered: 0,
36 });
37 const [showOptions, setShowOptions] = createSignal(false);
38 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
39 const [options, setOptions] = createSignal<AppOptions>({
40 bitrate: 128000,
41 enableTranscoding: true,
42 maxTranscodes: 4,
43 maxDownloads: 2,
44 rememberDir: false,
45 format: MediaFormat["opus/ogg"],
46 });
47 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
48 const [isShuffled, setShuffled] = createSignal(false);
49 const [isRepeated, setRepeated] = createSignal(false);
50 const [isOffline, setOffline] = createSignal(true);
51
52 createEffect(() => {
53 const parts = currentDir().split("/").filter(Boolean);
54 setBreadcrumbs(["root", ...parts]);
55 // Update URL fragment when directory changes
56 window.location.hash = encodeURI(currentDir());
57 });
58
59 function stopPlaying() {
60 setPlayerState({
61 ...playerState(),
62 currentTrack: null,
63 isPlaying: false,
64 currentTime: 0,
65 });
66 audioElement.pause();
67 audioElement.srcObject = null;
68 }
69
70 createEffect(
71 on(playlist, () => {
72 if (playlist().length === 0) {
73 stopPlaying();
74 }
75 }),
76 );
77
78 let audioElement!: HTMLAudioElement;
79
80 function handlePlayerError(e: unknown) {
81 if (e instanceof Error && e.name === "NotSupportedError") {
82 toast(
83 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
84 "error",
85 );
86 } else {
87 toast("Playing track failed", "error", e);
88 }
89 }
90
91 let playRequested = false; //solves errors about aborting playback
92 // Play a track from the playlist
93 async function playTrack(track: PlaylistItem) {
94 if (playRequested) return;
95 playRequested = true;
96 try {
97 const src = await getSrc(track.path, options(), isOffline());
98 console.log(src);
99 if (!src) return;
100 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
101
102 //need both of these lines to work without logging an error
103 //just removeAttribute doesn't work at all
104 //just srcObject = null works, but logs a weird csp error
105 audioElement.removeAttribute("src");
106 audioElement.srcObject = null;
107 audioElement.src = src;
108 setPlayerState({
109 ...playerState(),
110 currentTrack: track,
111 isPlaying: true,
112 currentTime: 0,
113 });
114 await audioElement.play();
115 } catch (e) {
116 setPlayerState({
117 ...playerState(),
118 isPlaying: false,
119 currentTime: 0,
120 });
121 handlePlayerError(e);
122 } finally {
123 playRequested = false;
124 }
125 }
126
127 // Play next track
128 function playNext() {
129 const currentTrack = playerState().currentTrack;
130 if (!currentTrack) return;
131
132 let nextTrack: PlaylistItem | null = null;
133 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
134 if (isShuffled()) {
135 // Pick a different random track
136 if (playlist().length > 1) {
137 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
138 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
139 } else {
140 nextTrack = currentTrack;
141 }
142 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
143 nextTrack = playlist()[currentIndex + 1];
144 } else if (isRepeated()) {
145 nextTrack = playlist()[0];
146 }
147
148 if (nextTrack) {
149 playTrack(nextTrack);
150 } else {
151 stopPlaying();
152 }
153 }
154
155 // Play previous track
156 function playPrevious() {
157 const currentTrack = playerState().currentTrack;
158 if (!currentTrack) return;
159 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
160 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
161 }
162
163 // Toggle play/pause
164 function togglePlay() {
165 if (playerState().currentTrack === null) return;
166 const newIsPlaying = !playerState().isPlaying;
167 setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
168
169 if (newIsPlaying) {
170 audioElement.play().catch((e) => {
171 setPlayerState({
172 ...playerState(),
173 isPlaying: false,
174 currentTime: 0,
175 });
176 handlePlayerError(e);
177 });
178 } else {
179 audioElement.pause();
180 }
181 }
182
183 // Update progress when the time updates
184 function updateProgress() {
185 setPlayerState({
186 ...playerState(),
187 currentTime: audioElement.currentTime,
188 buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
189 });
190 }
191
192 // Initialize app
193 onMount(async () => {
194 // Load from localStorage
195 try {
196 const savedOptions = localStorage.getItem(LocalStorageValues.options);
197 if (savedOptions) {
198 setOptions(JSON.parse(savedOptions));
199 }
200 const savedVolume = localStorage.getItem(LocalStorageValues.volume);
201 if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) {
202 setPlayerState({ ...playerState(), volume: Number.parseFloat(savedVolume) });
203 }
204 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
205 if (savedPlaylist) {
206 setPlaylist(JSON.parse(savedPlaylist));
207 }
208 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
209 if (savedShuffle !== null) {
210 setShuffled(savedShuffle === "true");
211 }
212 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
213 if (savedRepeat !== null) {
214 setRepeated(savedRepeat === "true");
215 }
216 const savedPath = localStorage.getItem(LocalStorageValues.path);
217 if (options().rememberDir && savedPath !== null && !window.location.hash) {
218 setCurrentDir(savedPath);
219 }
220 } catch (e) {
221 toast("loading values from localStorage failed", "error", e);
222 localStorage.clear();
223 }
224 // Sync to localStorage
225 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options())));
226 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState().volume.toString()));
227 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
228 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
229 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
230 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
231
232 // Set initial audio element volume
233 audioElement.volume = playerState().volume;
234
235 // Set up audio element event handlers
236 audioElement.addEventListener("ended", playNext);
237 audioElement.addEventListener("timeupdate", updateProgress);
238 audioElement.addEventListener("durationchange", updateProgress);
239 audioElement.addEventListener("pause", () =>
240 setPlayerState({
241 ...playerState(),
242 isPlaying: false,
243 }),
244 );
245 audioElement.addEventListener("play", () =>
246 setPlayerState({
247 ...playerState(),
248 isPlaying: true,
249 }),
250 );
251 audioElement.addEventListener("volumechange", () =>
252 setPlayerState({
253 ...playerState(),
254 volume: audioElement.volume,
255 }),
256 );
257
258 window.addEventListener("hashchange", () => {
259 setCurrentDir(decodeURI(window.location.hash.slice(1)));
260 });
261
262 document.addEventListener("keydown", (e) => {
263 if (e.key === " ") togglePlay();
264 });
265 });
266
267 createEffect(() => {
268 // Update audio element when volume changes
269 audioElement.volume = playerState().volume;
270 });
271
272 return (
273 <div class="app-container">
274 <div class="header">
275 <div class="title">Pico Pixel Player</div>
276 <div class="spacer" />
277 <button
278 title="Toggle File Browser"
279 class="filebrowser-toggle"
280 onClick={() => setShowFileBrowser((prev) => !prev)}
281 >
282 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
283 </button>
284 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
285 <PlaylistSVG />
286 </button>
287 <button
288 title="Toggle Fullscreen"
289 onClick={() =>
290 document.fullscreenElement
291 ? document.exitFullscreen()
292 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
293 }
294 >
295 <FullscreenSVG />
296 </button>
297 <button title="Settings" onClick={() => setShowOptions(true)}>
298 <CogSVG />
299 </button>
300 </div>
301
302 <div class="main-content">
303 <FileBrowser
304 breadcrumbs={breadcrumbs}
305 currentDir={currentDir}
306 setCurrentDir={setCurrentDir}
307 playTrack={playTrack}
308 playlist={playlist}
309 setPlaylist={setPlaylist}
310 showFileBrowser={showFileBrowser}
311 setShowFileBrowser={setShowFileBrowser}
312 options={options}
313 isOffline={isOffline}
314 setOffline={setOffline}
315 />
316
317 <Playlist
318 currentDir={currentDir}
319 playlist={playlist}
320 setPlaylist={setPlaylist}
321 playerState={playerState}
322 setPlayerState={setPlayerState}
323 playTrack={playTrack}
324 audioElement={audioElement}
325 />
326 </div>
327
328 <audio ref={audioElement} preload="auto" />
329
330 <PlayerControls
331 playerState={playerState}
332 setPlayerState={setPlayerState}
333 togglePlay={togglePlay}
334 playPrevious={playPrevious}
335 playNext={playNext}
336 isShuffled={isShuffled}
337 setShuffled={setShuffled}
338 isRepeated={isRepeated}
339 setRepeated={setRepeated}
340 audioElement={audioElement}
341 />
342
343 <OptionsModal
344 showOptions={showOptions}
345 setShowOptions={setShowOptions}
346 options={options}
347 setOptions={setOptions}
348 />
349
350 <PlaylistManager
351 showPlaylistManager={showPlaylistManager}
352 setShowPlaylistManager={setShowPlaylistManager}
353 playlist={playlist}
354 setPlaylist={setPlaylist}
355 playTrack={playTrack}
356 />
357 </div>
358 );
359};
360
361export default App;
362