fix filtering bug, restructure options menu, add metadata info for current track, add option for displaying track titles instead of filenames

AuthorKonata <konata@posteo.jp>
Date
Commit54d11cd8238d69878e97c9ab379eec27d6ebd60b
Parente426be9
19 files changed, 657 insertions(+), 359 deletions(-)
▾Mserver/package.json
@@ -11,20 +11,20 @@
"build:frontend": "cd ../webclient && bun run build -c vite.compile.config.ts --emptyOutDir --outDir ../server/dist",
"compile": "bun run build:frontend && bun build src/index.ts --compile --production --outfile dist/server"
},
"devDependencies": {
"@types/command-exists": "^1.2.3",
"@types/range-parser": "^1.2.7",
"bun-types": "latest",
"concurrently": "^9.1.2"
},
"dependencies": {
"elysia": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"command-exists": "^1.2.9",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"music-metadata": "^11.2.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1"
},
"devDependencies": {
"@types/command-exists": "^1.2.3",
"@types/range-parser": "^1.2.7",
"bun-types": "latest",
"concurrently": "^9.1.2"
},
"module": "src/index.js"
}
▾Mserver/src/index.ts
@@ -173,7 +173,13 @@ const app = setup
if ("path" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
return Bun.file(fileScanResult.path);
//wrapping in new response discards the accept-range header, which we don't support here
return new Response(Bun.file(fileScanResult.path));
}
if ("content" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
return new Response(fileScanResult.content);
}
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
@@ -182,7 +188,6 @@ const app = setup
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
console.log(typeof body);
generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
setTimeout(
() => {
@@ -203,7 +208,7 @@ const app = setup
set.status = "OK";
if (playlist.length === 1) {
set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
return Bun.file(path.join(musicRoot, playlist[0]));
return new Response(Bun.file(path.join(musicRoot, playlist[0])));
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
▾Mserver/src/utils.ts
@@ -1,8 +1,8 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import { type FileListingWithStatus, MediaFormat } from "music-server-shared/types";
import { type FileListingWithStatus, MediaFormat, type Metadata } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path, { extname } from "node:path";
import path from "node:path";
import {
coverRegex,
excludeExtension,
@@ -22,16 +22,27 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
return arr[index];
}
type CoverResult = { path: string; info: PathInfo } | { info: ServerError };
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
if ((await stat(targetPath)).isFile()) {
const parsed = await parseFile(targetPath, { duration: true, skipCovers: false });
if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data };
}
} catch (e) {
return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) };
}
export async function findCover(dirPath: string): Promise<CoverResult> {
if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
let coverFile: string | undefined;
try {
coverFile = await findAsync(await readdir(dirPath), async (file) => {
return file.match(coverRegex) != null && (await stat(path.join(dirPath, file))).isFile();
coverFile = await findAsync(await readdir(targetPath), async (file) => {
return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
});
const above = path.dirname(dirPath);
const above = path.dirname(targetPath);
if (!coverFile && isBelow(musicRoot, above)) {
return findCover(above);
}
@@ -44,32 +55,47 @@ export async function findCover(dirPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
}
if (coverFile) {
const info = (await getPathInfo(path.join(dirPath, coverFile))) as PathInfo | ServerError;
const info = (await getPathInfo(path.join(targetPath, coverFile))) as PathInfo | ServerError;
if (info instanceof ServerError) {
return { info: info };
}
return { path: path.join(dirPath, coverFile), info };
return { path: path.join(targetPath, coverFile), info };
}
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
export type ProbeData = {
duration: number;
export interface ProbeData extends Metadata {
bitrate?: number;
};
}
export async function probeFile(filePath: string): Promise<ProbeData> {
const cached = probeCache.get(filePath);
if (cached) return cached;
const parsed = await parseFile(filePath, { duration: true, skipCovers: true });
const probeData = { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate };
const parsed = await parseFile(filePath, { duration: true, skipCovers: false });
const probeData = {
duration: parsed.format.duration || 0,
bitrate: parsed.format.bitrate,
title: parsed.common.title,
artist: parsed.common.artist,
album: parsed.common.album,
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
// embeddedCover: parsed.common.picture
// ? `data:${parsed.common.picture[0].format};base64,${Buffer.from(parsed.common.picture[0].data).toString("base64")}`
// : undefined,
};
probeCache.set(filePath, probeData);
return probeData;
}
function probeDataToMetadata(probeData: ProbeData): Metadata {
const { bitrate, ...metadata } = { ...probeData };
return metadata;
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
try {
if (excludeExtension.includes(extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
const stats = await stat(filePath);
if (stats.isDirectory()) {
return undefined;
@@ -126,14 +152,12 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
files.set(fileName, { files: new Map(), status: "Unknown" });
}
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
let duration = 0;
try {
const probe = await probeFile(itemPath);
duration = probe.duration || 0;
files.set(fileName, { metadata: probeDataToMetadata(probe) });
} catch (probeError) {
console.error("probing error:", probeError);
}
files.set(fileName, { metadata: { duration } });
}
}
return files;
@@ -216,7 +240,7 @@ export function convertWithFFmpeg(filePath: string, bitrate: number, format: Med
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
@@ -248,7 +272,7 @@ export function packWithTar(files: string[]) {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("tar failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
▾Mshared/types.ts
@@ -1,7 +1,14 @@
export interface Metadata {
duration: number;
trackNumber?: number;
title?: string;
artist?: string;
album?: string;
genre?: string[]
}
export interface MediaFile {
metadata: {
duration: number;
};
metadata: Metadata;
}
export interface Directory {
@@ -26,4 +33,4 @@ export enum MediaFormat {
mp3 = "mp3",
"vorbis/ogg" = "vorbis/ogg",
"vorbis/webm" = "vorbis/webm",
}
}
▾Mwebclient/package.json
@@ -7,18 +7,18 @@
"build": "vite build"
},
"devDependencies": {
"bun-types": "latest",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.11.6",
"vite-plugin-pwa": "^1.0.0",
"@types/toastify-js": "^1.12.3"
},
"dependencies": {
"bun-types": "latest",
"dexie": "^4.0.11",
"lightningcss": "^1.29.3",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
"toastify-js": "^1.12.0",
"vite-plugin-pwa": "^1.0.0"
"toastify-js": "^1.12.0"
}
}
▾Mwebclient/src/App.tsx
@@ -1,6 +1,6 @@
import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js";
import type { Component } from "solid-js";
import { type PlaylistItem, type PlayerState, type AppOptions, getItemName } from "./types";
import { type PlaylistItem, type PlayerState, type AppOptions, FileDisplay } from "./types";
import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import FileBrowser from "./components/FileBrowser";
@@ -12,7 +12,7 @@ import {
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
import { LocalStorageValues, toast } from "./utils";
import { formatFilename, LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
@@ -22,13 +22,12 @@ import { createStore, reconcile } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
//TODO: more file display options
//TODO: cover transcoding
//TODO: keep synced-aborted in downloadmanager
//TODO: bypass music-metadata if it fails
//TODO: search
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
//TODO: button to show metadata
//TODO: show cover of playing item
//TODO: video support
//TODO: virtual rendering for playlist
//TODO: custom serialization?
@@ -53,17 +52,23 @@ const App: Component = () => {
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
bitrate: 128000,
enableTranscoding: true,
disableChunkedTranscoding: false,
bitrate: 128000,
format: MediaFormat["opus/webm"],
maxTranscodes: 4,
maxDownloads: 2,
rememberDir: false,
format: MediaFormat["opus/webm"],
hideVolume: false,
duplicateWarning: true,
fontResolution: 12,
hideCover: false,
hidePlaylistCover: false,
fontResolution: 12,
filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
playlistDisplay: FileDisplay.TRACKNO_TITLE,
controlsDisplay: FileDisplay.TITLE,
rememberDir: false,
duplicateWarning: true,
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -72,13 +77,7 @@ const App: Component = () => {
const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
() => ({ dir: currentDir(), offline: isOffline() }),
async (args) => {
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);
return Array.from(await listFiles(args.dir, args.offline, false));
},
{ initialValue: [] },
);
@@ -128,7 +127,7 @@ const App: Component = () => {
if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
const artwork = await getCover(playerState.currentTrack.path.split("/").slice(0, -1).join("/"), isOffline());
navigator.mediaSession.metadata = new MediaMetadata({
title: getItemName(playerState.currentTrack),
title: formatFilename(playerState.currentTrack, options.controlsDisplay),
artwork: [
{
src: artwork ?? "",
@@ -404,6 +403,7 @@ const App: Component = () => {
audioElement={audioElement}
options={options}
fetchFiles={fetchFiles}
isOffline={isOffline}
/>
</div>
@@ -436,6 +436,7 @@ const App: Component = () => {
playlist={playlist}
setPlaylist={setPlaylist}
playTrack={playTrack}
options={options}
/>
<DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
</div>
▾Mwebclient/src/components/DownloadButton.tsx
@@ -9,7 +9,7 @@ import { LoadingSpinner } from "./LoadingScreen";
import type { FlatFileListing } from "../App";
interface DownloadButtonProps {
fileName: string;
fullPath: string;
item: MediaFile | Directory;
fetchFiles: ResourceActions<FlatFileListing>;
isOffline: Accessor<boolean>;
@@ -18,30 +18,21 @@ interface DownloadButtonProps {
}
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.fileName);
return (
<div
class="download-item"
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline())
download(
props.fileName,
props.item,
props.currentDir(),
props.options,
props.fetchFiles,
"metadata" in props.item,
);
else deleteFile(props.fileName, props.item, props.currentDir(), props.fetchFiles);
download(props.fullPath, props.item, props.options, props.fetchFiles, "metadata" in props.item);
else deleteFile(props.fullPath, props.item, props.fetchFiles);
}}
>
{(() => {
//memo needed so progress amount doesn't cause rerenders
type ButtonType = "Spinner" | "Delete" | "Sync" | "Refresh" | { status: number };
const progressMemo = createMemo<ButtonType>(() => {
const progress = getDownloadStatus(fullPath);
const progress = getDownloadStatus(props.fullPath);
if (typeof progress === "object" && "amount" in progress) return "Spinner";
if (props.isOffline()) return "Delete";
if (progress === "Synced") return "Refresh";
▾Mwebclient/src/components/FileBrowser.tsx
@@ -15,7 +15,7 @@ import {
} from "solid-js";
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { formatFilename, generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
import { DownloadButton } from "./DownloadButton";
@@ -50,7 +50,8 @@ export default function FileBrowser(props: FileBrowserProps) {
}),
);
createEffect(
on(showFilter, (showFilter) => {
on([showFilter, showActionButtons], ([showFilter, showActionButtons]) => {
if (!showActionButtons) setShowFilter(false);
if (!showFilter) setFilter("");
}),
);
@@ -84,8 +85,24 @@ export default function FileBrowser(props: FileBrowserProps) {
// Recursively convert file listing to PlaylistItem array
function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
//keep sorting same as shown in the filebrowser
const sorted = Array.from(files.entries()).sort((a, b) => {
if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
return (
"files" in a[1]
? a[0]
: formatFilename({ path: a[0], metadata: a[1].metadata }, props.options.filebrowserDisplay)
).localeCompare(
"files" in b[1]
? b[0]
: formatFilename({ path: b[0], metadata: b[1].metadata }, props.options.filebrowserDisplay),
undefined,
{ numeric: true },
);
});
let result: PlaylistItem[] = [];
for (const [fileName, file] of files) {
for (const [fileName, file] of sorted) {
const fullPath = joinPath(basePath, fileName);
if ("metadata" in file) {
// It's an audio file
@@ -95,6 +112,7 @@ export default function FileBrowser(props: FileBrowserProps) {
result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
}
}
return result;
}
@@ -111,12 +129,11 @@ export default function FileBrowser(props: FileBrowserProps) {
const addedToast = toast("", "info");
addedToast.hideToast();
async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) {
async function playFiles(fullPath: 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() : [];
@@ -203,17 +220,46 @@ export default function FileBrowser(props: FileBrowserProps) {
});
}
interface FileProxy {
key: string;
fullPath: string;
displayName: string;
file: MediaFile | Directory;
}
//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<FlatFileListing>([]);
const [fileStore, setFileStore] = createStore<FileProxy[]>([]);
function fileSort(a: FileProxy, b: FileProxy): number {
//sort directories first
if ("metadata" in b.file && !("metadata" in a.file)) return -1;
if ("metadata" in a.file && !("metadata" in b.file)) return 1;
return a.displayName.localeCompare(b.displayName, undefined, { numeric: true });
}
createEffect(() => {
setFileStore(
reconcile(
getAbsolutePathFiles().filter((item) => {
const fileName = item[0].split("/").toReversed()[0];
return fileName.toLowerCase().includes(filter());
}),
{ key: "0" },
getAbsolutePathFiles()
.map((value) => {
const fullPath = value[0];
const displayName =
"metadata" in value[1]
? formatFilename({ path: value[0], metadata: value[1].metadata }, props.options.filebrowserDisplay)
: value[0].split("/").toReversed()[0];
return {
key: fullPath + displayName,
fullPath,
displayName,
file: value[1],
};
})
.filter((item) => {
return item.displayName.toLowerCase().includes(filter());
})
.sort(fileSort),
{ key: "key" },
),
);
});
@@ -259,22 +305,25 @@ export default function FileBrowser(props: FileBrowserProps) {
<div>
<button
title="Replace playlist with all files in current directory"
onClick={() => playFiles("", false, false)}
onClick={() => playFiles(props.currentDir(), false, false)}
>
Play Directory
</button>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles("", true, false)}
onClick={() => playFiles(props.currentDir(), true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
<button
title="Add all files in current directory to playlist"
onClick={() => playFiles(props.currentDir(), false, true)}
>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles("", true, true)}
onClick={() => playFiles(props.currentDir(), true, true)}
>
Queue All
</button>
@@ -311,23 +360,23 @@ export default function FileBrowser(props: FileBrowserProps) {
<div class="file-list" ref={filelistElement}>
<For each={fileStore}>
{([fullPath, item]) => {
const isDirectory = !("metadata" in item);
const fileName = fullPath.split("/").toReversed()[0];
{({ fullPath, displayName, file }) => {
console.log(fullPath);
const isDirectory = !("metadata" in file);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => {
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile);
}}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{fileName}</span>
<span>{displayName}</span>
<div class="spacer" />
<DownloadButton
fileName={fileName}
item={item}
fullPath={fullPath}
item={file}
fetchFiles={props.fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
@@ -338,9 +387,9 @@ export default function FileBrowser(props: FileBrowserProps) {
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(fileName, true, true);
playFiles(fullPath, true, true);
} else {
playFiles(fileName, false, true, item);
playFiles(fullPath, false, true, file);
}
}}
>
▾Mwebclient/src/components/OptionsModal.css
@@ -8,6 +8,17 @@
}
}
& > .options-page-buttons {
justify-content: left;
gap: 1rem;
& > button:not(.page-button-active) {
color: var(--text-color);
background: none;
border: 1px solid var(--color-d);
}
}
& > label {
margin-right: 1rem;
}
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,5 +1,5 @@
import { type Accessor, createEffect, type Setter, on } from "solid-js";
import type { AppOptions } from "../types";
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match } from "solid-js";
import { FileDisplay, type AppOptions } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
@@ -17,6 +17,7 @@ interface OptionsModalProps {
export default function OptionsModal(props: OptionsModalProps) {
//clone, otherwise it shares the reference
const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
const [optionsPage, setOptionsPage] = createSignal(1);
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
@@ -25,6 +26,7 @@ export default function OptionsModal(props: OptionsModalProps) {
if (props.showOptions()) {
dialogElement.showModal();
setNewOptions(props.options);
setOptionsPage(1);
}
}),
);
@@ -55,128 +57,193 @@ export default function OptionsModal(props: OptionsModalProps) {
<form class="option-content" ref={formElement}>
<div class="title">Player Options</div>
<hr />
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
id="remember-dir-checkbox"
type="checkbox"
checked={newOptions.rememberDir}
onChange={(e) => setNewOptions("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("enableTranscoding", e.target.checked)}
/>
</div>
<div>
<label for="transcoding-buffer-checkbox">Disable chunked transcoding:</label>
<input
id="transcoding-buffer-checkbox"
type="checkbox"
checked={newOptions.disableChunkedTranscoding}
onChange={(e) => setNewOptions("disableChunkedTranscoding", e.target.checked)}
disabled={!newOptions.enableTranscoding}
/>
</div>
<div>
<label for="bitrate-select">Bitrate:</label>
<select
id="bitrate-select"
value={newOptions.bitrate}
onChange={(e) => setNewOptions("bitrate", Number.parseInt(e.target.value))}
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>
<option value="192000">192 kbps</option>
<option value="320000">320 kbps</option>
</select>
</div>
<div>
<label for="codec-select">Format:</label>
<select
id="codec-select"
value={newOptions.format}
onChange={(e) => setNewOptions("format", e.target.value as MediaFormat)}
disabled={!newOptions.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
<option>{MediaFormat.mp3}</option>
<option>{MediaFormat["vorbis/ogg"]}</option>
<option>{MediaFormat["vorbis/webm"]}</option>
</select>
</div>
<div>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<input
id="max-transcodes-input"
type="number"
min={1}
required
value={newOptions.maxTranscodes}
disabled={!newOptions.enableTranscoding}
onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="max-downloads-input">Max concurrent downloads:</label>
<input
id="max-downloads-input"
type="number"
min={1}
required
value={newOptions.maxDownloads}
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="hide-volume-checkbox">Hide volume:</label>
<input
id="hide-volume-checkbox"
type="checkbox"
checked={newOptions.hideVolume}
onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
/>
</div>
<div>
<label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
<input
id="duplicate-checkbox"
type="checkbox"
checked={newOptions.duplicateWarning}
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
<div>
<label for="font-resolution-select">Font resolution:</label>
<select
id="font-resolution-select"
value={newOptions.fontResolution}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value))}
>
<option value={10}>10px</option>
<option value={12}>12px</option>
<option value={16}>16px</option>
</select>
</div>
<div>
<label for="hide-cover-checkbox">Hide cover:</label>
<input
id="hide-cover-checkbox"
type="checkbox"
checked={newOptions.hideCover}
onChange={(e) => setNewOptions("hideCover", e.target.checked)}
/>
<div class="options-page-buttons">
<button onClick={() => setOptionsPage(1)} classList={{ "page-button-active": optionsPage() === 1 }}>
1
</button>
<button onClick={() => setOptionsPage(2)} classList={{ "page-button-active": optionsPage() === 2 }}>
2
</button>
<button onClick={() => setOptionsPage(3)} classList={{ "page-button-active": optionsPage() === 3 }}>
3
</button>
</div>
<Switch>
<Match when={optionsPage() === 1}>
<div>
<label for="transcoding-checkbox">Enable transcoding:</label>
<input
id="transcoding-checkbox"
type="checkbox"
checked={newOptions.enableTranscoding}
onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
/>
</div>
<div>
<label for="transcoding-buffer-checkbox">Disable chunked transcoding:</label>
<input
id="transcoding-buffer-checkbox"
type="checkbox"
checked={newOptions.disableChunkedTranscoding}
onChange={(e) => setNewOptions("disableChunkedTranscoding", e.target.checked)}
disabled={!newOptions.enableTranscoding}
/>
</div>
<div>
<label for="bitrate-select">Bitrate:</label>
<select
id="bitrate-select"
value={newOptions.bitrate}
onChange={(e) => setNewOptions("bitrate", Number.parseInt(e.target.value))}
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>
<option value="192000">192 kbps</option>
<option value="320000">320 kbps</option>
</select>
</div>
<div>
<label for="codec-select">Format:</label>
<select
id="codec-select"
value={newOptions.format}
onChange={(e) => setNewOptions("format", e.target.value as MediaFormat)}
disabled={!newOptions.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
<option>{MediaFormat.mp3}</option>
<option>{MediaFormat["vorbis/ogg"]}</option>
<option>{MediaFormat["vorbis/webm"]}</option>
</select>
</div>
<div>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<input
id="max-transcodes-input"
type="number"
min={1}
required
value={newOptions.maxTranscodes}
disabled={!newOptions.enableTranscoding}
onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="max-downloads-input">Max concurrent downloads:</label>
<input
id="max-downloads-input"
type="number"
min={1}
required
value={newOptions.maxDownloads}
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
</Match>
<Match when={optionsPage() === 2}>
<div>
<label for="hide-volume-checkbox">Hide volume:</label>
<input
id="hide-volume-checkbox"
type="checkbox"
checked={newOptions.hideVolume}
onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
/>
</div>
<div>
<label for="hide-cover-checkbox">Hide file browser cover:</label>
<input
id="hide-cover-checkbox"
type="checkbox"
checked={newOptions.hideCover}
onChange={(e) => setNewOptions("hideCover", e.target.checked)}
/>
</div>
<div>
<label for="hide-playlist-cover-checkbox">Hide current track cover:</label>
<input
id="hide-playlist-cover-checkbox"
type="checkbox"
checked={newOptions.hidePlaylistCover}
onChange={(e) => setNewOptions("hidePlaylistCover", e.target.checked)}
/>
</div>
<div>
<label for="filebrowser-display-select">Filebrowser:</label>
<select
id="filebrowser-display-select"
value={newOptions.filebrowserDisplay}
onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
<option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
</select>
</div>
<div>
<label for="playlist-display-select">Playlist:</label>
<select
id="playlist-display-select"
value={newOptions.playlistDisplay}
onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
<option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
</select>
</div>
<div>
<label for="controls-display-select">Current track:</label>
<select
id="controls-display-select"
value={newOptions.controlsDisplay}
onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
<option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
</select>
</div>
<div>
<label for="font-resolution-select">Font resolution:</label>
<select
id="font-resolution-select"
value={newOptions.fontResolution}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value))}
>
<option value={10}>10px</option>
<option value={12}>12px</option>
<option value={16}>16px</option>
</select>
</div>
</Match>
<Match when={optionsPage() === 3}>
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
id="remember-dir-checkbox"
type="checkbox"
checked={newOptions.rememberDir}
onChange={(e) => setNewOptions("rememberDir", e.target.checked)}
/>
</div>
<div>
<label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
<input
id="duplicate-checkbox"
type="checkbox"
checked={newOptions.duplicateWarning}
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
</Match>
</Switch>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
▾Mwebclient/src/components/PlayerControls.css
@@ -18,19 +18,30 @@
text-align: center;
font-weight: bold;
overflow: hidden;
& > button > svg {
width: 20px;
height: 20px;
}
& > .short-name {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
.loading-spinner {
width: 24px !important;
height: 24px !important;
width: 20px !important;
height: 20px !important;
}
}
}
}
.metadata-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-buttons {
display: flex;
flex-wrap: wrap;
▾Mwebclient/src/components/PlayerControls.tsx
@@ -1,8 +1,10 @@
import "./PlayerControls.css";
import { type AppOptions, getItemName, type PlayerState } from "../types";
import type { AppOptions, PlayerState } from "../types";
import { type Accessor, createEffect, createSignal, For, onMount, type Setter, Show } from "solid-js";
import {
ClearSVG,
DownSVG,
InfoSVG,
NextTrackSVG,
PauseSVG,
PlaySVG,
@@ -12,7 +14,7 @@ import {
UpSVG,
VolumeSVG,
} from "./SVG";
import { formatTime, LocalStorageValues } from "../utils";
import { formatFilename, formatTime, LocalStorageValues } from "../utils";
import type { SetStoreFunction } from "solid-js/store";
import { LoadingSpinner } from "./LoadingScreen";
@@ -68,110 +70,157 @@ export default function PlayerControls(props: PlayerControlsProps) {
createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
});
return (
<div class="player-controls">
<div class="track-info-toggle-container">
<button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
{showPlayerControls() ? <DownSVG /> : <UpSVG />}
</button>
<div class="track-info">
<span classList={{ "short-name": !showPlayerControls() }}>
{getItemName(props.playerState.currentTrack) || "No track selected"}
</span>
{props.playerState.waitingForStart ? (
<div>
<LoadingSpinner center={true} />
</div>
) : (
""
)}
</div>
</div>
<div class="progress-container" onClick={seekTo}>
<div class="progress-bar" style={controlsDisabled() ? { cursor: "default" } : {}}>
<For each={props.playerState.buffered}>
{(bufferRange) => {
return (
<div
class="buffered-fill"
style={{
left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
}}
/>
);
}}
</For>
<div
class="progress-fill"
style={{
width: `${
props.playerState.currentTrack
? Math.min(
100,
(props.playerState.currentTime /
(props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
}%`,
}}
/>
</div>
let metadataDialogRef!: HTMLDialogElement;
function handleClose() {
metadataDialogRef.close();
}
function MetadataEntry(label: string, value: unknown, suffix?: string) {
if (!value) return undefined;
return (
<div>
<strong>{label}</strong>: {value.toString()}
{suffix}
<br />
</div>
<Show when={showPlayerControls()}>
<div class="time-container">
<span>{formatTime(props.playerState.currentTime)}</span>/
<span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
</div>
);
}
<div class="control-buttons">
<button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
<PreviousTrackSVG disabled={controlsDisabled} />
return (
<>
<dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<div>
<div style={{ display: "flex" }}>
<div class="title">Metadata</div>
<div class="spacer" />
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<div class="metadata-list">
{MetadataEntry("Path", props.playerState.currentTrack?.path)}
{MetadataEntry("Title", props.playerState.currentTrack?.metadata.title)}
{MetadataEntry("Track number", props.playerState.currentTrack?.metadata.trackNumber)}
{MetadataEntry("Album", props.playerState.currentTrack?.metadata.album)}
{MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
{MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
{MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
</div>
</div>
</dialog>
<div class="player-controls">
<div class="track-info-toggle-container">
<button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
{showPlayerControls() ? <DownSVG /> : <UpSVG />}
</button>
<button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
{props.playerState.isPlaying ? (
<PauseSVG disabled={controlsDisabled} />
<div class="track-info">
<Show when={props.playerState.currentTrack}>
<button title="Show metadata" onClick={() => metadataDialogRef.showModal()}>
<InfoSVG />
</button>
</Show>
<span classList={{ "short-name": !showPlayerControls() }}>
{props.playerState.currentTrack
? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay)
: "No track selected"}
</span>
{props.playerState.waitingForStart ? (
<div>
<LoadingSpinner center={true} />
</div>
) : (
<PlaySVG disabled={controlsDisabled} />
""
)}
</button>
<button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
<NextTrackSVG disabled={controlsDisabled} />
</button>
</div>
</div>
<div class="progress-container" onClick={seekTo}>
<div class="progress-bar" style={controlsDisabled() ? { cursor: "default" } : {}}>
<For each={props.playerState.buffered}>
{(bufferRange) => {
return (
<div
class="buffered-fill"
style={{
left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
}}
/>
);
}}
</For>
<div
class="progress-fill"
style={{
width: `${
props.playerState.currentTrack
? Math.min(
100,
(props.playerState.currentTime /
(props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
}%`,
}}
/>
</div>
</div>
<Show when={showPlayerControls()}>
<div class="time-container">
<span>{formatTime(props.playerState.currentTime)}</span>/
<span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
</div>
<button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
<RepeatSVG disabled={() => !props.isRepeated()} />
</button>
<div class="control-buttons">
<button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
<PreviousTrackSVG disabled={controlsDisabled} />
</button>
<button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
{props.playerState.isPlaying ? (
<PauseSVG disabled={controlsDisabled} />
) : (
<PlaySVG disabled={controlsDisabled} />
)}
</button>
<button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
<NextTrackSVG disabled={controlsDisabled} />
</button>
<button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
<ShuffleSVG disabled={() => !props.isShuffled()} />
</button>
<button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
<RepeatSVG disabled={() => !props.isRepeated()} />
</button>
<div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
<VolumeSVG />
<input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
</div>
<div>
<select
value={props.playerState.rate}
onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={1.75}>1.75x</option>
<option value={2}>2x</option>
<option value={2.5}>2.5x</option>
<option value={3}>3x</option>
</select>
<button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
<ShuffleSVG disabled={() => !props.isShuffled()} />
</button>
<div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
<VolumeSVG />
<input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
</div>
<div>
<select
value={props.playerState.rate}
onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={1.75}>1.75x</option>
<option value={2}>2x</option>
<option value={2.5}>2.5x</option>
<option value={3}>3x</option>
</select>
</div>
</div>
</div>
</Show>
</div>
</Show>
</div>
</>
);
}
▾Mwebclient/src/components/Playlist.css
@@ -110,3 +110,18 @@
flex-grow: 1;
min-height: 2rem;
}
.cover {
position: relative;
height: 25%;
border-top: 1px solid var(--color-c);
display: flex;
justify-content: center;
& > img {
padding: 0.5rem;
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
}
▾Mwebclient/src/components/Playlist.tsx
@@ -9,14 +9,17 @@ import {
Show,
on,
batch,
Suspense,
createResource,
} from "solid-js";
import "./Playlist.css";
import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
import { formatTime, LocalStorageValues, toast } from "../utils";
import type { PlayerState, PlaylistItem, AppOptions } from "../types";
import { formatFilename, formatTime, LocalStorageValues, toast } from "../utils";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
import { savePlaylist, download, storedFilesLive } from "../offline";
import { savePlaylist, download, storedFilesLive, getCover } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FlatFileListing } from "../App";
import { LoadingScreen } from "./LoadingScreen";
interface PlaylistProps {
playlist: Accessor<PlaylistItem[]>;
@@ -28,10 +31,23 @@ interface PlaylistProps {
audioElement: HTMLAudioElement;
options: AppOptions;
fetchFiles: ResourceActions<FlatFileListing>;
isOffline: Accessor<boolean>;
}
export default function Playlist(props: PlaylistProps) {
const [showActionButtons, setShowActionButtons] = createSignal(true);
//TODO: add a way to refetch cover
const [cover, fetchCover] = createResource<string | undefined, { filePath?: string; offline: boolean }>(
() => ({ filePath: props.playerState.currentTrack?.path, offline: props.isOffline() }),
async (args, info) => {
if (info.value) URL.revokeObjectURL(info.value as string);
if (!args.filePath) return undefined;
return await getCover(args.filePath, args.offline);
},
{ initialValue: undefined },
);
function removeFromPlaylist(id: string) {
if (id === props.playerState.currentTrack?.id) props.playNext();
const newPlaylist = props.playlist().filter((i) => i.id !== id);
@@ -77,11 +93,7 @@ export default function Playlist(props: PlaylistProps) {
}
let downloadPromises!: Promise<void>[];
batch(() => {
downloadPromises = itemsToSync.map((item) => {
const basePath = item.path.split("/").slice(0, -1).join("/");
const fileName = item.path.split("/").toReversed()[0];
return download(fileName, item, basePath, props.options, props.fetchFiles);
});
downloadPromises = itemsToSync.map((item) => download(item.path, item, props.options, props.fetchFiles));
});
await Promise.allSettled(downloadPromises);
toast("Playlist sync completed", "success");
@@ -199,7 +211,7 @@ export default function Playlist(props: PlaylistProps) {
<div class="playlist-drag-btn" {...dragHandlers}>
<DragSVG />
</div>
<span>{getItemName(item)}</span>
<span>{formatFilename(item, props.options.playlistDisplay)}</span>
<div class="spacer" />
{formatTime(item.metadata.duration)}
<button
@@ -215,6 +227,13 @@ export default function Playlist(props: PlaylistProps) {
);
}}
</DraggableList>
<Show when={!props.options.hidePlaylistCover && (cover.loading || cover())}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
</Suspense>
</div>
</Show>
</div>
);
}
▾Mwebclient/src/components/PlaylistManager.tsx
@@ -2,8 +2,8 @@ import { createEffect, createResource, createSignal, For, Show, Signal, type Acc
import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { getItemName, type Playlist, type PlaylistItem } from "../types";
import { formatTime, generateUUID, toast } from "../utils";
import type { AppOptions, Playlist, PlaylistItem } from "../types";
import { formatFilename, formatTime, generateUUID, toast } from "../utils";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import { LoadingScreen } from "./LoadingScreen";
@@ -14,6 +14,7 @@ interface PlaylistManagerProps {
playlist: Accessor<PlaylistItem[]>;
setPlaylist: Setter<PlaylistItem[]>;
playTrack: (track: PlaylistItem) => void;
options: AppOptions;
}
export default function PlaylistManager(props: PlaylistManagerProps) {
@@ -167,7 +168,7 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
{(item) => {
return (
<>
{getItemName(item)}
{formatFilename(item, props.options.playlistDisplay)}
<br />
</>
);
▾Mwebclient/src/components/SVG.tsx
@@ -514,3 +514,24 @@ export function FilterSVG() {
</svg>
);
}
export function InfoSVG() {
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 9V7h-1V5h-1V4h-1V3h-2V2h-2V1H9v1H7v1H5v1H4v1H3v2H2v2H1v6h1v2h1v2h1v1h1v1h2v1h2v1h6v-1h2v-1h2v-1h1v-1h1v-2h1v-2h1V9zM11 6h2v2h-2zm-1 9h1v-5h-1V9h3v6h1v2h-4z"
/>
<path fill="none" d="M0 0h24v24H0z" />
</svg>
);
}
▾Mwebclient/src/offline.ts
@@ -4,11 +4,12 @@ import type {
FileListing,
FileListingWithStatus,
DirectoryWithStatus,
Metadata,
} from "music-server-shared/types";
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast } from "./utils";
import { batch, from, observable, untrack, type ResourceActions } from "solid-js";
import { joinPath, toast, unproxy } from "./utils";
import { batch, from, untrack, type ResourceActions } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
import { encodePath } from "music-server-shared/utils";
import type { FlatFileListing } from "./App";
@@ -18,9 +19,7 @@ import Dexie, { liveQuery } from "dexie";
interface StoredFile {
path: string;
data: Blob;
metadata: {
duration: number;
};
metadata: Metadata;
}
interface StoredCover {
@@ -287,7 +286,7 @@ export async function listFiles(dir: string, offline: boolean, recursive: boolea
return recursive ? combined : pruneChildren(combined);
}
//get cover art for a directory with cache-first strategy
//get cover art object-url for a file or directory with cache-first strategy
export async function getCover(dir: string, offline: boolean) {
try {
const cached = await db.covers.get(dir);
@@ -310,9 +309,9 @@ export async function getCover(dir: string, offline: boolean) {
}
}
export const clearCoverCache = async (path: string) => {
export async function clearCoverCache(path: string) {
await db.covers.delete(path);
};
}
//returns an object url to the blob if synced, otherwise the server url
export async function getSrc(path: string, options: AppOptions, offline: boolean, forceUrl?: true): Promise<string>;
@@ -340,8 +339,7 @@ export async function loadPlaylists() {
export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
playlist.items = playlist.items.map((item) => {
//some less space in the db by omitting ids
//spread item.metadata because it might be a proxy object
return { path: item.path, metadata: { ...item.metadata } };
return { path: item.path, metadata: unproxy(item.metadata) };
});
if (overwrite) {
@@ -373,12 +371,10 @@ function collectMediaFilePaths(listing: FileListing, basePath: string): string[]
//deletes file or directory(+recursive contents) from indexedDB and updates their download statuses
export async function deleteFile(
fileName: string,
fullPath: string,
item: MediaFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FlatFileListing>,
) {
const fullPath = joinPath(basePath, fileName);
try {
if ("metadata" in item && downloadStatusMap[fullPath] !== "Synced") return;
startProgress(fullPath, { amount: 1 });
@@ -390,7 +386,8 @@ export async function deleteFile(
toDelete = [fullPath];
}
await db.files.bulkDelete(toDelete); //delete from database
await db.files.bulkDelete(toDelete); //delete file from database
await db.covers.bulkDelete(toDelete); //delete embedded cover from database
deleteInOfflineTree(fullPath); //delete node from tree
batch(() => {
for (const path of toDelete) {
@@ -472,7 +469,7 @@ async function downloadFile(
await db.files.put({
path,
data: blob,
metadata: { ...item.metadata }, //spread needed, because item might be a proxy object from the filebrowser, which can't be stored
metadata: unproxy(item.metadata),
});
updateFileTree(path, item, true);
@@ -498,26 +495,24 @@ async function downloadFile(
}
export async function download(
fileName: string,
fullPath: string,
item: MediaFile | Directory,
basePath: string,
options: AppOptions,
fetchFiles: ResourceActions<FlatFileListing>,
redownload = false,
) {
const fullPath = joinPath(basePath, fileName);
if ("metadata" in item) {
await downloadFile(fullPath, options, item, fetchFiles, redownload || false);
} else {
updateRecursiveStatus(fullPath, 1, true);
try {
await getCover(fullPath, false);
getCover(fullPath, false).then((cover) => URL.revokeObjectURL(cover || ""));
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
const listing = await listFiles(fullPath, false, false);
batch(() => {
for (const [childName, childItem] of listing) {
promises.push(download(childName, childItem, fullPath, options, fetchFiles, redownload));
promises.push(download(joinPath(fullPath, childName), childItem, options, fetchFiles, redownload));
}
});
await Promise.allSettled(promises);
▾Mwebclient/src/types.ts
@@ -5,13 +5,6 @@ export interface PlaylistItem extends MediaFile {
path: string;
}
export function getItemName(item: Pick<PlaylistItem, "path">): string;
export function getItemName(item: Pick<PlaylistItem, "path"> | null): string | undefined;
export function getItemName(item: Pick<PlaylistItem, "path"> | null) {
if (!item) return undefined;
return item.path.split("/").toReversed()[0];
}
export interface Playlist {
name: string;
items: Omit<PlaylistItem, "id">[];
@@ -27,16 +20,28 @@ export interface PlayerState {
waitingForStart: boolean;
}
export enum FileDisplay {
FILENAME = 0,
TITLE = 1,
TRACKNO_TITLE = 2,
}
export interface AppOptions {
bitrate: number;
format: MediaFormat;
enableTranscoding: boolean;
disableChunkedTranscoding: boolean;
bitrate: number;
format: MediaFormat;
maxTranscodes: number;
maxDownloads: number;
rememberDir: boolean;
hideVolume: boolean;
duplicateWarning: boolean;
fontResolution: number;
hideCover: boolean;
hidePlaylistCover: boolean;
filebrowserDisplay: FileDisplay;
playlistDisplay: FileDisplay;
controlsDisplay: FileDisplay;
fontResolution: number;
rememberDir: boolean;
duplicateWarning: boolean;
}
▾Mwebclient/src/utils.ts
@@ -1,5 +1,6 @@
import type { FileListing, MediaFile } from "music-server-shared/types";
import Toastify from "toastify-js";
import { FileDisplay, type PlaylistItem } from "./types";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -93,3 +94,29 @@ export function formatBytes(bytes: number): string {
return `${value.toFixed(2)} ${sizes[i]}`;
}
export function unproxy<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
function getItemName(item: Pick<PlaylistItem, "path">): string;
function getItemName(item: Pick<PlaylistItem, "path"> | null): string | undefined;
function getItemName(item: Pick<PlaylistItem, "path"> | null) {
if (!item) return undefined;
return item.path.split("/").toReversed()[0];
}
export function formatFilename(file: Omit<PlaylistItem, "id">, format: FileDisplay): string {
switch (format) {
case FileDisplay.FILENAME:
return getItemName(file);
case FileDisplay.TITLE:
return file.metadata.title || getItemName(file);
case FileDisplay.TRACKNO_TITLE: {
if (!file.metadata.title) return getItemName(file);
const prefix = file.metadata.trackNumber ? `${file.metadata.trackNumber}. ` : "";
return `${prefix}${file.metadata.title}`;
}
}
throw new Error(`unhandled: ${format}`);
}