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