file store is now an array to simplify updating items, directories automatically show progress when some child is bein downloaded
Mwebclient/src/App.tsx
@@ -8,7 +8,7 @@ import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } fr
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore } from "solid-js/store";
@@ -24,6 +24,8 @@ import { decodePath, encodePath } from "music-server-shared/utils";
//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(decodeURI(window.location.hash.slice(1)));
@@ -57,13 +59,18 @@ const App: Component = () => {
const [isShuffled, setShuffled] = createSignal(false);
const [isRepeated, setRepeated] = createSignal(false);
const [isOffline, setOffline] = createSignal(true);
const [files, fetchFiles] = createResource(
const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
() => ({ dir: currentDir(), offline: isOffline() }),
async (args) => {
//new map to force rerender
return new Map(await listFiles(args.dir, args.offline, false));
function fileSort(a: [string, Directory | MediaFile], b: [string, Directory | MediaFile]): number {
//sort directories first
if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
return a[0].localeCompare(b[0], undefined, { numeric: true });
}
return Array.from(await listFiles(args.dir, args.offline, false)).sort(fileSort);
},
{ initialValue: new Map() },
{ initialValue: [] },
);
createEffect(() => {
Mwebclient/src/components/DownloadButton.tsx
@@ -1,16 +1,24 @@
import "./DownloadButton.css";
import type { MediaFile, Directory, FileListing } from "music-server-shared/types";
import { deleteFile, download, getDownloadStatus } from "../offline";
import type { Accessor, ResourceActions } from "solid-js";
import {
deleteFile,
download,
type DownloadStatus,
getDownloadStatus,
getDownloadStatusWithChildren,
type StatusWithChildren,
} from "../offline";
import { createMemo, type Accessor, type ResourceActions, type Signal } from "solid-js";
import type { AppOptions } from "../types";
import { formatBytes, joinPath } from "../utils";
import { DeleteSVG, RefreshSVG, SyncSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
import type { FlatFileListing } from "../App";
interface DownloadButtonProps {
fileName: string;
item: MediaFile | Directory;
fetchFiles: ResourceActions<FileListing>;
fetchFiles: ResourceActions<FlatFileListing>;
isOffline: Accessor<boolean>;
currentDir: Accessor<string>;
options: AppOptions;
@@ -19,6 +27,24 @@ interface DownloadButtonProps {
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.fileName);
const containsProgressChildren = createMemo(() => {
const status = getDownloadStatusWithChildren(fullPath);
if (!status.children) return false;
function inProgress(signal: Signal<DownloadStatus>) {
const status = signal[0]();
return status === "UnspecificProgress" || (typeof status === "object" && "bytes" in status);
}
function recurse(statusWithChildren: StatusWithChildren) {
for (const [, child] of statusWithChildren.children || []) {
if (inProgress(child.status) || recurse(child)) return true;
}
return false;
}
return recurse(status);
});
return (
<div
class="download-item"
@@ -32,7 +58,7 @@ export function DownloadButton(props: DownloadButtonProps) {
{(() => {
const [getStatus] = getDownloadStatus(fullPath);
const progress = getStatus();
if (progress === "UnspecificProgress") return <LoadingSpinner center={true} />;
if (progress === "UnspecificProgress" || containsProgressChildren()) return <LoadingSpinner center={true} />;
if (props.isOffline()) {
return (
<button title="Purge this file/these files from local sync storage">
Mwebclient/src/components/FileBrowser.tsx
@@ -6,6 +6,7 @@ import {
createSignal,
For,
type InitializedResource,
mapArray,
on,
onMount,
type ResourceActions,
@@ -13,13 +14,15 @@ import {
Show,
Suspense,
} from "solid-js";
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import type { MediaFile, FileListing } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
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<string[]>;
@@ -32,8 +35,8 @@ interface FileBrowserProps {
setShowFileBrowser: Setter<boolean>;
options: AppOptions;
isOffline: Accessor<boolean>;
files: InitializedResource<FileListing>;
fetchFiles: ResourceActions<FileListing>;
files: InitializedResource<FlatFileListing>;
fetchFiles: ResourceActions<FlatFileListing>;
}
export default function FileBrowser(props: FileBrowserProps) {
@@ -80,7 +83,7 @@ export default function FileBrowser(props: FileBrowserProps) {
result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
}
}
return result.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
return result;
}
function playFile(path: string, item: MediaFile) {
@@ -143,13 +146,6 @@ export default function FileBrowser(props: FileBrowserProps) {
fetchCover.refetch();
}
function fileSort(a: [string, Directory | MediaFile], b: [string, Directory | MediaFile]): number {
//sort directories first
if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
return a[0].localeCompare(b[0], undefined, { numeric: true });
}
let filelistElement!: HTMLDivElement;
const scrollPositions: { part: string; pos: number }[] = [];
createEffect(
@@ -182,6 +178,10 @@ export default function FileBrowser(props: FileBrowserProps) {
});
});
//needed to only apply diff, and allow files.refetech to not cause full rerenders
const [fileStore, setFileStore] = createStore<FlatFileListing>([]);
createEffect(() => setFileStore(reconcile(props.files.latest)));
return (
<>
<dialog
@@ -260,8 +260,11 @@ export default function FileBrowser(props: FileBrowserProps) {
</Show>
<div class="file-list" ref={filelistElement}>
<For each={Array.from(props.files?.latest.entries()).sort(fileSort)}>
{([fileName, item]) => {
{/* for some reason neither <For> nor <Index> seems to cache the input files correctly
either causing full rerenders or only updating the length, and newly added items, not changes ones */}
{mapArray(
() => fileStore,
([fileName, item]) => {
const isDirectory = !("metadata" in item);
const fullPath = joinPath(props.currentDir(), fileName);
return (
@@ -299,8 +302,8 @@ export default function FileBrowser(props: FileBrowserProps) {
</div>
</>
);
}}
</For>
},
)()}
</div>
</div>
<Show when={!props.options.hideCover && (cover.loading || cover())}>
Mwebclient/src/components/Playlist.tsx
@@ -15,7 +15,7 @@ import { formatTime, LocalStorageValues, toast } from "../utils";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
import { savePlaylist, download, getStoredFiles, getDownloadStatus } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FileListing } from "music-server-shared/types";
import type { FlatFileListing } from "../App";
interface PlaylistProps {
currentDir: Accessor<string>;
@@ -27,7 +27,7 @@ interface PlaylistProps {
playNext: () => void;
audioElement: HTMLAudioElement;
options: AppOptions;
fetchFiles: ResourceActions<FileListing>;
fetchFiles: ResourceActions<FlatFileListing>;
}
export default function Playlist(props: PlaylistProps) {
Mwebclient/src/offline.ts
@@ -11,6 +11,7 @@ import { joinPath, toast } from "./utils";
import { createSignal, type Accessor, type ResourceActions, type Signal } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
import { encodePath } from "music-server-shared/utils";
import type { FlatFileListing } from "./App";
interface Progress {
bytes: number;
@@ -19,7 +20,7 @@ interface Progress {
export type DownloadStatus = Progress | "UnspecificProgress" | "Synced" | undefined;
//used to display download status for directories/files in the filebrowser view
// export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
interface StatusWithChildren {
export interface StatusWithChildren {
status: Signal<DownloadStatus>;
children?: RecursiveStatus;
}
@@ -27,7 +28,7 @@ interface StatusWithChildren {
type RecursiveStatus = Map<string, StatusWithChildren>;
const downloadStatusMap: RecursiveStatus = new Map<string, StatusWithChildren>();
export function getDownloadStatus(path: string) {
export function getDownloadStatusWithChildren(path: string) {
const parts = path.split("/");
let current = downloadStatusMap;
//iterate through the basepath
@@ -50,7 +51,11 @@ export function getDownloadStatus(path: string) {
ret = { status: createSignal<DownloadStatus>(undefined) };
current.set(fileName, ret);
}
return ret.status;
return ret;
}
export function getDownloadStatus(path: string) {
return getDownloadStatusWithChildren(path).status;
}
//initialize filetrees and download statuses from indexedDB
@@ -334,7 +339,7 @@ export async function deleteFile(
fileName: string,
item: MediaFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FileListing>,
fetchFiles: ResourceActions<FlatFileListing>,
) {
const fullPath = joinPath(basePath, fileName);
const [getStatus, setStatus] = getDownloadStatus(fullPath);
@@ -353,7 +358,7 @@ export async function deleteFile(
await db.deleteAll(FileStore, toDelete);
deleteOfflinePath(fullPath);
for (const path of toDelete) {
setStatus(undefined);
getDownloadStatus(path)[1](undefined);
}
fetchFiles.refetch();
} finally {
@@ -370,7 +375,7 @@ async function downloadFile(
currentDir: Accessor<string>,
options: AppOptions,
item: MediaFile,
fetchFiles: ResourceActions<FileListing>,
fetchFiles: ResourceActions<FlatFileListing>,
): Promise<void> {
const [getStatus, setStatus] = getDownloadStatus(path);
const initialProgress = getStatus();
@@ -413,25 +418,14 @@ async function downloadFile(
} finally {
downloadSemaphore.release();
}
await db.put(FileStore, {
path,
data: blob,
metadata: item.metadata,
metadata: { ...item.metadata }, //spread needed, because item might be a proxy object from the filebrowser, which can't be stored
} as StoredFile);
updateFileTree(path, item, true);
setStatus("Synced");
const basePath = path.split("/").slice(0, -1).join("/");
const fileName = path.split("/").toReversed()[0];
//add files to listing if the containing directory is currently shown
if (currentDir() === basePath) {
//directly mutate files to not cause unnecessary rerenders
fetchFiles.mutate((prev) => {
prev.set(fileName, item);
//new map to force rerender
return new Map(prev);
});
}
fetchFiles.refetch();
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
//restore original status
@@ -445,26 +439,19 @@ export async function download(
basePath: string,
currentDir: Accessor<string>,
options: AppOptions,
fetchFiles: ResourceActions<FileListing>,
fetchFiles: ResourceActions<FlatFileListing>,
) {
const fullPath = joinPath(basePath, fileName);
if ("metadata" in item) {
await downloadFile(fullPath, currentDir, options, item, fetchFiles);
} else {
const [, setStatus] = getDownloadStatus(fullPath);
setStatus("UnspecificProgress");
try {
await getCover(fullPath, false);
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
for (const [childName, childItem] of await listFiles(fullPath, false, false)) {
promises.push(download(childName, childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
} finally {
//always set to undefined afterwards to show the normal sync icon, since we don't know for sure if the directory is completely synced
setStatus(undefined);
await getCover(fullPath, false);
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
for (const [childName, childItem] of await listFiles(fullPath, false, false)) {
promises.push(download(childName, childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
}
}