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