update
MREADME.md
@@ -4,11 +4,11 @@ Pico Pixel Player is a lightweight, self-hostable audio player with an [ElysiaJS
## Features
- **Lightweight**: Minimal dependencies, fast performance
- **Easy Setup**: Configure with just a `.env` file.
- **Easy Setup**: Configure with just a `.env` file
- **Responsive UI**: Works seamlessly on desktop and mobile
- **Offline Capable**: Progressive Web App (PWA) support and local caching of files
- **Transcoding**: Optionally transcode files at a selected bitrate to your client, so you can listen to your `.flac` files on the go without using up all your data
- **Directory Listing**: Browse and play files directly from your server
- **Transcoding**: Optionally transcode files at a selected bitrate to your client
- **Directory Listing**: Browse files in their original directory structure
## Screenshot

@@ -33,7 +33,8 @@ docker-compose up # or podman-compose up
- **No Authentication**: The app has no built-in auth. Run it behind a proxy with basic auth for security.
## Non-Goals
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this.
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this
- **Multiple users**: Right now everything besides the actual file hosting is stored on the server. When syncing is implemented, it will only be single storage. Permissison systems are also not planned. If distinct permissions/syncs are absouletely required, hosting multiple instances is an option
## Attributions
- Font: https://github.com/TakWolf/ark-pixel-font
Mbiome.json
@@ -1,5 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"files": {
"ignore": ["**/*.d.ts"]
},
"organizeImports": {
"enabled": true
},
Mserver/src/utils.ts
@@ -1,6 +1,6 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import type { Directory, AudioFile } from "music-server-shared/types";
import type { Directory, FileListing } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
@@ -86,14 +86,11 @@ export async function getPathInfo(filePath: string): Promise<PathInfo | ServerEr
const type = await fileTypeFromFile(filePath);
mimeType = type?.mime;
if (mimeType) {
console.log("scanned MIME type:", mimeType, "for", filePath);
fileTypeCache.set(filePath, mimeType);
} else {
console.error("Failed to determine MIME type for:", filePath);
return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
}
} else {
console.log("cached MIME type:", mimeType, "for", filePath);
}
return new PathInfo(mimeType);
// biome-ignore lint/suspicious/noExplicitAny:
@@ -121,7 +118,7 @@ export function isBelow(basePath: string, targetPath: string) {
}
export async function listFiles(subPath: string, recursive: boolean): Promise<Directory | ServerError> {
const files: (Directory | AudioFile)[] = [];
const files: FileListing = [];
try {
for (const file of await readdir(subPath)) {
Mwebclient/index.html
@@ -11,14 +11,55 @@
#root {
display: none;
}
span {
padding: 1rem;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
}
body {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
background: #3f4d4e;
}
</style>
</noscript>
<style>
#inital-loading-screen {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
background: #3f4d4e;
div {
padding: 1rem;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
}
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<noscript>Pico Pixel Player requires javascript to be enabled.</noscript>
<div id="root">
Loading...
<div id="inital-loading-screen">
<div>
Loading required assets to start...
</div>
</div>
</div>
<script src="./src/index.tsx" type="module"></script>
</body>
Mwebclient/package.json
@@ -4,8 +4,7 @@
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"build": "vite build",
"prepack": "vite build"
"build": "vite build"
},
"devDependencies": {
"typescript": "^5.7.2",
@@ -14,13 +13,11 @@
"@types/toastify-js": "^1.12.3"
},
"dependencies": {
"@types/sortablejs": "^1.15.8",
"lightningcss": "^1.29.3",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
"sortablejs": "^1.15.6",
"toastify-js": "^1.12.0",
"vite-plugin-pwa": "^1.0.0"
},
"files": ["dist"]
}
}
Mwebclient/src/App.tsx
@@ -5,22 +5,25 @@ import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import FileBrowser from "./components/FileBrowser";
import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG } from "./components/SVG";
import { LocalStorageValues } from "./utils";
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
//TODO: error message when playlist item doesn't play from server
//TODO: clear server cache && cover cache
//TODO: delete downloaded file
//TODO: initial app loading screen
//TODO: download single file, playlist
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: make sure all button have title texts
//TODO: better touch controls?
//signal changed file listing when file is downloaded
//---
//TODO: setting export/import
//TODO: playlist management
//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: button to show metadata
//TODO: show cover of playing item
//TODO: delete confirmation?
//TODO: nicer errors when song can't be played because server is down
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
@@ -30,13 +33,16 @@ const App: Component = () => {
isPlaying: false,
currentTrack: null,
currentTime: 0,
volume: 0.8,
loaded: 0,
volume: 0.5,
buffered: 0,
});
const [showOptions, setShowOptions] = createSignal(false);
const [options, setOptions] = createSignal<AppOptions>({
bitrate: 128000,
enableTranscoding: true,
maxTranscodes: 4,
maxDownloads: 1,
rememberDir: false,
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -87,8 +93,14 @@ const App: Component = () => {
audioElement.removeAttribute("src");
audioElement.srcObject = null;
audioElement.src = await getSrc(track.path, options());
//TODO: toast
audioElement.play().catch((error) => console.error("Error playing track:", error));
audioElement.play().catch((e) => {
setPlayerState({
...playerState(),
isPlaying: false,
currentTime: 0,
});
return toast("Playing track failed", "error", e);
});
}
// Play next track
@@ -133,8 +145,14 @@ const App: Component = () => {
setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
if (newIsPlaying) {
//TODO: toast
audioElement.play().catch((error) => console.error("Error playing track:", error));
audioElement.play().catch((e) => {
setPlayerState({
...playerState(),
isPlaying: false,
currentTime: 0,
});
return toast("Error playing track", "error", e);
});
} else {
audioElement.pause();
}
@@ -146,7 +164,7 @@ const App: Component = () => {
setPlayerState({
...playerState(),
currentTime: audioElement.currentTime,
loaded: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
});
}
}
@@ -176,12 +194,11 @@ const App: Component = () => {
setRepeated(savedRepeat === "true");
}
const savedPath = localStorage.getItem(LocalStorageValues.path);
if (savedPath !== null && !window.location.hash) {
if (options().rememberDir && savedPath !== null && !window.location.hash) {
setCurrentDir(savedPath);
}
} catch (error) {
//TODO: toast
console.error("loading values from localStorage failed:", error);
} catch (e) {
toast("loading values from localStorage failed", "error", e);
localStorage.clear();
}
// Sync to localStorage
Mwebclient/src/components/DownloadButton.tsx
@@ -1,14 +1,16 @@
import "./DownloadButton.css";
import type { AudioFile, Directory } from "music-server-shared/types";
import { download, signalMap, type SignalMapEntry } from "../offline";
import { createSignal, type Accessor, type Signal } from "solid-js";
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import { deleteItem, 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 { DownloadSVG, RefreshSVG } from "./SVG";
import { DeleteSVG, DownloadSVG, RefreshSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
interface DownloadButtonProps {
item: AudioFile | Directory;
fetchFiles: ResourceActions<FileListing>;
isOffline: Accessor<boolean>;
currentDir: Accessor<string>;
options: Accessor<AppOptions>;
}
@@ -27,24 +29,43 @@ export function DownloadButton(props: DownloadButtonProps) {
class="download-item"
onClick={(event) => {
event.stopPropagation();
download(props.item, props.currentDir(), props.options());
if (!props.isOffline()) download(props.item, props.currentDir(), props.options(), props.fetchFiles);
else deleteItem(props.item, props.currentDir(), props.fetchFiles);
}}
>
{(() => {
const progress = getProgress();
if (progress === undefined)
if (progress === undefined) {
if (props.isOffline()) {
//only happens if this is a directory
return (
<button>
<DeleteSVG />
</button>
);
}
return (
<button>
<DownloadSVG />
</button>
);
}
if (progress === "DirectoryProgress") return <LoadingSpinner center={true} />;
if (progress === "CachedFile")
if (progress === "CachedFile") {
if (props.isOffline()) {
return (
<button>
<DeleteSVG />
</button>
);
}
return (
<button>
<RefreshSVG />
</button>
);
}
if ("bytes" in progress) return <span>{formatBytes(progress.bytes)}</span>;
console.log(progress);
console.error("Unexpected", progress);
Mwebclient/src/components/FileBrowser.tsx
@@ -5,18 +5,16 @@ import {
createResource,
createSignal,
For,
Match,
onMount,
type Setter,
Show,
Suspense,
Switch,
} from "solid-js";
import type { AudioFile, Directory } from "music-server-shared/types";
import type { AudioFile, FileListing } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues } from "../utils";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
import { clearDirectoryCache, getCover, listFiles } from "../offline";
import { clearCoverCache, clearDirectoryCache, getCover, listFiles } from "../offline";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
@@ -58,7 +56,7 @@ export default function FileBrowser(props: FileBrowserProps) {
}
// Recursively collect audio files
function collectAudioFiles(files: (AudioFile | Directory)[], basePath: string): PlaylistItem[] {
function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
let result: PlaylistItem[] = [];
const normpath = basePath === "" ? "" : `${basePath}/`;
@@ -120,8 +118,7 @@ export default function FileBrowser(props: FileBrowserProps) {
function reloadDirectory() {
clearDirectoryCache(props.currentDir());
//TODO: delete cover cache
clearCoverCache(props.currentDir());
fetchFiles.refetch();
fetchCover.refetch();
}
@@ -130,9 +127,8 @@ export default function FileBrowser(props: FileBrowserProps) {
try {
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
setOffline(savedOffline === "true");
} catch (error) {
//TODO: toast
console.error("loading values from localStorage failed:", error);
} catch (e) {
toast("loading values from localStorage failed", "error", e);
localStorage.clear();
}
@@ -190,56 +186,52 @@ export default function FileBrowser(props: FileBrowserProps) {
</div>
<div class="file-list-wrapper">
<Switch>
<Match when={files.error}>
<div class="error">{String(files.error)}</div>
</Match>
<Match when={true}>
<Show when={files.loading}>
<LoadingScreen />
</Show>
<div class="file-list">
<For each={files.latest}>
{(item) => {
const isDirectory = "files" in item;
const fullPath = joinPath(props.currentDir(), item.name);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => isDirectory && props.setCurrentDir(fullPath)}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
<div class="spacer" />
<Show when={!isOffline()}>
<DownloadButton item={item} currentDir={props.currentDir} options={props.options} />
</Show>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(true, true, fullPath);
} else {
addToPlaylist(item as AudioFile, props.currentDir());
}
}}
>
Add
</button>
</div>
</>
);
}}
</For>
</div>
</Match>
</Switch>
<Show when={files.loading}>
<LoadingScreen />
</Show>
<div class="file-list">
<For each={files.latest}>
{(item) => {
const isDirectory = "files" in item;
const fullPath = joinPath(props.currentDir(), item.name);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => isDirectory && props.setCurrentDir(fullPath)}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
<div class="spacer" />
<DownloadButton
item={item}
fetchFiles={fetchFiles}
isOffline={isOffline}
currentDir={props.currentDir}
options={props.options}
/>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(true, true, fullPath);
} else {
addToPlaylist(item as AudioFile, props.currentDir());
}
}}
>
Add
</button>
</div>
</>
);
}}
</For>
</div>
</div>
<Show when={!cover.error && (cover.loading || cover())}>
<Show when={cover.loading || cover()}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
Mwebclient/src/components/OptionsModal.css
@@ -16,33 +16,45 @@ dialog {
.option-content {
display: flex;
gap: 0.5rem;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--color-a);
padding: 1.5rem;
.option-title {
align-self: flex-start;
font-size: 1.2rem;
margin-bottom: 1rem;
font-weight: bold;
}
label {
& > div {
display: flex;
justify-content: space-between;
& > input[type="number"] {
background: none;
width: 5rem;
}
}
& > label {
margin-right: 1rem;
}
.option-buttons {
display: flex;
& > .option-buttons {
justify-content: end;
gap: 0.5rem;
margin-top: 1.5rem;
.cache-button {
margin-right: auto;
}
.cancel-button {
background-color: oklch(0.49 0.1742 26.51);
&:hover{
background-color: oklch(0.70 0.1742 26.51);
&:hover {
background-color: oklch(0.7 0.1742 26.51);
}
}
}
Mwebclient/src/components/OptionsModal.tsx
@@ -1,8 +1,8 @@
import { type Accessor, createEffect, createSignal, type Setter, on } from "solid-js";
import type { AppOptions } from "../types";
import "./OptionsModal.css";
import Toastify from "toastify-js";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -14,6 +14,8 @@ interface OptionsModalProps {
export default function OptionsModal(props: OptionsModalProps) {
const [newOptions, setNewOptions] = createSignal<AppOptions>(props.options());
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
let toastElement!: HTMLDivElement;
createEffect(
on(props.showOptions, () => {
@@ -25,12 +27,8 @@ export default function OptionsModal(props: OptionsModalProps) {
);
function handleSave() {
Toastify({
text: "Saved changes",
duration: 2000,
close: true,
style: { background: "linear-gradient(135deg,rgb(38, 194, 27),rgb(5, 144, 52))" },
}).showToast();
if (!formElement.reportValidity()) return;
toast("Saved changes", "sucess");
props.setOptions(newOptions());
handleClose();
}
@@ -40,23 +38,36 @@ export default function OptionsModal(props: OptionsModalProps) {
props.setShowOptions(false);
}
//TODO: add form elements
async function deleteServerCache() {
const response = await fetch("/reset-cache", { method: "POST" }).catch(() => {});
if (!response || response.status !== 200) {
toast("Clearing server cache failed", "error");
} else {
toast("Server cache cleared", "info");
}
}
return (
<dialog ref={dialogElement}>
<div class="option-content">
<dialog ref={dialogElement} onClose={handleClose}>
<div ref={toastElement} />
<form class="option-content" ref={formElement}>
<div class="option-title">Player Options</div>
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
id="remember-dir-checkbox"
type="checkbox"
checked={newOptions()?.rememberDir}
onChange={(e) => setNewOptions({ ...newOptions(), rememberDir: e.target.checked })}
/>
</div>
<div>
<label for="transcoding-checkbox">Enable transcoding:</label>
<input
id="transcoding-checkbox"
type="checkbox"
checked={newOptions()?.enableTranscoding}
onChange={(e) =>
setNewOptions({
...newOptions(),
enableTranscoding: e.target.checked,
})
}
onChange={(e) => setNewOptions({ ...newOptions(), enableTranscoding: e.target.checked })}
/>
</div>
<div>
@@ -72,6 +83,8 @@ export default function OptionsModal(props: OptionsModalProps) {
}
disabled={!newOptions()?.enableTranscoding}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
<option value="64000">64 kbps</option>
<option value="96000">96 kbps</option>
<option value="128000">128 kbps</option>
@@ -79,7 +92,32 @@ export default function OptionsModal(props: OptionsModalProps) {
<option value="320000">320 kbps</option>
</select>
</div>
<div>
<label for="max-downloads-input">Max concurrent downloads:</label>
<input
id="max-downloads-input"
type="number"
min={0}
required
value={newOptions()?.maxDownloads}
onChange={(e) => setNewOptions({ ...newOptions(), maxDownloads: e.target.valueAsNumber })}
/>
</div>
<div>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<input
id="max-transcodes-input"
type="number"
min={0}
required
value={newOptions()?.maxTranscodes}
onChange={(e) => setNewOptions({ ...newOptions(), maxTranscodes: e.target.valueAsNumber })}
/>
</div>
<div class="option-buttons">
<button type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
</button>
<button type="button" onClick={handleSave}>
Save
</button>
@@ -87,7 +125,7 @@ export default function OptionsModal(props: OptionsModalProps) {
Cancel
</button>
</div>
</div>
</form>
</dialog>
);
}
Mwebclient/src/components/PlayerControls.tsx
@@ -48,7 +48,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
const pos = (e.clientX - rect.left) / rect.width;
const duration = playerState.currentTrack.duration;
props.audioElement.currentTime = Math.min(pos * duration, playerState.loaded - 1);
props.audioElement.currentTime = Math.min(pos * duration, playerState.buffered - 1);
props.setPlayerState({
...playerState,
currentTime: pos * duration,
@@ -67,7 +67,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
props.playerState().currentTrack
? Math.min(
100,
(props.playerState().loaded /
(props.playerState().buffered /
(props.playerState().currentTrack?.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
Mwebclient/src/components/SVG.tsx
@@ -233,6 +233,26 @@ export function ClearSVG() {
);
}
export function DeleteSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M22 3v2H2V3h6V2h1V1h6v1h1v1zM4 7v15h1v1h14v-2h1V7zm12 12h-2V9h2zm-6 0H8V9h2z"
/>
</svg>
);
}
export function RefreshSVG() {
const [hovered, setHovered] = createSignal();
return (
Mwebclient/src/offline.ts
@@ -1,7 +1,7 @@
import type { AudioFile, Directory } from "music-server-shared/types";
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type { AppOptions } from "./types";
import { joinPath } from "./utils";
import { createSignal, type Signal } from "solid-js";
import { joinPath, toast } from "./utils";
import { createSignal, type ResourceActions, type Signal } from "solid-js";
interface CachedFile {
duration: number;
@@ -23,7 +23,7 @@ export async function fillSignalCache() {
metaDataMap.set(file.path, file.duration);
}
} catch (e) {
//TODO toast error
toast("Loading offline files failed", "error", e);
console.error(e);
}
}
@@ -32,16 +32,22 @@ type FileEntry = { name: string } & ({ type: "Directory" } | { type: "File"; dur
const directoryCache = new Map<string, FileEntry[]>();
export const clearDirectoryCache = (path: string) => directoryCache.delete(path);
async function requestFileList(dir: string, recursive: boolean): Promise<(AudioFile | Directory)[]> {
const response = await fetch(`/list/${dir}?recursive=${recursive}`);
if (response.status === 404) throw new Error("Directory not found");
if (!response.ok) throw new Error("⚠️ Failed to fetch files ⚠️");
async function requestFileList(dir: string, recursive: boolean): Promise<FileListing | undefined> {
const response = await fetch(`/list/${dir}?recursive=${recursive}`).catch(() => {});
if (response?.status === 404) {
toast(`Directory not found: ${dir}`, "error");
return;
}
if (!response || !response.ok) {
toast(`Fetching files for ${dir} failed${response ? `: ${response.statusText}` : ""}`, "error");
return;
}
const data: Directory = await response.json();
return data.files;
}
function saveFileListToCache(basePath: string, files: (AudioFile | Directory)[], recursive: boolean) {
function saveFileListToCache(basePath: string, files: FileListing, recursive: boolean) {
const converted: FileEntry[] = files.map((item) => {
if ("files" in item) {
if (recursive) saveFileListToCache(joinPath(basePath, item.name), item.files, true);
@@ -52,11 +58,7 @@ function saveFileListToCache(basePath: string, files: (AudioFile | Directory)[],
directoryCache.set(basePath, converted);
}
function cachedToDirectory(
fullPath: string,
entries: FileEntry[] | undefined,
recursive: boolean,
): (AudioFile | Directory)[] | null {
function cachedToDirectory(fullPath: string, entries: FileEntry[] | undefined, recursive: boolean): FileListing | null {
if (!entries) return null;
const result = [];
for (const item of entries) {
@@ -104,7 +106,7 @@ function buildOfflineTree(): TreeDir {
* @param recursive Whether to expand subdirectories recursively.
* @returns (Array of AudioFile | Directory)
*/
function listOfflineFiles(targetDir: string, recursive: boolean): (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);
@@ -115,7 +117,7 @@ function listOfflineFiles(targetDir: string, recursive: boolean): (AudioFile | D
node = next;
}
// Now node corresponds to the directory we're listing
function dirEntries(n: TreeDir): (AudioFile | Directory)[] {
function dirEntries(n: TreeDir): FileListing {
const dirs: Directory[] = [];
for (const [name, childDir] of n.dirs) {
dirs.push({
@@ -132,13 +134,14 @@ function listOfflineFiles(targetDir: string, recursive: boolean): (AudioFile | D
return dirEntries(node);
}
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<(AudioFile | Directory)[]> {
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
if (offline) {
return listOfflineFiles(dir, recursive);
}
const cached = cachedToDirectory(dir, directoryCache.get(dir), recursive);
if (cached !== null) return cached;
const files = await requestFileList(dir, recursive);
if (!files) return [];
saveFileListToCache(dir, files, recursive);
return files;
}
@@ -153,8 +156,8 @@ export async function getCover(dir: string, offline: boolean) {
if (offline) return undefined;
const response = await fetch(`/cover/${dir}`, { method: "GET" });
if (response.status === 404) {
const response = await fetch(`/cover/${dir}`, { method: "GET" }).catch(() => {});
if (!response || response.status === 404) {
db.put(CoverStore, { path: dir, data: null });
return undefined;
}
@@ -163,6 +166,11 @@ export async function getCover(dir: string, offline: boolean) {
return URL.createObjectURL(blob);
}
export const clearCoverCache = async (path: string) => {
const db = await openDB();
return db.delete(CoverStore, path);
};
export async function getSrc(path: string, options: AppOptions) {
const db = await openDB();
const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
@@ -179,6 +187,7 @@ async function downloadFile(
progressSignal: Signal<SignalMapEntry>,
options: AppOptions,
duration: number,
fetchFiles: ResourceActions<FileListing>,
): Promise<void> {
const [getProgress, setProgress] = progressSignal;
const progress = getProgress();
@@ -194,15 +203,7 @@ async function downloadFile(
} else {
res = await fetch(joinPath("/download", path));
}
if (!res.ok) {
//TODO: toast error
return;
}
if (!res.body) {
//TODO: toast error
return;
}
if (!res.ok || !res.body) throw res.statusText;
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
@@ -234,14 +235,43 @@ async function downloadFile(
} as StoredFileMetadata);
metaDataMap.set(path, duration);
setProgress("CachedFile");
} catch {
//TODO: toast error
fetchFiles.refetch();
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
setProgress(undefined);
} finally {
await db.delete("downloads", path).catch(() => {});
}
}
export async function deleteItem(
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) {
deleteItem(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.
*/
@@ -249,7 +279,8 @@ export async function download(
item: AudioFile | Directory,
basePath: string,
options: AppOptions,
listing?: (AudioFile | Directory)[],
fetchFiles: ResourceActions<FileListing>,
listing?: FileListing,
) {
const fullPath = joinPath(basePath, item.name);
let signal = signalMap.get(fullPath);
@@ -259,14 +290,14 @@ export async function download(
}
const [, setProgress] = signal;
if ("duration" in item) {
await downloadFile(fullPath, signal, options, item.duration);
await downloadFile(fullPath, signal, options, item.duration, fetchFiles);
} else {
getCover(fullPath, false);
setProgress("DirectoryProgress");
try {
const result = listing ?? (await listFiles(fullPath, false, true));
for (const childItem of result) {
await download(childItem, fullPath, options, "files" in childItem ? childItem.files : undefined);
await download(childItem, fullPath, options, fetchFiles, "files" in childItem ? childItem.files : undefined);
}
} finally {
setProgress(undefined);
Mwebclient/src/types.ts
@@ -10,10 +10,13 @@ export interface PlayerState {
currentTrack: PlaylistItem | null;
currentTime: number;
volume: number;
loaded: number;
buffered: number;
}
export interface AppOptions {
bitrate: number;
enableTranscoding: boolean; // Add this line
enableTranscoding: boolean;
maxTranscodes: number;
maxDownloads: number;
rememberDir: boolean;
}
Mwebclient/src/utils.ts
@@ -1,3 +1,5 @@
import Toastify from "toastify-js";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -20,7 +22,7 @@ export const LocalStorageValues = {
shuffle: "Shuffle",
repeat: "Repeat",
offline: "Offline",
path: "Path"
path: "Path",
};
export function generateUUID() {
@@ -31,14 +33,38 @@ export function generateUUID() {
});
}
function stringErr(e: unknown) {
return e && typeof e === "object" && "message" in e ? e.message : e;
}
export function toast(text: string, type: "info" | "error" | "sucess", e?: unknown) {
if (e) console.error(e);
Toastify({
text: e ? `${text}:\n${stringErr(e)}` : text,
duration: 20000,
close: true,
style: {
display: "flex",
"align-items": "center",
background:
type === "sucess"
? "linear-gradient(135deg,rgb(38, 194, 27),rgb(5, 144, 52))"
: type === "error"
? "linear-gradient(135deg,rgb(255, 24, 24),rgb(220, 0, 0))"
: "linear-gradient(135deg,rgb(50, 60, 255),rgb(34, 37, 209))",
},
}).showToast();
}
export function toastError() {}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
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 `${Number.parseFloat(value.toFixed(2))} ${sizes[i]}`;
}
}
Mwebclient/tsconfig.json
@@ -1,21 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"alwaysStrict": true,
}
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"alwaysStrict": true,
"skipLibCheck": true
}
}