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