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