import { createSignal, createEffect, onMount, on } from "solid-js"; import type { Component } from "solid-js"; import type { PlaylistItem, PlayerState, AppOptions } from "./types"; import OptionsModal from "./components/OptionsModal"; import PlayerControls from "./components/PlayerControls"; import FileBrowser from "./components/FileBrowser"; import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG"; import { LocalStorageValues, toast } from "./utils"; import Playlist from "./components/Playlist"; import { getSrc } from "./offline"; import { MediaFormat } from "music-server-shared/types"; import PlaylistManager from "./components/PlaylistManager"; //TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[] //TODO: remember scroll position for parent directories //TODO: better touch controls? //TODO: Add documentation to functions //TODO: allow syncing playlists to server //TODO: button to show metadata //TODO: show cover of playing item //TODO: delete confirmation? //TODO: nicer errors when song can't be played because server is down //TODO: setting export/import //TODO: video support const App: Component = () => { const [showFileBrowser, setShowFileBrowser] = createSignal(false); const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1))); const [playlist, setPlaylist] = createSignal([]); const [playerState, setPlayerState] = createSignal({ isPlaying: false, currentTrack: null, currentTime: 0, volume: 0.5, buffered: 0, }); const [showOptions, setShowOptions] = createSignal(false); const [showPlaylistManager, setShowPlaylistManager] = createSignal(false); const [options, setOptions] = createSignal({ bitrate: 128000, enableTranscoding: true, maxTranscodes: 4, maxDownloads: 2, rememberDir: false, format: MediaFormat["opus/ogg"], }); const [breadcrumbs, setBreadcrumbs] = createSignal([]); const [isShuffled, setShuffled] = createSignal(false); const [isRepeated, setRepeated] = createSignal(false); const [isOffline, setOffline] = createSignal(true); createEffect(() => { const parts = currentDir().split("/").filter(Boolean); setBreadcrumbs(["root", ...parts]); // Update URL fragment when directory changes window.location.hash = encodeURI(currentDir()); }); function stopPlaying() { setPlayerState({ ...playerState(), currentTrack: null, isPlaying: false, currentTime: 0, }); audioElement.pause(); audioElement.srcObject = null; } createEffect( on(playlist, () => { if (playlist().length === 0) { stopPlaying(); } }), ); let audioElement!: HTMLAudioElement; function handlePlayerError(e: unknown) { if (e instanceof Error && e.name === "NotSupportedError") { toast( "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings", "error", ); } else { toast("Playing track failed", "error", e); } } let playRequested = false; //solves errors about aborting playback // Play a track from the playlist async function playTrack(track: PlaylistItem) { if (playRequested) return; playRequested = true; try { const src = await getSrc(track.path, options(), isOffline()); console.log(src); if (!src) return; if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src); //need both of these lines to work without logging an error //just removeAttribute doesn't work at all //just srcObject = null works, but logs a weird csp error audioElement.removeAttribute("src"); audioElement.srcObject = null; audioElement.src = src; setPlayerState({ ...playerState(), currentTrack: track, isPlaying: true, currentTime: 0, }); await audioElement.play(); } catch (e) { setPlayerState({ ...playerState(), isPlaying: false, currentTime: 0, }); handlePlayerError(e); } finally { playRequested = false; } } // Play next track function playNext() { const currentTrack = playerState().currentTrack; if (!currentTrack) return; let nextTrack: PlaylistItem | null = null; const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id); if (isShuffled()) { // Pick a different random track if (playlist().length > 1) { const otherTracks = playlist().filter((_, idx) => idx !== currentIndex); nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)]; } else { nextTrack = currentTrack; } } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) { nextTrack = playlist()[currentIndex + 1]; } else if (isRepeated()) { nextTrack = playlist()[0]; } if (nextTrack) { playTrack(nextTrack); } else { stopPlaying(); } } // Play previous track function playPrevious() { const currentTrack = playerState().currentTrack; if (!currentTrack) return; const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id); if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]); } // Toggle play/pause function togglePlay() { if (playerState().currentTrack === null) return; const newIsPlaying = !playerState().isPlaying; setPlayerState({ ...playerState(), isPlaying: newIsPlaying }); if (newIsPlaying) { audioElement.play().catch((e) => { setPlayerState({ ...playerState(), isPlaying: false, currentTime: 0, }); handlePlayerError(e); }); } else { audioElement.pause(); } } // Update progress when the time updates function updateProgress() { setPlayerState({ ...playerState(), currentTime: audioElement.currentTime, buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0, }); } // Initialize app onMount(async () => { // Load from localStorage try { const savedOptions = localStorage.getItem(LocalStorageValues.options); if (savedOptions) { setOptions(JSON.parse(savedOptions)); } const savedVolume = localStorage.getItem(LocalStorageValues.volume); if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) { setPlayerState({ ...playerState(), volume: Number.parseFloat(savedVolume) }); } const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist); if (savedPlaylist) { setPlaylist(JSON.parse(savedPlaylist)); } const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle); if (savedShuffle !== null) { setShuffled(savedShuffle === "true"); } const savedRepeat = localStorage.getItem(LocalStorageValues.repeat); if (savedRepeat !== null) { setRepeated(savedRepeat === "true"); } const savedPath = localStorage.getItem(LocalStorageValues.path); if (options().rememberDir && savedPath !== null && !window.location.hash) { setCurrentDir(savedPath); } } catch (e) { toast("loading values from localStorage failed", "error", e); localStorage.clear(); } // Sync to localStorage createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options()))); createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState().volume.toString())); createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist()))); createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString())); createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString())); createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir())); // Set initial audio element volume audioElement.volume = playerState().volume; // Set up audio element event handlers audioElement.addEventListener("ended", playNext); audioElement.addEventListener("timeupdate", updateProgress); audioElement.addEventListener("durationchange", updateProgress); audioElement.addEventListener("pause", () => setPlayerState({ ...playerState(), isPlaying: false, }), ); audioElement.addEventListener("play", () => setPlayerState({ ...playerState(), isPlaying: true, }), ); audioElement.addEventListener("volumechange", () => setPlayerState({ ...playerState(), volume: audioElement.volume, }), ); window.addEventListener("hashchange", () => { setCurrentDir(decodeURI(window.location.hash.slice(1))); }); document.addEventListener("keydown", (e) => { if (e.key === " ") togglePlay(); }); }); createEffect(() => { // Update audio element when volume changes audioElement.volume = playerState().volume; }); return (
Pico Pixel Player
); }; export default App;