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 } from "./components/SVG";
8import { LocalStorageValues, toast } from "./utils";
9import Playlist from "./components/Playlist";
10import { getSrc } from "./offline";
11
12//TODO: download single file, playlist
13//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
14//TODO: make sure all button have title texts
15//TODO: better touch controls?
16//---
17//TODO: setting export/import
18//TODO: playlist management
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
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 [options, setOptions] = createSignal<AppOptions>({
41 bitrate: 128000,
42 enableTranscoding: true,
43 maxTranscodes: 4,
44 maxDownloads: 1,
45 rememberDir: false,
46 });
47 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
48 const [isShuffled, setShuffled] = createSignal(false);
49 const [isRepeated, setRepeated] = createSignal(false);
50
51 createEffect(() => {
52 const parts = currentDir().split("/").filter(Boolean);
53 setBreadcrumbs(["root", ...parts]);
54 // Update URL fragment when directory changes
55 window.location.hash = encodeURI(currentDir());
56 });
57
58 function stopPlaying() {
59 setPlayerState({
60 ...playerState(),
61 currentTrack: null,
62 isPlaying: false,
63 currentTime: 0,
64 });
65 audioElement.pause();
66 audioElement.srcObject = null;
67 }
68
69 createEffect(
70 on(playlist, () => {
71 if (playlist().length === 0) {
72 stopPlaying();
73 }
74 }),
75 );
76
77 let audioElement!: HTMLAudioElement;
78
79 // Play a track from the playlist
80 async function playTrack(track: PlaylistItem) {
81 setPlayerState({
82 ...playerState(),
83 currentTrack: track,
84 isPlaying: true,
85 currentTime: 0,
86 });
87
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 audioElement.play().catch((e) => {
97 setPlayerState({
98 ...playerState(),
99 isPlaying: false,
100 currentTime: 0,
101 });
102 return toast("Playing track failed", "error", e);
103 });
104 }
105
106 // Play next track
107 function playNext() {
108 const currentTrack = playerState().currentTrack;
109 if (!currentTrack) return;
110
111 let nextTrack: PlaylistItem | null = null;
112 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
113 if (isShuffled()) {
114 // Pick a different random track
115 if (playlist().length > 1) {
116 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
117 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
118 } else {
119 nextTrack = currentTrack;
120 }
121 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
122 nextTrack = playlist()[currentIndex + 1];
123 } else if (isRepeated()) {
124 nextTrack = playlist()[0];
125 }
126
127 if (nextTrack) {
128 playTrack(nextTrack);
129 } else {
130 stopPlaying();
131 }
132 }
133
134 // Play previous track
135 function playPrevious() {
136 const currentTrack = playerState().currentTrack;
137 if (!currentTrack) return;
138 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
139 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
140 }
141
142 // Toggle play/pause
143 function togglePlay() {
144 const newIsPlaying = !playerState().isPlaying;
145 setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
146
147 if (newIsPlaying) {
148 audioElement.play().catch((e) => {
149 setPlayerState({
150 ...playerState(),
151 isPlaying: false,
152 currentTime: 0,
153 });
154 return toast("Error playing track", "error", e);
155 });
156 } else {
157 audioElement.pause();
158 }
159 }
160
161 // Update progress when the time updates
162 function updateProgress() {
163 if (audioElement) {
164 setPlayerState({
165 ...playerState(),
166 currentTime: audioElement.currentTime,
167 buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
168 });
169 }
170 }
171
172 // Initialize app
173 onMount(async () => {
174 // Load from localStorage
175 try {
176 const savedOptions = localStorage.getItem(LocalStorageValues.options);
177 if (savedOptions) {
178 setOptions(JSON.parse(savedOptions));
179 }
180 const savedVolume = localStorage.getItem(LocalStorageValues.volume);
181 if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) {
182 setPlayerState({ ...playerState(), volume: Number.parseFloat(savedVolume) });
183 }
184 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
185 if (savedPlaylist) {
186 setPlaylist(JSON.parse(savedPlaylist));
187 }
188 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
189 if (savedShuffle !== null) {
190 setShuffled(savedShuffle === "true");
191 }
192 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
193 if (savedRepeat !== null) {
194 setRepeated(savedRepeat === "true");
195 }
196 const savedPath = localStorage.getItem(LocalStorageValues.path);
197 if (options().rememberDir && savedPath !== null && !window.location.hash) {
198 setCurrentDir(savedPath);
199 }
200 } catch (e) {
201 toast("loading values from localStorage failed", "error", e);
202 localStorage.clear();
203 }
204 // Sync to localStorage
205 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options())));
206 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState().volume.toString()));
207 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
208 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
209 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
210 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
211
212 // Set initial audio element volume
213 audioElement.volume = playerState().volume;
214
215 // Set up audio element event handlers
216 audioElement.addEventListener("ended", playNext);
217 audioElement.addEventListener("timeupdate", updateProgress);
218 audioElement.addEventListener("durationchange", updateProgress);
219
220 window.addEventListener("hashchange", () => {
221 setCurrentDir(decodeURI(window.location.hash.slice(1)));
222 });
223 });
224
225 createEffect(() => {
226 // Update audio element when volume changes
227 audioElement.volume = playerState().volume;
228 });
229
230 return (
231 <div class="app-container">
232 <div class="header">
233 <div class="title">Pico Pixel Player</div>
234 <div class="spacer" />
235 <button
236 title="Toggle File Browser"
237 class="filebrowser-toggle"
238 onClick={() => setShowFileBrowser((prev) => !prev)}
239 >
240 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
241 </button>
242 <button
243 title="Toggle Fullscreen"
244 class="fullscreen-button"
245 onClick={() =>
246 document.fullscreenElement
247 ? document.exitFullscreen()
248 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
249 }
250 >
251 <FullscreenSVG />
252 </button>
253 <button title="Settings" class="options-button" onClick={() => setShowOptions(true)}>
254 <CogSVG />
255 </button>
256 </div>
257 <audio ref={audioElement} preload="auto" />
258
259 <div class="main-content">
260 <FileBrowser
261 breadcrumbs={breadcrumbs}
262 currentDir={currentDir}
263 setCurrentDir={setCurrentDir}
264 playTrack={playTrack}
265 playlist={playlist}
266 setPlaylist={setPlaylist}
267 showFileBrowser={showFileBrowser}
268 setShowFileBrowser={setShowFileBrowser}
269 options={options}
270 />
271
272 <Playlist
273 playlist={playlist}
274 setPlaylist={setPlaylist}
275 playerState={playerState}
276 setPlayerState={setPlayerState}
277 playTrack={playTrack}
278 audioElement={audioElement}
279 />
280 </div>
281
282 <PlayerControls
283 playerState={playerState}
284 setPlayerState={setPlayerState}
285 togglePlay={togglePlay}
286 playPrevious={playPrevious}
287 playNext={playNext}
288 isShuffled={isShuffled}
289 setShuffled={setShuffled}
290 isRepeated={isRepeated}
291 setRepeated={setRepeated}
292 audioElement={audioElement}
293 />
294
295 <OptionsModal
296 showOptions={showOptions}
297 setShowOptions={setShowOptions}
298 options={options}
299 setOptions={setOptions}
300 />
301 </div>
302 );
303};
304
305export default App;
306