import "./FileBrowser.css"; import { type Accessor, createEffect, createResource, createSignal, For, type InitializedResource, on, onMount, type ResourceActions, type Setter, Show, Suspense, } from "solid-js"; import type { MediaFile, FileListing, Directory } from "music-server-shared/types"; import type { AppOptions, PlaylistItem } from "../types"; import { generateUUID, joinPath, LocalStorageValues } from "../utils"; import { ClosedFolderSVG, DownSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG"; import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline"; import { DownloadButton } from "./DownloadButton"; import { LoadingScreen } from "./LoadingScreen"; import type { FlatFileListing } from "../App"; import { createStore, reconcile } from "solid-js/store"; interface FileBrowserProps { breadcrumbs: Accessor; 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) { const [loadingActions, setLoadingActions] = createSignal(0); const [showActionButtons, setShowActionButtons] = createSignal(true); 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, args.offline); }, { initialValue: undefined }, ); let dialogElement!: HTMLDialogElement; let addAnywayButton!: HTMLButtonElement; function handleBreadcrumbClick(index: number) { if (index === 0) { props.setCurrentDir(""); } else { props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1))); } } function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem { return { id: generateUUID(), metadata: item.metadata, path: path, }; } // Recursively convert file listing to PlaylistItem array function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] { let result: PlaylistItem[] = []; for (const [fileName, file] of files) { const fullPath = joinPath(basePath, fileName); if ("metadata" in file) { // It's an audio file result.push(fileToPlaylistItem(fullPath, file)); } else if (file.files && file.files.size > 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() { const element = document.getElementById("playlist"); if (!element) return; element.scrollTop = element.scrollHeight; } async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) { if (recursive) { setLoadingActions(loadingActions() + 1); } try { const fullPath = joinPath(props.currentDir(), fileName); let oldLength = 0; let newPlaylist: PlaylistItem[]; const oldPlaylist = queue ? props.playlist() : []; if (item) { newPlaylist = [fileToPlaylistItem(fullPath, item)]; } else { const files = await listFiles(fullPath, props.isOffline(), recursive); newPlaylist = listingToPlaylistItems(fullPath, files); if (queue) { oldLength = oldPlaylist.length; } } const addAction = () => { if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]); props.setPlaylist([...oldPlaylist, ...newPlaylist]); if (queue) scrollPlaylistToBottom(); }; if ( props.options.duplicateWarning && oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path)) ) { addAnywayButton.onclick = () => { addAction(); dialogElement.close(); }; dialogElement.showModal(); } else { addAction(); } } finally { if (recursive) { setLoadingActions(loadingActions() - 1); } } } function reloadDirectory() { clearDirectoryCache(props.currentDir()); //TODO: only clears the current dir, not recursively like clearDirectoryCache deleteCover(props.currentDir()); props.fetchFiles.refetch(); fetchCover.refetch(); } let filelistElement!: HTMLDivElement; const scrollPositions: { part: string; pos: number }[] = []; createEffect( on(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(() => { const savedFileBrowserButtons = localStorage.getItem(LocalStorageValues.fileBrowserButtons); if (savedFileBrowserButtons !== null) setShowActionButtons(savedFileBrowserButtons === "true"); createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString())); filelistElement.addEventListener("scrollend", () => { scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop; }); }); //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([]); createEffect(() => { const absolutePathArray = props.files.latest.map(([name, item]) => { return [joinPath(props.currentDir(), name), item] as [string, Directory | MediaFile]; }); setFileStore(reconcile(absolutePathArray, { key: "0" })); }); return ( <> e.target.nodeName === "DIALOG" && dialogElement.close()} >
Some of the items you're trying to add already exists in your current playlist
{([fullPath, item]) => { const isDirectory = !("metadata" in item); const fileName = fullPath.split("/").toReversed()[0]; return ( <>
{ (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile); }} > {(isDirectory && ) || } {fileName}
); }}
}>
0}>
props.setShowFileBrowser(false)} /> ); }