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