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