App.tsx
⎇
Raw
1import { createSignal, createEffect, onMount, on, createResource, batch } 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, listFiles } from "./offline";
11import { MediaFormat } from "music-server-shared/types";
12import PlaylistManager from "./components/PlaylistManager";
13import { BUILD_HASH } from "./build-info";
14import { createStore } from "solid-js/store";
15
16//TODO: use more store
17//TODO: show offline-only files in online mode as well
18//TODO: add loading state to playercontrols
19//TODO: search
20//TODO: better touch controls?
21//TODO: Add documentation to functions
22//TODO: allow syncing playlists to server
23//TODO: button to show metadata
24//TODO: show cover of playing item
25//TODO: delete confirmation?
26//TODO: setting export/import
27//TODO: video support
28//TODO: virtual rendering for playlist
29//TODO: custom serialization?
30
31const App: Component = () => {
32 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
33 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
34 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
35 const [playerState, setPlayerState] = createStore<PlayerState>({
36 isPlaying: false,
37 currentTrack: null,
38 currentTime: 0,
39 volume: 0.5,
40 buffered: 0,
41 rate: 1,
42 });
43 const [showOptions, setShowOptions] = createSignal(false);
44 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
45 const [options, setOptions] = createStore<AppOptions>({
46 bitrate: 128000,
47 enableTranscoding: true,
48 disableChunkedTranscoding: false,
49 maxTranscodes: 4,
50 maxDownloads: 2,
51 rememberDir: false,
52 format: MediaFormat["opus/webm"],
53 hideVolume: false,
54 duplicateWarning: true,
55 });
56 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
57 const [isShuffled, setShuffled] = createSignal(false);
58 const [isRepeated, setRepeated] = createSignal(false);
59 const [isOffline, setOffline] = createSignal(true);
60 const [files, fetchFiles] = createResource(
61 () => ({ dir: currentDir(), offline: isOffline() }),
62 async (args) => {
63 //new map to force rerender
64 return new Map(await listFiles(args.dir, args.offline, false));
65 },
66 { initialValue: new Map() },
67 );
68
69 createEffect(() => {
70 const parts = currentDir().split("/").filter(Boolean);
71 setBreadcrumbs(["root", ...parts]);
72 // Update URL fragment when directory changes
73 window.location.hash = encodeURI(currentDir());
74 });
75
76 function stopPlaying() {
77 batch(() => {
78 setPlayerState("isPlaying", false);
79 setPlayerState("currentTime", 0);
80 setPlayerState("currentTrack", null);
81 });
82 audioElement.pause();
83 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
84 audioElement.srcObject = null;
85 }
86
87 createEffect(
88 on(playlist, () => {
89 if (playlist().length === 0) {
90 stopPlaying();
91 }
92 }),
93 );
94
95 let audioElement!: HTMLAudioElement;
96
97 function handlePlayerError(e: unknown) {
98 if (e instanceof Error && e.name === "AbortError") return;
99 if (e instanceof Error && e.name === "NotSupportedError") {
100 toast(
101 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
102 "error",
103 );
104 } else {
105 toast("Playing track failed", "error", e);
106 }
107 }
108
109 // Play a track from the playlist
110 async function playTrack(track: PlaylistItem) {
111 try {
112 const src = await getSrc(track.path, options, isOffline());
113 if (!src) return;
114 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
115
116 //need both of these lines to work without logging an error
117 //just removeAttribute doesn't work at all
118 //just srcObject = null works, but logs a weird csp error
119 audioElement.removeAttribute("src");
120 audioElement.srcObject = null;
121 audioElement.src = src;
122 batch(() => {
123 setPlayerState("isPlaying", true);
124 setPlayerState("currentTime", 0);
125 setPlayerState("buffered", 0);
126 //clone, otherwise it shares the reference
127 setPlayerState("currentTrack", { ...track });
128 });
129
130 await audioElement.play();
131 } catch (e) {
132 setPlayerState("isPlaying", false);
133 handlePlayerError(e);
134 }
135 }
136
137 // Play next track
138 function playNext() {
139 const currentTrack = playerState.currentTrack;
140 if (!currentTrack) return;
141
142 let nextTrack: PlaylistItem | null = null;
143
144 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
145 if (isShuffled()) {
146 // Pick a different random track
147 if (playlist().length > 1) {
148 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
149 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
150 } else {
151 nextTrack = isRepeated() ? currentTrack : null;
152 }
153 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
154 nextTrack = playlist()[currentIndex + 1];
155 } else if (isRepeated()) {
156 nextTrack = playlist()[0];
157 }
158 if (nextTrack) {
159 playTrack(nextTrack);
160 } else {
161 stopPlaying();
162 }
163 }
164
165 // Play previous track
166 function playPrevious() {
167 const currentTrack = playerState.currentTrack;
168 if (!currentTrack) return;
169 const track = playlist().find((item) => item.id === currentTrack.id);
170 if (track) playTrack(track);
171 }
172
173 // Toggle play/pause
174 function togglePlay() {
175 if (playerState.currentTrack === null) return;
176 const newIsPlaying = !playerState.isPlaying;
177 setPlayerState("isPlaying", newIsPlaying);
178
179 if (newIsPlaying) {
180 audioElement.play().catch((e) => {
181 setPlayerState("isPlaying", false);
182 setPlayerState("currentTime", 0);
183 handlePlayerError(e);
184 });
185 } else {
186 audioElement.pause();
187 }
188 }
189
190 // Update progress when the time updates
191 function updateProgress() {
192 setPlayerState("currentTime", audioElement.currentTime);
193 setPlayerState(
194 "buffered",
195 audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
196 );
197 }
198
199 // Initialize app
200 onMount(async () => {
201 // Load from localStorage
202 try {
203 const savedOptions = localStorage.getItem(LocalStorageValues.options);
204 if (savedOptions) {
205 setOptions({ ...options, ...JSON.parse(savedOptions) });
206 }
207 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
208 if (savedVolume && !Number.isNaN(savedVolume)) {
209 setPlayerState("volume", savedVolume);
210 }
211 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
212 if (savedPlaylist) {
213 setPlaylist(JSON.parse(savedPlaylist));
214 }
215 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
216 setShuffled(savedShuffle === "true");
217 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
218 setRepeated(savedRepeat === "true");
219 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
220 setOffline(savedOffline === "true");
221 const savedPath = localStorage.getItem(LocalStorageValues.path);
222 if (options.rememberDir && savedPath !== null && !window.location.hash) {
223 setCurrentDir(savedPath);
224 }
225 } catch (e) {
226 toast("loading values from localStorage failed", "error", e);
227 localStorage.clear();
228 }
229 // Sync to localStorage
230 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
231 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
232 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
233 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
234 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
235 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
236
237 // Set initial audio element volume
238 audioElement.volume = playerState.volume;
239
240 // Set up audio element event handlers
241 audioElement.addEventListener("ended", playNext);
242 audioElement.addEventListener("timeupdate", updateProgress);
243 audioElement.addEventListener("durationchange", updateProgress);
244 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
245 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
246 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
247
248 window.addEventListener("hashchange", () => {
249 setCurrentDir(decodeURI(window.location.hash.slice(1)));
250 });
251
252 document.addEventListener("keydown", (e) => {
253 if (e.key === " ") togglePlay();
254 });
255 });
256
257 createEffect(() => {
258 audioElement.volume = playerState.volume;
259 audioElement.playbackRate = playerState.rate;
260 });
261
262 return (
263 <div class="app-container">
264 <div class="app-header">
265 <div style={{ "min-width": 0 }}>
266 <div class="title">Pico Pixel Player</div>
267 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
268 </div>
269
270 <div class="spacer" />
271 <div class="app-buttons">
272 <button
273 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
274 class="offline-button"
275 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
276 textContent={isOffline() ? "Go online" : "Go offline"}
277 onclick={() => setOffline(!isOffline())}
278 />
279 <div>
280 <button
281 title="Toggle File Browser"
282 class="filebrowser-toggle"
283 onClick={() => setShowFileBrowser((prev) => !prev)}
284 >
285 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
286 </button>
287 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
288 <PlaylistSVG />
289 </button>
290 <button
291 title="Toggle Fullscreen"
292 onClick={() =>
293 document.fullscreenElement
294 ? document.exitFullscreen()
295 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
296 }
297 >
298 <FullscreenSVG />
299 </button>
300 <button title="Settings" onClick={() => setShowOptions(true)}>
301 <CogSVG />
302 </button>
303 </div>
304 </div>
305 </div>
306
307 <div class="main-content">
308 <FileBrowser
309 breadcrumbs={breadcrumbs}
310 currentDir={currentDir}
311 setCurrentDir={setCurrentDir}
312 playTrack={playTrack}
313 playlist={playlist}
314 setPlaylist={setPlaylist}
315 showFileBrowser={showFileBrowser}
316 setShowFileBrowser={setShowFileBrowser}
317 options={options}
318 isOffline={isOffline}
319 files={files}
320 fetchFiles={fetchFiles}
321 />
322
323 <Playlist
324 currentDir={currentDir}
325 playlist={playlist}
326 setPlaylist={setPlaylist}
327 playerState={playerState}
328 setPlayerState={setPlayerState}
329 playTrack={playTrack}
330 playNext={playNext}
331 audioElement={audioElement}
332 options={options}
333 fetchFiles={fetchFiles}
334 />
335 </div>
336
337 <audio ref={audioElement} preload="auto" />
338
339 <PlayerControls
340 playerState={playerState}
341 setPlayerState={setPlayerState}
342 togglePlay={togglePlay}
343 playPrevious={playPrevious}
344 playNext={playNext}
345 isShuffled={isShuffled}
346 setShuffled={setShuffled}
347 isRepeated={isRepeated}
348 setRepeated={setRepeated}
349 audioElement={audioElement}
350 options={options}
351 />
352
353 <OptionsModal
354 showOptions={showOptions}
355 setShowOptions={setShowOptions}
356 options={options}
357 setOptions={setOptions}
358 />
359
360 <PlaylistManager
361 showPlaylistManager={showPlaylistManager}
362 setShowPlaylistManager={setShowPlaylistManager}
363 playlist={playlist}
364 setPlaylist={setPlaylist}
365 playTrack={playTrack}
366 />
367 </div>
368 );
369};
370
371export default App;
372