import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js"; import type { Component } from "solid-js"; import { type PlaylistItem, type PlayerState, type AppOptions, FileDisplay } from "./types"; import OptionsModal from "./components/OptionsModal"; import PlayerControls from "./components/PlayerControls"; import FileBrowser from "./components/FileBrowser"; import { ClosedFolderSVG, CogSVG, DownloadManagerSVG, FullscreenSVG, LoginSVG, OpenFolderSVG, PlaylistSVG, } from "./components/SVG"; import { formatFilename, LocalStorageValues, toast } from "./utils"; import Playlist from "./components/Playlist"; import { getCover, getSrc, listFiles } from "./offline"; import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types"; import PlaylistManager from "./components/PlaylistManager"; import { BUILD_HASH } from "./build-info"; import { createStore, reconcile } from "solid-js/store"; import { decodePath, encodePath } from "music-server-shared/utils"; import DownloadManager from "./components/DownloadManager"; import { SignIn } from "./components/SignIn"; //TODO: add bitrate+format to metadata //TODO: more file display options //TODO: keep synced-aborted in downloadmanager //TODO: bypass music-metadata if it fails //TODO: Add documentation to functions //TODO: allow syncing playlists to server //TODO: video support //TODO: virtual rendering for playlist //TODO: custom serialization? export type FlatFileListing = [string, Directory | MediaFile][]; const App: Component = () => { const [showFileBrowser, setShowFileBrowser] = createSignal(false); const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1))); const [playlist, setPlaylist] = createSignal([]); const [playerState, setPlayerState] = createStore({ isPlaying: false, currentTrack: null, currentTime: 0, volume: 0.5, buffered: [], rate: 1, waitingForStart: false, }); const [showOptions, setShowOptions] = createSignal(false); const [showPlaylistManager, setShowPlaylistManager] = createSignal(false); const [showDownloadManager, setShowDownloadManager] = createSignal(false); const [showSignIn, setShowSignIn] = createSignal(false); const [options, setOptions] = createStore({ //default settings enableTranscoding: true, disableChunkedTranscoding: false, bitrate: 128000, format: MediaFormat["opus/webm"], maxTranscodes: 4, maxDownloads: 2, transcodeCovers: false, hideVolume: false, hideCover: false, hidePlaylistCover: false, fontResolution: 12, filebrowserDisplay: FileDisplay.TRACKNO_TITLE, playlistDisplay: FileDisplay.TRACKNO_TITLE, controlsDisplay: FileDisplay.TITLE, progressBarHeight: 10, rememberDir: false, duplicateWarning: true, }); const [breadcrumbs, setBreadcrumbs] = createSignal([]); const [isShuffled, setShuffled] = createSignal(false); const [isRepeated, setRepeated] = createSignal(false); const [isOffline, setOffline] = createSignal(true); const [files, fetchFiles] = createResource( () => ({ dir: currentDir(), offline: isOffline() }), async (args) => { return Array.from(await listFiles(args.dir, args.offline, false)); }, { initialValue: [] }, ); createEffect(() => { const parts = currentDir().split("/").filter(Boolean); setBreadcrumbs(["root", ...parts]); // Update URL fragment when directory changes window.location.hash = encodePath(currentDir()); }); createEffect( on(currentDir, () => { setShowFileBrowser(true); }), ); createEffect(() => { const new_font = new FontFace( "ark-latin", `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`, ); document.fonts.clear(); document.fonts.add(new_font); }); function stopPlaying() { batch(() => { setPlayerState("isPlaying", false); setPlayerState("currentTime", 0); setPlayerState("currentTrack", null); }); audioElement.pause(); if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src); audioElement.srcObject = null; } createEffect( on(playlist, () => { if (playlist().length === 0) { stopPlaying(); } }), ); if ("mediaSession" in navigator) { createEffect(async () => { if (!playerState.currentTrack) { navigator.mediaSession.metadata = new MediaMetadata(); navigator.mediaSession.setActionHandler("pause", null); navigator.mediaSession.setActionHandler("play", null); navigator.mediaSession.setActionHandler("stop", null); navigator.mediaSession.setActionHandler("previoustrack", null); navigator.mediaSession.setActionHandler("nexttrack", null); return; } const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? ""; if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc); const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline()); navigator.mediaSession.metadata = new MediaMetadata({ title: formatFilename(playerState.currentTrack, options.controlsDisplay), album: playerState.currentTrack.metadata.album, artist: playerState.currentTrack.metadata.artist, artwork: [ { src: artwork ?? "", }, ], }); navigator.mediaSession.setActionHandler("nexttrack", playNext); navigator.mediaSession.setActionHandler("previoustrack", playPrevious); navigator.mediaSession.setActionHandler("stop", stopPlaying); }); createEffect(() => { if (!playerState.currentTrack) { navigator.mediaSession.setActionHandler("pause", null); navigator.mediaSession.setActionHandler("play", null); return; } if (playerState.isPlaying) { navigator.mediaSession.setActionHandler("pause", togglePlay); navigator.mediaSession.setActionHandler("play", null); } else { navigator.mediaSession.setActionHandler("pause", null); navigator.mediaSession.setActionHandler("play", togglePlay); } }); } let audioElement!: HTMLAudioElement; function handlePlayerError(e: unknown) { if (e instanceof Error && e.name === "AbortError") return; 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); } } // Play a track from the playlist async function playTrack(track: PlaylistItem) { try { const src = await getSrc(track.path, options, isOffline()); 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; batch(() => { setPlayerState("isPlaying", true); setPlayerState("currentTime", 0); setPlayerState("buffered", []); //clone, otherwise it shares the reference setPlayerState("currentTrack", { ...track }); setPlayerState("waitingForStart", true); }); await audioElement.play(); } catch (e) { setPlayerState("isPlaying", false); handlePlayerError(e); } } // 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 = isRepeated() ? currentTrack : null; } } 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("isPlaying", newIsPlaying); if (newIsPlaying) { setPlayerState("waitingForStart", true); audioElement .play() .catch((e) => { setPlayerState("isPlaying", false); setPlayerState("currentTime", 0); handlePlayerError(e); }) .finally(() => { setPlayerState("waitingForStart", false); }); } else { audioElement.pause(); } } function timeRangesToArray(timeRanges: TimeRanges) { const result = []; for (let i = 0; i < timeRanges.length; i++) { result.push({ start: timeRanges.start(i), end: timeRanges.end(i), }); } return result; } // Update progress when the time updates function updateProgress() { setPlayerState("currentTime", audioElement.currentTime); setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered))); } // Initialize app onMount(async () => { // Load from localStorage try { const savedOptions = localStorage.getItem(LocalStorageValues.options); if (savedOptions) { //keep default settings when no override exists setOptions({ ...options, ...JSON.parse(savedOptions) }); } const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? ""); if (savedVolume && !Number.isNaN(savedVolume)) { setPlayerState("volume", savedVolume); } const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist); if (savedPlaylist) { setPlaylist(JSON.parse(savedPlaylist)); } const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle); setShuffled(savedShuffle === "true"); const savedRepeat = localStorage.getItem(LocalStorageValues.repeat); setRepeated(savedRepeat === "true"); const savedOffline = localStorage.getItem(LocalStorageValues.offline); setOffline(savedOffline === "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())); createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString())); // 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("isPlaying", false)); audioElement.addEventListener("play", () => setPlayerState("isPlaying", true)); audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume)); audioElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false)); window.addEventListener("hashchange", () => { setCurrentDir(decodePath(window.location.hash.slice(1))); }); document.addEventListener("keydown", (e) => { const activeElementTag = document.activeElement?.tagName.toLowerCase(); if (activeElementTag === "input") { return; } if (e.key === " ") { togglePlay(); e.preventDefault(); } }); }); createEffect(() => { audioElement.volume = playerState.volume; audioElement.playbackRate = playerState.rate; }); return (
Pico Pixel Player
{BUILD_HASH}
); }; export default App;