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