fix various pwa and offline issues, improve caching
Mserver/src/index.ts
@@ -1,7 +1,7 @@
import path, { basename } from "node:path";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import { Elysia, type InferHandler, t } from "elysia";
import { type Context, Elysia, StatusMap, t } from "elysia";
import { stringifyMap } from "music-server-shared/mapconversion";
import {
AudioCodec,
@@ -21,6 +21,7 @@ import {
generatedPlaylistIds,
mediaTypes,
musicRoot,
type PathInfo,
password,
probeCache,
ServerError,
@@ -36,29 +37,51 @@ import { findCover, getPathInfo, listFiles, matchesType, packWithTar, probeFile,
//increase timeout to not abort when listing huge folders
const setup = new Elysia({ serve: { idleTimeout: 255 } });
const paramType = t.ArrayString();
type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
const filePath = path.join(musicRoot, decodePath(encodedPath));
const info = await getPathInfo(filePath);
//undefined means it is a directory rather than a file
if (!info || info instanceof ServerError)
return info ?? new ServerError(StatusMap["Internal Server Error"], "Not a file");
if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type");
return { filePath, info };
}
const downloadHandler: DownloadHandler = async ({ params, set }) => {
const filePath = path.join(musicRoot, decodePath(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.status || "Internal Server Error";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
type FileHandlerContext = Context<{ params: { "*": string } }>;
const downloadHandler = async ({ params, set }: FileHandlerContext) => {
const resolved = await resolveMediaFile(params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
set.status = "OK";
if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
set.headers["content-type"] = fileScan.mimeType;
//audio/flac seems to be better supported than the x-flac the sniffer reports
set.headers["content-type"] = resolved.info.mimeType === "audio/x-flac" ? "audio/flac" : resolved.info.mimeType;
//returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416)
return Bun.file(filePath);
return Bun.file(resolved.filePath);
};
//the PWA entry points must always be revalidated: a stale index.html references hashed assets that no
//longer exist, and a stale sw.js pins an outdated precache manifest. everything with a content hash in
//its name can be cached forever instead. other static files keep the plugin's default (1 day + etag).
const noCachePaths = new Set(["/", "/index.html", "/sw.js", "/registerSW.js", "/manifest.webmanifest"]);
const hashedAssetPattern = /-[A-Za-z0-9_-]{8,}\.(js|css)$/;
const app = setup
.onAfterHandle({ as: "global" }, ({ path, set, responseValue }) => {
const cacheControl = noCachePaths.has(path)
? "no-cache"
: hashedAssetPattern.test(path)
? "public, max-age=31536000, immutable"
: undefined;
if (!cacheControl) return;
set.headers["cache-control"] = cacheControl;
//the static plugin already put its own cache-control on the Response, and set.headers
//alone does not override that, so patch the response headers directly as well
if (responseValue instanceof Response) responseValue.headers.set("cache-control", cacheControl);
})
.use(staticPlugin({ assets: args.serve, prefix: "/" }))
.onBeforeHandle(({ request, path }) => {
if (path === "/remote-log") return;
@@ -124,16 +147,12 @@ const app = setup
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodePath(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.status || "Internal Server Error";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
const resolved = await resolveMediaFile(params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
const { filePath, info: fileScan } = resolved;
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown
const audioBitrate = probe.audioBitrate
Mwebclient/index.html
@@ -4,6 +4,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=350, initial-scale=1">
<meta name="theme-color" content="#3f4d4e">
<link rel="icon" type="image/svg+xml" href="/assets/favicon-any.svg">
<title>Pico Pixel Player</title>
<noscript>
Mwebclient/src/App.tsx
@@ -210,6 +210,9 @@ const App: Component = () => {
if ("mediaSession" in navigator) {
createEffect(async () => {
//revoked before the early return as well, otherwise the last track's artwork leaks on every stop
const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
if (!playerState.currentTrack) {
navigator.mediaSession.metadata = new MediaMetadata();
navigator.mediaSession.setActionHandler("pause", null);
@@ -219,8 +222,6 @@ const App: Component = () => {
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),
@@ -420,8 +421,16 @@ const App: Component = () => {
async function playTrack(track: PlaylistItem, seekTo?: number) {
const generation = ++playGeneration;
try {
const { src, isVideo } = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
if (!src || generation !== playGeneration) return;
const srcResult = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
//undefined means offline and not cached, getSrc already explained that via toast
if (!srcResult) return;
const { src, isVideo } = srcResult;
if (generation !== playGeneration) {
//superseded while getSrc ran: for a synced file it already minted a blob url that
//nothing will ever assign to the element, so revoke it instead of leaking it
if (src.startsWith("blob:")) URL.revokeObjectURL(src);
return;
}
//Cleanup
if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
if (mediaSource) {
Mwebclient/src/components/DownloadButton.tsx
@@ -23,6 +23,10 @@ export function DownloadButton(props: DownloadButtonProps) {
class="download-item"
onClick={(event) => {
event.stopPropagation();
//ignore clicks while this item is already downloading or a directory walk is still running:
//the button shows a byte counter/spinner then, and a second click would start a duplicate download
const progress = getDownloadStatus(props.fullPath);
if (typeof progress === "object" && ("amount" in progress || typeof progress.status === "number")) return;
if (!props.isOffline())
download(props.fullPath, props.item, props.options, props.fetchFiles, "metadata" in props.item);
else deleteFile(props.fullPath, props.item, props.fetchFiles);
Mwebclient/src/components/DownloadManager.tsx
@@ -97,8 +97,8 @@ export default function DownloadManager(props: DownloadManagerProps) {
</button>
<button
title="stop all running downloads"
onClick={async () => {
batch(async () => {
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if (typeof status.status === "number") {
@@ -137,44 +137,67 @@ export default function DownloadManager(props: DownloadManagerProps) {
<For each={downloadKeys()}>
{(path) => {
const [showReason, setShowReason] = createSignal(false);
const status = downloadStatusMap[path] as Progress;
//an accessor, not a snapshot: progress updates replace the whole value object while <For>
//keeps this row alive, so reading it once would freeze the byte counter and the status styling
const status = () => downloadStatusMap[path] as Progress | undefined;
return (
<div
classList={{
"downloadmanager-item": true,
"downloadmanager-item-failed": status.status === "Failed",
"downloadmanager-item-aborted": status.status === "Aborted",
}}
>
<span>{path}</span>
<hr />
<div class="downloadmanager-buttons">
{status && typeof status.status === "number" ? (
<>
<span>{formatBytes(status.status)}</span>
<button title="Stop Download" onClick={() => status.abort()}>
<StopSVG />
</button>
</>
) : (
<>
{(status.status === "Failed" && (
<button title="Show reason" onclick={() => setShowReason(!showReason())}>
Reason
</button>
)) ||
"Aborted"}
<button title="Retry download" onClick={() => status.retry()}>
<RefreshSVG />
</button>
<button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
<ClearSVG />
</button>
</>
)}
</div>
<Show when={showReason()}>{"reason" in status && status.reason}</Show>
</div>
<Show when={status()}>
{(currentStatus) => (
<div
classList={{
"downloadmanager-item": true,
"downloadmanager-item-failed": currentStatus().status === "Failed",
"downloadmanager-item-aborted": currentStatus().status === "Aborted",
}}
>
<span>{path}</span>
<hr />
<div class="downloadmanager-buttons">
{typeof currentStatus().status === "number" ? (
<>
<span>{formatBytes(currentStatus().status as number)}</span>
<button
title="Stop Download"
onClick={() => {
const running = currentStatus();
if ("abort" in running) running.abort();
}}
>
<StopSVG />
</button>
</>
) : (
<>
{(currentStatus().status === "Failed" && (
<button title="Show reason" onclick={() => setShowReason(!showReason())}>
Reason
</button>
)) ||
"Aborted"}
<button
title="Retry download"
onClick={() => {
const stopped = currentStatus();
if ("retry" in stopped) stopped.retry();
}}
>
<RefreshSVG />
</button>
<button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
<ClearSVG />
</button>
</>
)}
</div>
<Show when={showReason()}>
{(() => {
const reasonStatus = currentStatus();
return "reason" in reasonStatus && reasonStatus.reason;
})()}
</Show>
</div>
)}
</Show>
);
}}
</For>
Mwebclient/src/components/FileBrowser.tsx
@@ -175,7 +175,12 @@ export default function FileBrowser(props: FileBrowserProps) {
function reloadDirectory() {
clearDirectoryCache(props.currentDir()).then(() => props.fetchFiles.refetch());
getCover(props.currentDir(), props.options, false, props.isOffline(), true).then(() => fetchCover.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;
Mwebclient/src/index.tsx
@@ -1,10 +1,13 @@
/* @refresh reload */
import { createSignal } from "solid-js";
import { render } from "solid-js/web";
import "./style.css";
import "./components/Dialog.css";
import { registerSW } from "virtual:pwa-register";
import App from "./App";
import { loadDataFromDB } from "./offline";
import { toast } from "./utils";
const root = document.getElementById("root");
if (!(root instanceof HTMLElement)) {
@@ -45,43 +48,72 @@ if (hasTouch()) {
} catch {}
}
//deletes every local trace of the app, so the next load starts from a clean slate.
//resolves to an error message when something could not be cleared, undefined on success
async function clearLocalData(): Promise<string | undefined> {
localStorage.clear();
//unregister first: the old service worker would otherwise keep serving the stale precached
//assets after the reload, defeating the whole point of the reset
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
}
if ("caches" in window) {
const cacheNames = await caches.keys().catch(() => []);
//awaited, otherwise the reload races the cleanup and can leave caches behind
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName).catch(() => false)));
}
//onsuccess alone can hang forever: another open tab blocks the delete, and errors never resolve
return await new Promise<string | undefined>((resolve) => {
const request = indexedDB.deleteDatabase("music-files");
request.onsuccess = () => resolve(undefined);
request.onerror = () => resolve("Deleting the local database failed, close all other tabs and try again.");
request.onblocked = () =>
resolve("Another tab still has this app open, close it and try again to delete the local database.");
});
}
function ResetDialog() {
const [error, setError] = createSignal<string>();
const [resetting, setResetting] = createSignal(false);
async function reset() {
setResetting(true);
setError(undefined);
const failure = await clearLocalData().catch((e) => e?.toString() ?? "Unknown error");
if (failure) {
setError(failure);
setResetting(false);
return;
}
location.hash = "";
location.reload();
}
return (
<div style={{ display: "flex", height: "100%", "align-items": "center" }}>
<dialog open>
<div>
Your local version of this application is not compatible with the newly loaded one, click "Reset" to delete
all stored settings, files and cached and refresh the page.
{error() && <div style={{ color: "rgb(255, 24, 24)" }}>{error()}</div>}
<button style={{ margin: "auto" }} disabled={resetting()} onClick={reset}>
{resetting() ? "Resetting..." : "Reset"}
</button>
</div>
</dialog>
</div>
);
}
const currentVersion = "1";
const version = localStorage.getItem("app-version");
if (version && version !== currentVersion) {
//TODO: add a way to at least export playlists before resetting
root.innerHTML = "";
render(
() => (
<div style={{ display: "flex", height: "100%", "align-items": "center" }}>
<dialog open>
<div>
Your local version of this application is not compatible with the newly loaded one, click "Reset" to delete
all stored settings, files and cached and refresh the page.
<button
style={{ margin: "auto" }}
onClick={async () => {
localStorage.clear();
if ("serviceWorker" in navigator) {
caches.keys().then((cacheNames) => {
for (const cacheName of cacheNames) {
caches.delete(cacheName);
}
});
}
indexedDB.deleteDatabase("music-files").onsuccess = () => {
location.hash = "";
location.reload();
};
}}
>
Reset
</button>
</div>
</dialog>
</div>
),
root,
);
render(() => <ResetDialog />, root);
} else {
if (!version) {
try {
@@ -89,6 +121,21 @@ if (version && version !== currentVersion) {
} catch {}
}
//the auto-injected registration only ever checks on a cold navigation, which for an installed
//player that stays open for days means new deploys are never picked up
const updateCheckInterval = 60 * 60 * 1000;
registerSW({
immediate: true,
onRegisteredSW: (_swScriptUrl, registration) => {
if (!registration) return;
setInterval(() => {
if (navigator.onLine) registration.update().catch(() => {});
}, updateCheckInterval);
},
//replaces the default automatic reload, which would cut off playback mid-track
onNeedReload: () => toast("A new version was installed, reload the page to use it", "info"),
});
loadDataFromDB()
.then(() => {
root.innerHTML = "";
Mwebclient/src/offline.ts
@@ -333,15 +333,18 @@ export async function getCover(
if (!response || response.status === 404) {
coverCache.set(dir, { data: null });
if (saveToDb) db.covers.add({ path: dir, data: null }).catch(() => {});
//if cover already exists in database, update it
else if (forceRefresh && db.covers.get(dir) !== undefined) db.covers.put({ path: dir, data: null });
//only update a row that already exists, otherwise merely browsing a directory would
//persist its cover - note the await, without it the promise is always truthy
else if (forceRefresh && (await db.covers.get(dir)) !== undefined)
db.covers.put({ path: dir, data: null }).catch(() => {});
return undefined;
}
const blob = await response.blob();
coverCache.set(dir, { data: blob });
if (saveToDb) db.covers.add({ path: dir, data: blob }).catch(() => {});
//if cover already exists in database, update it
else if (forceRefresh && db.covers.get(dir) !== undefined) db.covers.put({ path: dir, data: blob });
//see above: the await is what makes this an "already stored" check
else if (forceRefresh && (await db.covers.get(dir)) !== undefined)
db.covers.put({ path: dir, data: blob }).catch(() => {});
return URL.createObjectURL(blob);
} catch {
return undefined;
@@ -349,14 +352,22 @@ export async function getCover(
}
//returns an object url to the blob if synced, otherwise the server url
//with forceUrl it always resolves, without it returns undefined for an uncached file in offline mode
export async function getSrc(
path: string,
metadata: Metadata,
options: AppOptions,
offline: boolean,
seekTo: number,
forceUrl?: true,
forceUrl: true,
): Promise<{ src: string; isVideo?: boolean }>;
export async function getSrc(
path: string,
metadata: Metadata,
options: AppOptions,
offline: boolean,
seekTo: number,
): Promise<{ src: string; isVideo?: boolean } | undefined>;
export async function getSrc(
path: string,
metadata: Metadata,
@@ -451,8 +462,28 @@ export async function deleteFile(
toDelete = [fullPath];
}
//covers are keyed by file path (embedded art) and by directory path (directory art), so deleting a
//directory also has to drop its own cover row plus every nested one, or they stay orphaned forever
const coversToDelete = new Set(toDelete);
if ("files" in item) {
coversToDelete.add(fullPath);
for (const filePath of toDelete) {
let dir = filePath;
while (true) {
const lastSlash = dir.lastIndexOf("/");
if (lastSlash === -1) break;
dir = dir.substring(0, lastSlash);
//stop at the deleted root, parents of it may still hold other synced files
if (dir.length <= fullPath.length) break;
coversToDelete.add(dir);
}
}
}
await db.files.bulkDelete(toDelete); //delete file from database
await db.covers.bulkDelete(toDelete); //delete embedded cover from database
await db.covers.bulkDelete([...coversToDelete]); //delete embedded/directory covers from database
//also drop them from the session cache, otherwise a later getCover(saveToDb) would re-add them
for (const coverPath of coversToDelete) coverCache.delete(coverPath);
deleteInOfflineTree(fullPath); //delete node from tree
batch(() => {
for (const path of toDelete) {
@@ -478,62 +509,64 @@ async function downloadFile(
): Promise<void> {
const retry = () => downloadFile(path, options, item, fetchFiles, redownload);
const controller = new AbortController();
let abortResolve!: () => void;
const abortPromise = new Promise<void>((outerResolve) => {
abortResolve = () => {
controller.abort();
stopProgress(
path,
// if it was already synced, restore it
initialProgress === "Synced" ? "Synced" : { status: "Aborted", retry },
);
outerResolve();
};
});
// file already downloaded or download already in progress
const initialProgress = downloadStatusMap[path];
if (
(!redownload && initialProgress === "Synced") ||
(typeof initialProgress === "object" && "bytes" in initialProgress)
//an in-progress download carries a numeric byte count, without this check clicking a
//downloading item would start a second fetch racing the first one on db.files.put
(typeof initialProgress === "object" && "status" in initialProgress && typeof initialProgress.status === "number")
)
return;
startProgress(path, { status: 0, abort: abortResolve });
const controller = new AbortController();
const abortDownload = () => {
controller.abort();
stopProgress(
path,
// if it was already synced, restore it
initialProgress === "Synced" ? "Synced" : { status: "Aborted", retry },
);
};
startProgress(path, { status: 0, abort: abortDownload });
try {
//options might change at any point, update the semaphore to the current value
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
//make sure to only allow the set amount of parallel downloads
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
//make sure to only allow the set amount of parallel downloads. passing the signal makes an abort
//while still queued reject here instead of entering the section below, so the release() in its
//finally can't hand out a permit that was never acquired (which would leak a concurrency slot)
await downloadSemaphore.acquire(controller.signal);
try {
const res = await fetch((await getSrc(path, item.metadata, options, false, 0, true)).src, {
signal: controller.signal,
credentials: "same-origin",
});
//let the browser do the actual download
const blobPromise = res.clone().blob();
handleUnauthorized(res);
if (!res.ok || !res.body) throw res.statusText;
//we just iterate over the chunks to get the current progress
//read the body ourselves to track progress, and assemble the blob from the same chunks -
//res.clone().blob() would buffer the whole body a second time and reject unobserved on a mid-body error
const reader = res.body.getReader();
//a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
const chunks: Uint8Array<ArrayBuffer>[] = [];
let received = 0;
let lastUpdate = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value as Uint8Array<ArrayBuffer>);
received += value.length;
//debouncing logic to not update UI too fast
const now = Date.now();
if (now - lastUpdate < 250) continue;
lastUpdate = now;
setDownloadStatusMap(path, { status: received, abort: abortResolve });
setDownloadStatusMap(path, { status: received, abort: abortDownload });
}
const blob = await blobPromise;
const blob = new Blob(chunks, { type: res.headers.get("content-type") ?? undefined });
await db.files.put({
path,
Mwebclient/src/semaphore.ts
@@ -20,18 +20,42 @@ export class AsyncSemaphore {
setMaxConcurrency(newMax: number): void {
this.maxConcurrency = newMax;
// raising the limit has to wake waiters, otherwise the freed slots stay unused until a
// running task happens to finish. waking one counts as occupying a slot, hence running++
while (this.waitQueue.length > 0 && this.running < this.maxConcurrency) {
const nextResolver = this.waitQueue.shift();
this.running++;
if (nextResolver) queueMicrotask(nextResolver);
}
}
async acquire(): Promise<void> {
/**
* Acquires a slot, waiting in the queue if none is free.
* @param signal aborts the wait, rejecting with an AbortError. Required to abort a *queued*
* waiter: it removes the resolver from the queue, so a later release() can't hand a slot to a
* promise nobody awaits (which would permanently reduce the effective concurrency).
*/
async acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw new DOMException("Semaphore acquire aborted", "AbortError");
if (this.running < this.maxConcurrency) {
// slot acquired immediately
this.running++;
return Promise.resolve();
return;
}
// No slots available, wait in the queue
return new Promise<void>((resolve) => {
this.waitQueue.push(resolve);
return new Promise<void>((resolve, reject) => {
const resolver = () => {
signal?.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = () => {
const index = this.waitQueue.indexOf(resolver);
if (index !== -1) this.waitQueue.splice(index, 1);
reject(new DOMException("Semaphore acquire aborted", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
this.waitQueue.push(resolver);
});
}
Mwebclient/src/utils.ts
@@ -67,7 +67,10 @@ export function toast(text: string, type: "progress" | "info" | "error" | "succe
}
export function remoteLog(e: unknown) {
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST", credentials: "same-origin" });
//absolute path and a catch, so the error logger can't itself throw an unhandled rejection while offline
fetch("/remote-log", { body: JSON.stringify(e?.toString()), method: "POST", credentials: "same-origin" }).catch(
() => {},
);
}
//debounce so a burst of failing requests (e.g. many covers loading at once) shows a single notification
Mwebclient/tsconfig.json
@@ -7,7 +7,7 @@
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client", "bun-types"],
"types": ["vite/client", "bun-types", "vite-plugin-pwa/client"],
"noEmit": true,
"isolatedModules": true,
Mwebclient/vite.common.config.ts
@@ -14,6 +14,9 @@ export const pwaOptions: Partial<VitePWAOptions> = {
],
},
manifest: {
id: "/",
start_url: "/",
scope: "/",
name: "Pico Pixel Player",
short_name: "Pico Pixel Player",
description: "A directory based audio player with offline functionality",