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: codec choice as enum
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
28const App: Component = () => {
29 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
30 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
31 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
32 const [playerState, setPlayerState] = createSignal<PlayerState>({
33 isPlaying: false,
34 currentTrack: null,
35 currentTime: 0,
36 volume: 0.5,
37 buffered: 0,
38 });
39 const [showOptions, setShowOptions] = createSignal(false);
40 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
41 const [options, setOptions] = createSignal<AppOptions>({
42 bitrate: 128000,
43 enableTranscoding: true,
44 maxTranscodes: 4,
45 maxDownloads: 1,
46 rememberDir: false,
47 format: MediaFormat["opus/ogg"],
48 });
49 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
50 const [isShuffled, setShuffled] = createSignal(false);
51 const [isRepeated, setRepeated] = createSignal(false);
52
53 createEffect(() => {
54 const parts = currentDir().split("/").filter(Boolean);
55 setBreadcrumbs(["root", ...parts]);
56 // Update URL fragment when directory changes
57 window.location.hash = encodeURI(currentDir());
58 });
59
60 function stopPlaying() {
61 setPlayerState({
62 ...playerState(),
63 currentTrack: null,
64 isPlaying: false,
65 currentTime: 0,
66 });
67 audioElement.pause();
68 audioElement.srcObject = null;
69 }
70
71 createEffect(
72 on(playlist, () => {
73 if (playlist().length === 0) {
74 stopPlaying();
75 }
76 }),
77 );
78
79 let audioElement!: HTMLAudioElement;
80
81 let playRequested = false; //solves errors about aborting playback
82 // Play a track from the playlist
83 async function playTrack(track: PlaylistItem) {
84 if (playRequested) return;
85 playRequested = true;
86 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
87
88 //need both of these lines to work without logging an error
89 //just removeAttribute doesn't work at all
90 //just srcObject = null works, but logs a weird csp error
91 audioElement.removeAttribute("src");
92 audioElement.srcObject = null;
93 audioElement.src = await getSrc(track.path, options());
94 setPlayerState({
95 ...playerState(),
96 currentTrack: track,
97 isPlaying: true,
98 currentTime: 0,
99 });
100 try {
101 await audioElement.play();
102 } catch (e) {
103 setPlayerState({
104 ...playerState(),
105 isPlaying: false,
106 currentTime: 0,
107 });
108 toast("Playing track failed", "error", e);
109 } finally {
110 playRequested = false;
111 }
112 }
113
114 // Play next track
115 function playNext() {
116 const currentTrack = playerState().currentTrack;
117 if (!currentTrack) return;
118
119 let nextTrack: PlaylistItem | null = null;
120 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
121 if (isShuffled()) {
122 // Pick a different random track
123 if (playlist().length > 1) {
124 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
125 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
126 } else {
127 nextTrack = currentTrack;
128 }
129 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
130 nextTrack = playlist()[currentIndex + 1];
131 } else if (isRepeated()) {
132 nextTrack = playlist()[0];
133 }
134
135 if (nextTrack) {
136 playTrack(nextTrack);
137 } else {
138 stopPlaying();
139 }
140 }
141
142 // Play previous track
143 function playPrevious() {
144 const currentTrack = playerState().currentTrack;
145 if (!currentTrack) return;
146 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
147 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
148 }
149
150 // Toggle play/pause
151 function togglePlay() {
152 if (playerState().currentTrack === null) return;
153 const newIsPlaying = !playerState().isPlaying;
154 setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
155
156 if (newIsPlaying) {
157 audioElement.play().catch((e) => {
158 setPlayerState({
159 ...playerState(),
160 isPlaying: false,
161 currentTime: 0,
162 });
163 return toast("Error playing track", "error", e);
164 });
165 } else {
166 audioElement.pause();
167 }
168 }
169
170 // Update progress when the time updates
171 function updateProgress() {
172 if (audioElement) {
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
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
229 window.addEventListener("hashchange", () => {
230 setCurrentDir(decodeURI(window.location.hash.slice(1)));
231 });
232
233 document.addEventListener("keydown", (e) => {
234 if (e.key === " ") togglePlay();
235 });
236 });
237
238 createEffect(() => {
239 // Update audio element when volume changes
240 audioElement.volume = playerState().volume;
241 });
242
243 return (
244 <div class="app-container">
245 <div class="header">
246 <div class="title">Pico Pixel Player</div>
247 <div class="spacer" />
248 <button
249 title="Toggle File Browser"
250 class="filebrowser-toggle"
251 onClick={() => setShowFileBrowser((prev) => !prev)}
252 >
253 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
254 </button>
255 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
256 <PlaylistSVG />
257 </button>
258 <button
259 title="Toggle Fullscreen"
260 onClick={() =>
261 document.fullscreenElement
262 ? document.exitFullscreen()
263 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
264 }
265 >
266 <FullscreenSVG />
267 </button>
268 <button title="Settings" onClick={() => setShowOptions(true)}>
269 <CogSVG />
270 </button>
271 </div>
272
273 <div class="main-content">
274 <FileBrowser
275 breadcrumbs={breadcrumbs}
276 currentDir={currentDir}
277 setCurrentDir={setCurrentDir}
278 playTrack={playTrack}
279 playlist={playlist}
280 setPlaylist={setPlaylist}
281 showFileBrowser={showFileBrowser}
282 setShowFileBrowser={setShowFileBrowser}
283 options={options}
284 />
285
286 <Playlist
287 currentDir={currentDir}
288 playlist={playlist}
289 setPlaylist={setPlaylist}
290 playerState={playerState}
291 setPlayerState={setPlayerState}
292 playTrack={playTrack}
293 audioElement={audioElement}
294 />
295 </div>
296
297 <audio ref={audioElement} preload="auto" />
298
299 <PlayerControls
300 playerState={playerState}
301 setPlayerState={setPlayerState}
302 togglePlay={togglePlay}
303 playPrevious={playPrevious}
304 playNext={playNext}
305 isShuffled={isShuffled}
306 setShuffled={setShuffled}
307 isRepeated={isRepeated}
308 setRepeated={setRepeated}
309 audioElement={audioElement}
310 />
311
312 <OptionsModal
313 showOptions={showOptions}
314 setShowOptions={setShowOptions}
315 options={options}
316 setOptions={setOptions}
317 />
318
319 <PlaylistManager showPlaylistManager={showPlaylistManager} setShowPlaylistManager={setShowPlaylistManager} />
320 </div>
321 );
322};
323
324export default App;
325