offline mode uses tree structure, implemented download controls, better download progress indicators
Mwebclient/src/App.tsx
@@ -11,15 +11,11 @@ import { getSrc } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
//TODO: better error messages for unsupported codec
//TODO: dont store playlists with id
//TODO: remember scroll position for parent directories
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
//TODO: Rework offline cache to use tree structure instead of flat map
//TODO: Rework FileListing to be a Map
//TODO: Rework FileListing to be a Map, update offline structures as well
//TODO: button to show metadata
//TODO: show cover of playing item
//TODO: delete confirmation?
@@ -44,7 +40,7 @@ const App: Component = () => {
bitrate: 128000,
enableTranscoding: true,
maxTranscodes: 4,
maxDownloads: 1,
maxDownloads: 2,
rememberDir: false,
format: MediaFormat["opus/ogg"],
});
@@ -80,6 +76,17 @@ const App: Component = () => {
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) {
@@ -107,7 +114,7 @@ const App: Component = () => {
isPlaying: false,
currentTime: 0,
});
toast("Playing track failed", "error", e);
handlePlayerError(e);
} finally {
playRequested = false;
}
@@ -162,7 +169,7 @@ const App: Component = () => {
isPlaying: false,
currentTime: 0,
});
return toast("Error playing track", "error", e);
handlePlayerError(e);
});
} else {
audioElement.pause();
Mwebclient/src/components/DownloadButton.tsx
@@ -3,7 +3,7 @@ import type { AudioFile, Directory, FileListing } from "music-server-shared/type
import { deleteFile, download, signalMap, type SignalMapEntry } from "../offline";
import { createSignal, type Accessor, type ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { formatBytes, joinPath } from "../utils";
import { joinPath } from "../utils";
import { DeleteSVG, DownloadSVG, RefreshSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
@@ -15,6 +15,17 @@ interface DownloadButtonProps {
options: Accessor<AppOptions>;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "Queued...";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(2)} ${sizes[i]}`;
}
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.item.name);
let signal = signalMap.get(fullPath);
Mwebclient/src/components/FileBrowser.css
@@ -68,6 +68,9 @@
&:hover {
background-color: var(--color-c-highlight);
}
& > span {
min-width: 0;
}
}
& > .directory-item {
Mwebclient/src/components/OptionsModal.tsx
@@ -117,7 +117,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="max-downloads-input"
type="number"
min={0}
min={1}
required
value={newOptions()?.maxDownloads}
onChange={(e) => setNewOptions({ ...newOptions(), maxDownloads: e.target.valueAsNumber })}
@@ -128,7 +128,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="max-transcodes-input"
type="number"
min={0}
min={1}
required
value={newOptions()?.maxTranscodes}
onChange={(e) => setNewOptions({ ...newOptions(), maxTranscodes: e.target.valueAsNumber })}
Mwebclient/src/components/Playlist.css
@@ -51,6 +51,8 @@
display: flex;
align-items: center;
gap: 1.5rem;
border-width: 0;
border-color: var(--color-d);
div {
display: flex;
Mwebclient/src/components/Playlist.tsx
@@ -198,10 +198,7 @@ export default function Playlist(props: PlaylistProps) {
async function save() {
try {
if (!playlistNameInput.reportValidity()) return;
await savePlaylist(
{ name: playlistNameInput.value, items: props.playlist() },
allowOverwriteCheckbox.checked,
);
await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked);
closeDialog();
} catch (e) {
if (e instanceof Error && e.name === "ConstraintError") {
@@ -221,7 +218,7 @@ export default function Playlist(props: PlaylistProps) {
return (
<div class="playlist-container">
<dialog class="save-dialog" ref={dialogElement}>
<form>
<div>
<input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
<div class="save-buttons">
<label for="overwrite-checkbox">allow overwrite</label>
@@ -229,7 +226,7 @@ export default function Playlist(props: PlaylistProps) {
<button onClick={save}>Save</button>
<button onClick={closeDialog}>Cancel</button>
</div>
</form>
</div>
</dialog>
<div class="playlist-header">
Mwebclient/src/components/PlaylistManager.tsx
@@ -3,7 +3,7 @@ import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import type { Playlist, PlaylistItem } from "../types";
import { formatTime, generateUUID, sleep, toast } from "../utils";
import { formatTime, generateUUID, toast } from "../utils";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import { LoadingScreen } from "./LoadingScreen";
@@ -83,6 +83,10 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
input.click();
}
function generateIds(items: Omit<PlaylistItem, "id">[]): PlaylistItem[] {
return items.map((item) => ({ ...item, id: generateUUID() }));
}
return (
<dialog ref={dialogElement} onClose={handleClose}>
<div>
@@ -127,8 +131,9 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
<button
title="Replace current playlist with this one"
onClick={() => {
props.setPlaylist(item.items);
if (item.items.length > 0) props.playTrack(item.items[0]);
const playlist = generateIds(item.items);
props.setPlaylist(playlist);
if (item.items.length > 0) props.playTrack(playlist[0]);
handleClose();
}}
>
@@ -136,7 +141,7 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
</button>
<button
title="Add items from this playlist to current one"
onClick={() => props.setPlaylist(props.playlist().concat(item.items))}
onClick={() => props.setPlaylist(props.playlist().concat(generateIds(item.items)))}
>
Queue
</button>
Mwebclient/src/offline.ts
@@ -1,19 +1,30 @@
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type { AppOptions, Playlist } from "./types";
import type { AppOptions, Playlist, PlaylistItem } from "./types";
import { joinPath, toast } from "./utils";
import { createSignal, type ResourceActions, type Signal } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
interface CachedFile {
duration: number;
}
interface Progress {
bytes: number;
}
export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
export const signalMap = new Map<string, Signal<SignalMapEntry>>();
const metaDataMap = new Map<string, number>();
const playlists: Playlist[] = [];
function updateOfflineFileTree(path: string, duration: number): void {
const basePath = path.split("/").slice(0, -1).join("/");
const name = path.split("/").toReversed()[0];
const node = getDirInTree(basePath, true);
const existingFileIndex = node.files.findIndex((f) => f.name === name);
const file = { name: name, duration: duration };
if (existingFileIndex >= 0) {
node.files[existingFileIndex] = file;
} else {
node.files.push(file);
}
}
export async function loadDataFromDB() {
try {
@@ -21,7 +32,7 @@ export async function loadDataFromDB() {
const files = await db.listFiles(FileMetadataStore);
for (const file of files) {
signalMap.set(file.path, createSignal<SignalMapEntry>("CachedFile"));
metaDataMap.set(file.path, file.duration);
updateOfflineFileTree(file.path, file.duration);
}
} catch (e) {
toast("Loading offline files failed", "error", e);
@@ -29,6 +40,43 @@ export async function loadDataFromDB() {
}
}
interface DirectoryNode {
directories: Record<string, DirectoryNode>;
files: AudioFile[];
}
const directoryTree: DirectoryNode = {
directories: {},
files: [],
};
// get a DirectoryNode from the directory tree, and optionally create it if it doesn't exist yet
function getDirInTree(path: string, create: true): DirectoryNode;
function getDirInTree(path: string, create: false): DirectoryNode | null;
function getDirInTree(path: string, create: boolean): DirectoryNode | null {
const parts = path.split("/").filter(Boolean);
let current = directoryTree;
for (const part of parts) {
if (!current.directories[part]) {
if (!create) return null;
current.directories[part] = { directories: {}, files: [] };
}
current = current.directories[part];
}
return current;
}
function deletePath(path: string): void {
const basePath = path.split("/").slice(0, -1).join("/");
const node = getDirInTree(basePath, false);
if (!node) return;
const name = path.split("/").toReversed()[0];
delete node.directories[name];
node.files = node.files.filter((file) => file.name !== name);
}
type FileEntry = { name: string } & ({ type: "Directory" } | { type: "File"; duration: number });
const directoryCache = new Map<string, FileEntry[]>();
export const clearDirectoryCache = (path: string) => directoryCache.delete(path);
@@ -40,7 +88,10 @@ async function requestFileList(dir: string, recursive: boolean): Promise<FileLis
return;
}
if (!response || !response.ok) {
toast(`Fetching files for ${dir} failed${response ? `: ${response.statusText}` : ""}`, "error");
toast(
`Fetching files for ${dir === "" ? "root" : dir} failed${response ? `: ${response.statusText}` : ""}`,
"error",
);
return;
}
@@ -76,68 +127,34 @@ function cachedToDirectory(fullPath: string, entries: FileEntry[] | undefined, r
return result;
}
interface TreeDir {
dirs: Map<string, TreeDir>;
files: Map<string, AudioFile>;
}
function buildOfflineTree(): TreeDir {
const root: TreeDir = { dirs: new Map(), files: new Map() };
for (const [path, duration] of metaDataMap.entries()) {
const parts = path.split("/");
let node = root;
for (let i = 0; i < parts.length - 1; ++i) {
const part = parts[i];
let nextNodes = node.dirs.get(part);
if (!nextNodes) {
nextNodes = { dirs: new Map(), files: new Map() };
node.dirs.set(part, nextNodes);
}
node = nextNodes;
}
const filename = parts[parts.length - 1];
node.files.set(filename, { name: filename, duration });
}
return root;
}
function listOfflineFiles(dir: string, recursive: boolean): FileListing {
const node = getDirInTree(dir, false);
if (!node) return [];
const entries: FileListing = [];
/**
* Returns a directory listing mimicking the real filesystem,
* based entirely on in-memory offlineCache keys.
* @param dir Directory path, "" for root, else e.g. "a/b"
* @param recursive Whether to expand subdirectories recursively.
* @returns (Array of AudioFile | Directory)
*/
function listOfflineFiles(targetDir: string, recursive: boolean): FileListing {
const tree = buildOfflineTree();
// Normalize targetDir and walk down the tree
const normParts = targetDir === "" ? [] : targetDir.split("/").filter(Boolean);
let node = tree;
for (const part of normParts) {
const next = node.dirs.get(part);
if (!next) return [];
node = next;
}
// Now node corresponds to the directory we're listing
function dirEntries(n: TreeDir): FileListing {
const dirs: Directory[] = [];
for (const [name, childDir] of n.dirs) {
dirs.push({
entries.push(...node.files);
if (recursive) {
for (const [name, childNode] of Object.entries(node.directories)) {
const directoryEntry: Directory = {
name,
files: recursive ? dirEntries(childDir) : [],
});
}
const files: AudioFile[] = [];
for (const [name, f] of n.files) {
files.push({ name, duration: f.duration });
files: childNode.files,
};
directoryEntry.files = listOfflineFiles(joinPath(dir, name), true);
entries.push(directoryEntry);
}
return [...dirs, ...files].sort((a, b) => a.name.localeCompare(b.name));
} else {
entries.push(...Object.keys(node.directories).map((dir) => ({ name: dir, files: [] })));
}
return dirEntries(node);
return entries.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
if (offline) {
return listOfflineFiles(dir, recursive);
const res = listOfflineFiles(dir, recursive);
return res;
}
const cached = cachedToDirectory(dir, directoryCache.get(dir), recursive);
if (cached !== null) return cached;
@@ -190,7 +207,12 @@ export async function loadPlaylists() {
export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
const db = await openDB();
if(overwrite) await db.put(PlaylistStore, playlist);
playlist.items = playlist.items.map((item) => {
//some less space in the db by omitting ids
const { id, ...itemWithoutId } = item as PlaylistItem;
return itemWithoutId;
});
if (overwrite) await db.put(PlaylistStore, playlist);
else await db.add(PlaylistStore, playlist);
}
@@ -199,6 +221,36 @@ export async function deletePlaylist(name: string) {
await db.delete(PlaylistStore, name);
}
export async function deleteFile(
item: AudioFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FileListing>,
listing?: FileListing,
) {
console.log(item);
const fullPath = joinPath(basePath, item.name);
const signal = signalMap.get(fullPath);
if (!signal) return;
if (!("duration" in item)) {
const newBasePath = joinPath(basePath, item.name);
const result = listing ?? listOfflineFiles(newBasePath, true);
for (const childItem of result) {
await deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
}
}
deletePath(fullPath);
const db = await openDB();
await db.delete(FileBlobStore, fullPath);
await db.delete(FileMetadataStore, fullPath);
signalMap.get(fullPath)?.[1](undefined);
fetchFiles.refetch();
console.log("here");
}
const downloadSemaphore = new AsyncSemaphore(1);
async function downloadFile(
path: string,
progressSignal: Signal<SignalMapEntry>,
@@ -214,30 +266,34 @@ async function downloadFile(
try {
setProgress({ bytes: 0 });
let res: Response;
if (options.enableTranscoding) {
res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`);
} else {
res = await fetch(joinPath("/download", path));
}
if (!res.ok || !res.body) throw res.statusText;
const reader = res.body.getReader();
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
const chunks: Uint8Array[] = [];
let received = 0;
let lastUpdate = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const now = Date.now();
if (now - lastUpdate < 100) continue;
lastUpdate = now;
setTimeout(() => {
try {
await downloadSemaphore.acquire();
let res: Response;
if (options.enableTranscoding) {
res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`);
} else {
res = await fetch(joinPath("/download", path));
}
if (!res.ok || !res.body) throw res.statusText;
const reader = res.body.getReader();
let received = 0;
let lastUpdate = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const now = Date.now();
if (now - lastUpdate < 250) continue;
lastUpdate = now;
setProgress({ bytes: received });
}, 0);
}
} finally {
downloadSemaphore.release();
}
const data = concatChunks(chunks);
@@ -250,7 +306,7 @@ async function downloadFile(
path,
duration,
} as StoredFileMetadata);
metaDataMap.set(path, duration);
updateOfflineFileTree(path, duration);
setProgress("CachedFile");
fetchFiles.refetch();
} catch (e) {
@@ -259,39 +315,6 @@ async function downloadFile(
}
}
export async function deleteFile(
item: AudioFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FileListing>, //allows triggering reloads in the file view
listing?: FileListing,
) {
const fullPath = joinPath(basePath, item.name);
const signal = signalMap.get(fullPath);
if (!signal) return;
const [getProgress, setProgress] = signal;
const isFile = "duration" in item;
if (isFile && getProgress() !== "CachedFile") return;
if (!isFile) {
const newBasePath = joinPath(basePath, item.name);
const result = listing ?? listOfflineFiles(newBasePath, true);
for (const childItem of result) {
deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
}
}
const db = await openDB();
await db.delete(FileBlobStore, fullPath);
await db.delete(FileMetadataStore, fullPath);
metaDataMap.delete(fullPath);
setProgress(undefined);
//TODO: check if current directory shown includes this file, otherwise we uncessesarily refresh the view
//idea: maybe make files a signal, sync it to the resource with a create effect, and we just overwrite the signal with the new item here
fetchFiles.refetch();
}
/**
* Download an AudioFile or Directory to local IndexedDB recursively.
*/
export async function download(
item: AudioFile | Directory,
basePath: string,
@@ -313,9 +336,13 @@ export async function download(
setProgress("DirectoryProgress");
try {
const result = listing ?? (await listFiles(fullPath, false, true));
const promises: Promise<unknown>[] = [];
for (const childItem of result) {
await download(childItem, fullPath, options, fetchFiles, "files" in childItem ? childItem.files : undefined);
promises.push(
download(childItem, fullPath, options, fetchFiles, "files" in childItem ? childItem.files : undefined),
);
}
await Promise.allSettled(promises);
} finally {
setProgress(undefined);
}
@@ -337,6 +364,7 @@ interface StoredCover {
data: Uint8Array | null;
}
//separate blobs and metadata, so we don't have to load all file contents at startup, which would consume huge amounts of ram, and be very slow
const FileBlobStore = "fileblobs";
const FileMetadataStore = "filemetadata";
const CoverStore = "covers";
Awebclient/src/semaphore.ts
@@ -0,0 +1,51 @@
type PromiseResolver = () => void;
export class AsyncSemaphore {
private maxConcurrency: number;
private running: number;
private waitQueue: PromiseResolver[];
/**
* Creates a new AsyncSemaphore.
* @param maxConcurrency The maximum number of concurrent executions allowed. Must be a positive integer.
*/
constructor(maxConcurrency: number) {
if (typeof maxConcurrency !== "number" || !Number.isInteger(maxConcurrency) || maxConcurrency <= 0) {
throw new Error("AsyncSemaphore: maxConcurrency must be a positive integer.");
}
this.maxConcurrency = maxConcurrency;
this.running = 0;
this.waitQueue = [];
}
setMaxConcurrency(newMax: number): void {
this.maxConcurrency = newMax;
}
async acquire(): Promise<void> {
if (this.running < this.maxConcurrency) {
// slot acquired immediately
this.running++;
return Promise.resolve();
}
// No slots available, wait in the queue
return new Promise<void>((resolve) => {
this.waitQueue.push(resolve);
});
}
release(): void {
// If there are waiters, and we still have concurrency left (might have changed), wake one up
if (this.waitQueue.length > 0 && this.running <= this.maxConcurrency) {
const nextResolver = this.waitQueue.shift();
if (nextResolver) {
// Defer resolution to avoid potential deep stacks
// and allow the current execution context to complete.
queueMicrotask(nextResolver);
}
} else if (this.running > 0) {
this.running--;
}
}
}
Mwebclient/src/types.ts
@@ -1,13 +1,13 @@
import type { AudioFile, MediaFormat } from "music-server-shared/types";
export interface PlaylistItem extends AudioFile {
id: string;
id: string; //used for determining currently playing item
path: string; //TODO: change to basePath to avoid redundancy with name
}
export interface Playlist {
name: string;
items: PlaylistItem[];
items: Omit<PlaylistItem, "id">[];
}
export interface PlayerState {
Mwebclient/src/utils.ts
@@ -60,14 +60,3 @@ export function toast(text: string, type: "info" | "error" | "success", e?: unkn
}
export function toastError() {}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(2)} ${sizes[i]}`;
}