import "./FileBrowser.css"; import type { Directory, FileListing, MediaFile } from "music-server-shared/types"; import { type Accessor, createEffect, createResource, createSignal, For, type InitializedResource, on, onMount, type ResourceActions, type Setter, Show, } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import type { FlatFileListing } from "../App"; import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline"; import type { AppOptions, PlaylistItem } from "../types"; import { basename, compareDirFirst, formatFilename, joinPath, LocalStorageValues, persistedToggle, toast, } from "../utils"; import { DownloadButton } from "./DownloadButton"; import { LoadingScreen } from "./LoadingScreen"; import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG"; interface FileBrowserProps { currentDir: Accessor; setCurrentDir: Setter; playTrack: (track: PlaylistItem) => void; playlist: Accessor; setPlaylist: Setter; showFileBrowser: Accessor; setShowFileBrowser: Setter; options: AppOptions; isOffline: Accessor; files: InitializedResource; fetchFiles: ResourceActions; } export default function FileBrowser(props: FileBrowserProps) { //"root" stands for the virtual top level that lists the user's roots const breadcrumbs = () => ["root", ...props.currentDir().split("/").filter(Boolean)]; const [loadingActions, setLoadingActions] = createSignal(0); const [showActionButtons, setShowActionButtons] = persistedToggle(LocalStorageValues.fileBrowserButtons, true); const [showFilter, setShowFilter] = createSignal(false); const [filter, setFilter] = createSignal(""); createEffect( on(props.currentDir, () => { setFilter(""); setShowFilter(false); }), ); createEffect( on([showFilter, showActionButtons], ([showFilter, showActionButtons]) => { if (!showActionButtons) setShowFilter(false); if (!showFilter) setFilter(""); }), ); const [cover, fetchCover] = createResource( () => ({ dir: props.currentDir(), offline: props.isOffline() }), async (args, info) => { if (info.value) URL.revokeObjectURL(info.value as string); return await getCover(args.dir, props.options, false, args.offline); }, { initialValue: undefined }, ); let dialogElement!: HTMLDialogElement; let addAnywayButton!: HTMLButtonElement; function handleBreadcrumbClick(index: number) { if (index === 0) { props.setCurrentDir(""); } else { props.setCurrentDir(joinPath(...breadcrumbs().slice(1, index + 1))); } } function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem { return { id: crypto.randomUUID(), metadata: item.metadata, path: path, }; } // Recursively convert file listing to PlaylistItem array function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] { //keep sorting same as shown in the filebrowser: directories first (by name), then files (by display name) const displayNameOf = (name: string, item: Directory | MediaFile) => "metadata" in item ? formatFilename({ path: name, metadata: item.metadata }, props.options.filebrowserDisplay) : name; const sorted = Object.entries(files).sort(([aName, aItem], [bName, bItem]) => compareDirFirst( displayNameOf(aName, aItem), !("metadata" in aItem), displayNameOf(bName, bItem), !("metadata" in bItem), ), ); let result: PlaylistItem[] = []; for (const [fileName, file] of sorted) { const fullPath = joinPath(basePath, fileName); if ("metadata" in file) { // It's an audio file result.push(fileToPlaylistItem(fullPath, file)); } else if (Object.keys(file.files).length > 0) { // It's a directory with files, recursively add its contents result = [...result, ...listingToPlaylistItems(fullPath, file.files)]; } } return result; } function playFile(path: string, item: MediaFile) { props.playTrack(fileToPlaylistItem(path, item)); } function scrollPlaylistToBottom() { setTimeout(() => { const element = document.getElementById("playlist"); if (!element) return; element.scrollTop = element.scrollHeight; }, 0); } const addedToast = toast("", "info"); addedToast.hideToast(); async function playFiles(fullPath: string, recursive: boolean, queue: boolean, item?: MediaFile) { if (recursive) { setLoadingActions(loadingActions() + 1); } try { const oldPlaylist = queue ? props.playlist() : []; const newPlaylist = item ? [fileToPlaylistItem(fullPath, item)] : listingToPlaylistItems(fullPath, await listFiles(fullPath, props.isOffline(), recursive)); const addAction = () => { if (newPlaylist.length !== 0 && oldPlaylist.length === 0) props.playTrack(newPlaylist[0]); props.setPlaylist([...oldPlaylist, ...newPlaylist]); addedToast.hideToast(); addedToast.options.text = `Added ${newPlaylist.length} tracks`; addedToast.showToast(); if (queue) scrollPlaylistToBottom(); else props.setShowFileBrowser(false); }; const newPaths = new Set(newPlaylist.map((item) => item.path)); if (props.options.duplicateWarning && oldPlaylist.some((oldItem) => newPaths.has(oldItem.path))) { addAnywayButton.onclick = () => { addAction(); dialogElement.close(); }; dialogElement.showModal(); } else { addAction(); } } finally { if (recursive) { setLoadingActions(loadingActions() - 1); } } } function reloadDirectory() { clearDirectoryCache(props.currentDir()).then(() => props.fetchFiles.refetch()); //this call only exists to refresh the cache/db entry, the url it mints is unused //(fetchCover.refetch creates the one that actually gets rendered), so revoke it getCover(props.currentDir(), props.options, false, props.isOffline(), true).then((url) => { if (url) URL.revokeObjectURL(url); fetchCover.refetch(); }); } let filelistElement!: HTMLDivElement; const scrollPositions: { part: string; pos: number }[] = []; let lastDir = props.currentDir(); createEffect( //on props.files, because doing it on props.currentDir causes us to scroll up before the new content is being loaded on(props.files, () => { //don't update scroll pos if directory didn't change //otherwise would cause jank when scrolling while in offline mode and synced items pop up if (lastDir === props.currentDir()) return; lastDir = props.currentDir(); //need the timeout, otherwise we could execute the scroll before rendering finishes setTimeout(() => { let parts = `${props.currentDir()}`.split("/"); if (props.currentDir() !== "") parts = ["", ...parts]; scrollPositions.length = Math.min(parts.length, scrollPositions.length); for (let index = 0; index < parts.length; index++) { const part = parts[index]; if (scrollPositions.length - 1 < index) { scrollPositions.push({ part, pos: 0 }); } else if (scrollPositions[index].part !== part) { scrollPositions[index] = { part, pos: 0 }; } } filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos; }, 0); }), ); onMount(() => { filelistElement.addEventListener("scrollend", () => { if (scrollPositions.length > 0) scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop; }); }); function getAbsolutePathFiles() { return props.files.latest.map(([name, item]) => { return [joinPath(props.currentDir(), name), item] as [string, Directory | MediaFile]; }); } interface FileProxy { key: string; fullPath: string; displayName: string; file: MediaFile | Directory; } //this proxy store allows us to diff changes correctly when the filelisting changes, only rerendering the changed items //absolute paths are required here to make sure it's really the same file in the same directory const [fileStore, setFileStore] = createStore([]); function fileSort(a: FileProxy, b: FileProxy): number { return compareDirFirst(a.displayName, !("metadata" in a.file), b.displayName, !("metadata" in b.file)); } createEffect(() => { setFileStore( reconcile( getAbsolutePathFiles() .map((value) => { const fullPath = value[0]; const displayName = "metadata" in value[1] ? formatFilename({ path: value[0], metadata: value[1].metadata }, props.options.filebrowserDisplay) : basename(value[0]); return { key: fullPath + displayName, fullPath, displayName, file: value[1], }; }) .filter((item) => { return item.displayName.toLowerCase().includes(filter().toLowerCase()); }) .sort(fileSort), { key: "key" }, ), ); }); const [downloadedChildren, setDownloadedChildren] = createStore>({}); createEffect(() => { const storedFiles = storedFilesLive(); const newChildren: Record = {}; for (const storedFile of storedFiles) { const pathParts = storedFile.toString().split("/"); // Update all parent directories in the path for (let i = 1; i <= pathParts.length; i++) { const parentPath = pathParts.slice(0, i).join("/"); if (parentPath) { newChildren[parentPath] = (newChildren[parentPath] || 0) + 1; } } } setDownloadedChildren(reconcile(newChildren)); }); return ( <> e.target.nodeName === "DIALOG" && dialogElement.close()} >
Some of the items you're trying to add already exists in your current playlist
{(crumb, index) => ( <> handleBreadcrumbClick(index())}> {crumb} {index() < breadcrumbs().length - 1 && "/"} )}
setFilter(e.target.value)} onkeydown={(e) => e.key === "Escape" && setShowFilter(false)} class="filter-files" type="text" placeholder="Filter files..." />
{({ fullPath, displayName, file }) => { const isDirectory = !("metadata" in file); return (
{ (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile); }} > {(isDirectory && ) || } {displayName}
{isDirectory && !props.isOffline() && downloadedChildren[fullPath]}
); }}
0}>
); }