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