refactoring/simplification

AuthorKonata <konata@posteo.jp>
Date
Commitd6bf43a37056903b855265a58478449b5ee39246
Parent98a1a66
30 files changed, 940 insertions(+), 1184 deletions(-)
▾Mserver/package.json
@@ -7,24 +7,19 @@
"postinstall": "bun install --cwd ../webclient",
"build-watch:frontend": "cd ../webclient && bun run build -c vite.watch.config.ts --watch --emptyOutDir --outDir ../server/dist",
"start": "bun run --watch src/index.ts --serve dist",
"dev": "concurrently \"bun run build-watch:frontend\" \"sleep 1 && bun run start\"",
"dev": "bun run build-watch:frontend & sleep 1 && bun run start",
"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": {
"@biomejs/biome": "^2.5.4",
"@types/command-exists": "^1.2.3",
"@types/tmp": "^0.2.6",
"bun-types": "latest",
"concurrently": "^10.0.3"
"bun-types": "latest"
},
"dependencies": {
"elysia": "^1.4.29",
"@elysiajs/static": "^1.4.10",
"command-exists": "^1.2.9",
"file-type": "^22.0.1",
"music-metadata": "^11.14.0",
"music-server-shared": "file:../shared",
"tmp": "^0.2.7"
"music-server-shared": "file:../shared"
}
}
▾Mserver/src/ffmpeg.ts
@@ -36,12 +36,6 @@ const VP9_PRESETS: Record<VideoEncodingSetting, string[]> = {
[VideoEncodingSetting.quality]: ["-deadline", "good", "-cpu-used", "0"],
};
function lookup<T>(table: Record<string, T>, key: string, what: string): T {
const value = table[key];
if (value === undefined) throw new Error(`Unsupported ${what}: ${key}`);
return value;
}
//Choose the first matching preferred language. Audio always needs a track, so an unmatched
//preference falls back to the source default and then the first audio stream.
function selectAudioTrack(audioLanguagePreference: string, tracks: NonNullable<Metadata["audioTracks"]>): number {
@@ -74,19 +68,19 @@ function videoArgsFor(
//TODO: support hardware encoding
switch (requestedVideoCodec) {
case VideoCodec.av1:
args.push("-c:v", "libsvtav1", "-preset", lookup(AV1_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
args.push("-c:v", "libsvtav1", "-preset", AV1_PRESETS[requestedVideoEncodingSetting]);
break;
case VideoCodec.h264:
args.push("-c:v", "libx264", "-profile:v", "high");
args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
args.push("-preset", X26X_PRESETS[requestedVideoEncodingSetting]);
break;
case VideoCodec.h265:
args.push("-c:v", "libx265", "-profile:v", "main");
args.push("-preset", lookup(X26X_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
args.push("-preset", X26X_PRESETS[requestedVideoEncodingSetting]);
args.push("-x265-params", "log-level=error");
break;
case VideoCodec.vp9:
args.push("-c:v", "libvpx-vp9", ...lookup(VP9_PRESETS, requestedVideoEncodingSetting, "encoding setting"));
args.push("-c:v", "libvpx-vp9", ...VP9_PRESETS[requestedVideoEncodingSetting]);
args.push("-row-mt", "1", "-tile-columns", "2");
break;
default:
@@ -127,7 +121,7 @@ export async function convertWithFFmpeg(
) {
const video = videoStreamInfo(metadata, videoBitrate);
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, video);
const audioEncoder = lookup(AUDIO_ENCODERS, requestedAudioCodec, "audio codec");
const audioEncoder = AUDIO_ENCODERS[requestedAudioCodec];
const hasVideo = requestedVideoCodec !== VideoCodec.none;
const containerArgs: string[] = [];
▾Mserver/src/index.ts
@@ -37,7 +37,6 @@ import {
matchesType,
packWithTar,
probeFile,
readStream,
toAvif,
} from "./utils";
@@ -305,7 +304,7 @@ const app = setup
.finally(unregister);
if (query.disableChunkedTranscoding) {
const full = await readStream(cmd.stdout).catch((error) => {
const full = await new Response(cmd.stdout).bytes().catch((error) => {
if (stoppedByClient) return new Uint8Array();
throw error;
});
@@ -455,7 +454,7 @@ const app = setup
reportUnexpectedFFmpegExit("ffmpeg subtitle conversion", exitCode, await stderrText);
});
const bytes = await readStream(cmd.stdout).catch((error) => {
const bytes = await new Response(cmd.stdout).bytes().catch((error) => {
if (stoppedByClient) return new Uint8Array();
throw error;
});
▾Mserver/src/shared.ts
@@ -1,7 +1,6 @@
import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import commandExists from "command-exists";
import type { Metadata, VideoExtras } from "music-server-shared/types";
export const { values: args } = parseArgs({
@@ -161,20 +160,9 @@ export const scanConcurrency = (() => {
return parsed;
})();
if (!commandExists.sync("ffmpeg")) {
console.error("Required command ffmpeg not found in PATH");
process.exit(1);
}
if (!commandExists.sync("tar")) {
console.error("Required command tar not found in PATH");
process.exit(1);
}
if (!commandExists.sync("avifenc")) {
console.error("Required command avifenc not found in PATH");
process.exit(1);
}
if (!commandExists.sync("mediainfo")) {
console.error("Required command mediainfo not found in PATH");
for (const command of ["ffmpeg", "ffprobe", "tar", "avifenc", "mediainfo"]) {
if (Bun.which(command)) continue;
console.error(`Required command ${command} not found in PATH`);
process.exit(1);
}
▾Mserver/src/utils.ts
@@ -1,4 +1,5 @@
import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
import { readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { StatusMap } from "elysia";
import { fileTypeFromBlob } from "file-type";
@@ -12,7 +13,6 @@ import type {
SubtitleTrack,
VideoExtras,
} from "music-server-shared/types";
import tmp from "tmp";
import {
coverRegex,
excludeExtension,
@@ -30,13 +30,6 @@ function errorCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
const results = await Promise.all(promises);
const index = results.findIndex((result) => result);
return arr[index];
}
//success carries a bytes thunk so the handler doesn't care whether the cover came from a file on
//disk or from embedded metadata
export type CoverResult = { info: PathInfo; bytes: () => Promise<Uint8Array<ArrayBuffer>> } | { info: ServerError };
@@ -79,9 +72,11 @@ export async function findCover(rootDir: string, targetPath: string): Promise<Co
if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
let coverFile: string | undefined;
try {
coverFile = await findAsync(await readdir(targetPath), async (file) => {
return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
});
//symlinks count too, so the dirent alone cannot tell a file from a directory - the getPathInfo
//below is what rejects a directory that happens to match the cover pattern
coverFile = (await readdir(targetPath, { withFileTypes: true })).find(
(entry) => entry.name.match(coverRegex) != null && !entry.isDirectory(),
)?.name;
const above = path.dirname(targetPath);
if (!coverFile && isBelow(rootDir, above)) {
return findCover(rootDir, above);
@@ -95,9 +90,10 @@ export async function findCover(rootDir: string, targetPath: string): Promise<Co
}
if (coverFile) {
const coverPath = path.join(targetPath, coverFile);
const info = (await getPathInfo(coverPath)) as PathInfo | ServerError;
if (info instanceof ServerError) {
return { info: info };
const info = await getPathInfo(coverPath);
//undefined means it is a directory rather than a file, so there is nothing to serve
if (!info || info instanceof ServerError) {
return { info: info ?? new ServerError(StatusMap["Not Found"], "Cover not found") };
}
return { info, bytes: async () => (await Bun.file(coverPath).bytes()) as Uint8Array<ArrayBuffer> };
}
@@ -136,9 +132,6 @@ export async function probeFile(filePath: string): Promise<Metadata> {
const promise = (async (): Promise<Metadata> => {
const info = await analyzeWithMediainfo(filePath);
// for (const track of info.media?.track || []) {
// console.log(track);
// }
const generalTrack = info.media?.track.find((track) => track["@type"] === "General");
const videoTrack = info.media?.track.find((track) => track["@type"] === "Video");
const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio");
@@ -420,30 +413,6 @@ export async function listFiles(
}
}
export async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return combined;
} finally {
reader.releaseLock();
}
}
//takes absolute paths the caller has already resolved and validated. the transform flattens every
//entry to its basename, so a playlist spanning several roots needs no grouping
export function packWithTar(absoluteFiles: string[]) {
@@ -459,7 +428,7 @@ export function packWithTar(absoluteFiles: string[]) {
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(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
console.error(await new Response(subprocess.stderr).text());
}
},
});
@@ -471,32 +440,32 @@ export async function toAvif(
mimeType: string,
setContentType: (mimeType: string) => void,
): Promise<Uint8Array<ArrayBuffer>> {
const tmpFile = tmp.fileSync({
postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/png") ? ".png" : undefined,
});
const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
//avifenc reads and writes files rather than pipes, so the input has to land on disk first
const extension = mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/png") ? ".png" : "";
const sourceFile = path.join(tmpdir(), `cover-${crypto.randomUUID()}${extension}`);
const avifFile = `${sourceFile}.avif`;
try {
await writeFile(tmpFile.name, data);
const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
await writeFile(sourceFile, data);
const cmd = Bun.spawn(["avifenc", "-q", "50", sourceFile, avifFile], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("avifenc failed with status code", exitCode);
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
console.error(await new Response(subprocess.stderr).text());
}
},
});
const exitCode = await cmd.exited;
if (exitCode !== 0) throw new Error(`avifenc failed with status code ${exitCode}`);
setContentType("image/avif");
return (await readFile(tmpFile2.name)) as Uint8Array<ArrayBuffer>;
return (await readFile(avifFile)) as Uint8Array<ArrayBuffer>;
} catch {
setContentType(mimeType);
return data;
} finally {
tmpFile.removeCallback();
tmpFile2.removeCallback();
await rm(sourceFile, { force: true });
await rm(avifFile, { force: true });
}
}
▾Mwebclient/package.json
@@ -12,11 +12,11 @@
"vite": "^8.1.5",
"vite-plugin-solid": "^2.11.13",
"vite-plugin-pwa": "^1.3.0",
"@types/toastify-js": "^1.12.4"
"@types/toastify-js": "^1.12.4",
"lightningcss": "^1.32.0"
},
"dependencies": {
"dexie": "^4.4.4",
"lightningcss": "^1.32.0",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.14",
"toastify-js": "^1.12.0"
▾Mwebclient/src/App.tsx
@@ -151,7 +151,6 @@ const App: Component = () => {
audioCodec: AudioCodec.opus,
},
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
const [isRepeated, setRepeated] = createSignal(false);
//read the persisted mode synchronously so the very first listing fetch below already runs in the
@@ -245,10 +244,8 @@ const App: Component = () => {
if (!isOffline()) refreshAuthStatus();
});
// Update URL fragment when directory changes
createEffect(() => {
const parts = currentDir().split("/").filter(Boolean);
setBreadcrumbs(["root", ...parts]);
// Update URL fragment when directory changes
window.location.hash = encodePath(currentDir());
});
@@ -1000,7 +997,6 @@ const App: Component = () => {
<div class="main-content">
<FileBrowser
breadcrumbs={breadcrumbs}
currentDir={currentDir}
setCurrentDir={setCurrentDir}
playTrack={playTrack}
▾Mwebclient/src/components/DownloadButton.tsx
@@ -13,7 +13,6 @@ interface DownloadButtonProps {
item: MediaFile | Directory;
fetchFiles: ResourceActions<FlatFileListing>;
isOffline: Accessor<boolean>;
currentDir: Accessor<string>;
options: AppOptions;
}
▾Mwebclient/src/components/DownloadManager.css
@@ -29,8 +29,10 @@
gap: 0.2rem;
padding: 0.5rem;
background-color: var(--color-c);
&:hover {
background-color: var(--color-c-highlight);
@media (hover: hover) {
&:hover {
background-color: var(--color-c-highlight);
}
}
& > span {
@@ -51,14 +53,18 @@
&.downloadmanager-item-failed {
background-color: oklch(0.35 0.137 30);
&:hover {
background-color: oklch(0.4 0.137 30);
@media (hover: hover) {
&:hover {
background-color: oklch(0.4 0.137 30);
}
}
}
&.downloadmanager-item-aborted {
background-color: oklch(0.48 0.085 102);
&:hover {
background-color: oklch(0.53 0.085 102);
@media (hover: hover) {
&:hover {
background-color: oklch(0.53 0.085 102);
}
}
}
}
▾Mwebclient/src/components/DownloadManager.tsx
@@ -1,9 +1,10 @@
import { type Accessor, batch, createEffect, createMemo, createSignal, For, type Setter, Show } from "solid-js";
import { type Accessor, batch, createMemo, createSignal, For, type Setter, Show } from "solid-js";
import "./DownloadManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { type DownloadStatus, downloadStatusMap, type Progress, setDownloadStatusMap } from "../offline";
import { formatBytes } from "../utils";
import { Modal } from "./Modal";
import { ClearSVG, RefreshSVG, StopSVG } from "./SVG";
interface DownloadManagerProps {
@@ -12,19 +13,6 @@ interface DownloadManagerProps {
}
export default function DownloadManager(props: DownloadManagerProps) {
let dialogElement!: HTMLDialogElement;
createEffect(() => {
if (props.showDownloadManager()) {
dialogElement.showModal();
}
});
function handleClose() {
dialogElement.close();
props.setShowDownloadManager(false);
}
const downloadKeys = createMemo(() => {
const keys = Object.keys(downloadStatusMap);
const progressKeys = keys.filter((key) => isProgress(downloadStatusMap[key]));
@@ -54,155 +42,147 @@ export default function DownloadManager(props: DownloadManagerProps) {
return status !== undefined && typeof status === "object" && "status" in status;
}
const [downloading, setDownloading] = createSignal(0);
const [aborted, setAborted] = createSignal(0);
const [failed, setFailed] = createSignal(0);
createEffect(() => {
batch(() => {
setDownloading(0);
setAborted(0);
setFailed(0);
for (const key of downloadKeys()) {
const status = downloadStatusMap[key];
if (!isProgress(status)) continue;
if (typeof status.status === "number") setDownloading((prev) => prev + 1);
if (status.status === "Aborted") setAborted((prev) => prev + 1);
if (status.status === "Failed") setFailed((prev) => prev + 1);
}
});
const counts = createMemo(() => {
const counted = { downloading: 0, aborted: 0, failed: 0 };
for (const key of downloadKeys()) {
const status = downloadStatusMap[key];
if (!isProgress(status)) continue;
if (typeof status.status === "number") counted.downloading++;
else if (status.status === "Aborted") counted.aborted++;
else if (status.status === "Failed") counted.failed++;
}
return counted;
});
return (
<dialog
style={{ height: "90%" }}
ref={dialogElement}
onClose={handleClose}
onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
>
<div style={{ height: "100%" }}>
<div class="downloadmanager-header title">
<span>Downloads</span>
<button
title="Retry all failed/aborted downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if ("retry" in status) status.retry();
}
});
}}
>
<RefreshSVG />
</button>
<button
title="stop all running downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if (typeof status.status === "number") {
status.abort();
<Modal show={props.showDownloadManager} setShow={props.setShowDownloadManager} style={{ height: "90%" }}>
{(close) => (
<div style={{ height: "100%" }}>
<div class="downloadmanager-header title">
<span>Downloads</span>
<button
title="Retry all failed/aborted downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if ("retry" in status) status.retry();
}
}
});
}}
>
<StopSVG />
</button>
<button
title="Clear all failed/aborted downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if (status.status === "Aborted" || status.status === "Failed") setDownloadStatusMap(path, undefined);
}
});
}}
>
Clear
</button>
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<div class="downloadmanager-counters">
<span>In progress: {downloading()}</span>
<span>Aborted: {aborted()}</span>
<span>Failed: {failed()}</span>
</div>
<div class="downloadmanager-content">
<For each={downloadKeys()}>
{(path) => {
const [showReason, setShowReason] = createSignal(false);
//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 (
<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
});
}}
>
<RefreshSVG />
</button>
<button
title="stop all running downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if (typeof status.status === "number") {
status.abort();
}
}
});
}}
>
<StopSVG />
</button>
<button
title="Clear all failed/aborted downloads"
onClick={() => {
batch(() => {
for (const path of downloadKeys()) {
const status = downloadStatusMap[path] as Progress;
if (status.status === "Aborted" || status.status === "Failed")
setDownloadStatusMap(path, undefined);
}
});
}}
>
Clear
</button>
<button title="Close dialog" onClick={close}>
<ClearSVG />
</button>
</div>
<hr />
<div class="downloadmanager-counters">
<span>In progress: {counts().downloading}</span>
<span>Aborted: {counts().aborted}</span>
<span>Failed: {counts().failed}</span>
</div>
<div class="downloadmanager-content">
<For each={downloadKeys()}>
{(path) => {
const [showReason, setShowReason] = createSignal(false);
//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 (
<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>
)) ||
"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 when={showReason()}>
{(() => {
const reasonStatus = currentStatus();
return "reason" in reasonStatus && reasonStatus.reason;
})()}
</Show>
</div>
)}
</Show>
);
}}
</For>
)}
</Show>
);
}}
</For>
</div>
</div>
</div>
</dialog>
)}
</Modal>
);
}
▾Mwebclient/src/components/FileBrowser.css
@@ -70,8 +70,10 @@
display: flex;
gap: 1.5rem;
align-items: center;
&:hover {
background-color: var(--color-c-highlight);
@media (hover: hover) {
&:hover {
background-color: var(--color-c-highlight);
}
}
& > span {
min-width: 0;
@@ -114,8 +116,10 @@
& > span {
cursor: pointer;
color: var(--color-d);
&:hover {
text-decoration: underline;
@media (hover: hover) {
&:hover {
text-decoration: underline;
}
}
}
& > .toggle-browser-actions {
▾Mwebclient/src/components/FileBrowser.tsx
@@ -17,13 +17,20 @@ import { createStore, reconcile } from "solid-js/store";
import type { FlatFileListing } from "../App";
import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline";
import type { AppOptions, PlaylistItem } from "../types";
import { basename, compareDirFirst, formatFilename, generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import {
basename,
compareDirFirst,
formatFilename,
joinPath,
LocalStorageValues,
persistedToggle,
toast,
} from "../utils";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
interface FileBrowserProps {
breadcrumbs: Accessor<string[]>;
currentDir: Accessor<string>;
setCurrentDir: Setter<string>;
playTrack: (track: PlaylistItem) => void;
@@ -38,8 +45,10 @@ interface FileBrowserProps {
}
export default function FileBrowser(props: FileBrowserProps) {
//"root" stands for the virtual top level that lists the user's roots
const breadcrumbs = () => ["root", ...props.currentDir().split("/").filter(Boolean)];
const [loadingActions, setLoadingActions] = createSignal(0);
const [showActionButtons, setShowActionButtons] = createSignal(true);
const [showActionButtons, setShowActionButtons] = persistedToggle(LocalStorageValues.fileBrowserButtons, true);
const [showFilter, setShowFilter] = createSignal(false);
const [filter, setFilter] = createSignal("");
createEffect(
@@ -70,13 +79,13 @@ export default function FileBrowser(props: FileBrowserProps) {
if (index === 0) {
props.setCurrentDir("");
} else {
props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
props.setCurrentDir(joinPath(...breadcrumbs().slice(1, index + 1)));
}
}
function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
return {
id: generateUUID(),
id: crypto.randomUUID(),
metadata: item.metadata,
path: path,
};
@@ -86,7 +95,9 @@ export default function FileBrowser(props: FileBrowserProps) {
function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
//keep sorting same as shown in the filebrowser: directories first (by name), then files (by display name)
const displayNameOf = (name: string, item: Directory | MediaFile) =>
"metadata" in item ? formatFilename({ path: name, metadata: item.metadata }, props.options.filebrowserDisplay) : name;
"metadata" in item
? formatFilename({ path: name, metadata: item.metadata }, props.options.filebrowserDisplay)
: name;
const sorted = Object.entries(files).sort(([aName, aItem], [bName, bItem]) =>
compareDirFirst(
displayNameOf(aName, aItem),
@@ -207,10 +218,6 @@ export default function FileBrowser(props: FileBrowserProps) {
);
onMount(() => {
const savedFileBrowserButtons = localStorage.getItem(LocalStorageValues.fileBrowserButtons);
if (savedFileBrowserButtons !== null) setShowActionButtons(savedFileBrowserButtons === "true");
createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString()));
filelistElement.addEventListener("scrollend", () => {
if (scrollPositions.length > 0) scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
});
@@ -298,13 +305,13 @@ export default function FileBrowser(props: FileBrowserProps) {
</dialog>
<div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
<div classList={{ breadcrumb: true, "breadcrumb-nowrap": !showActionButtons() }}>
<For each={props.breadcrumbs()}>
<For each={breadcrumbs()}>
{(crumb, index) => (
<>
<span title={crumb} onClick={() => handleBreadcrumbClick(index())}>
{crumb}
</span>
{index() < props.breadcrumbs().length - 1 && "/"}
{index() < breadcrumbs().length - 1 && "/"}
</>
)}
</For>
@@ -395,7 +402,6 @@ export default function FileBrowser(props: FileBrowserProps) {
item={file}
fetchFiles={props.fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
</div>
▾Awebclient/src/components/Modal.tsx
@@ -0,0 +1,41 @@
import { type Accessor, createEffect, type JSX, type Setter, untrack } from "solid-js";
interface ModalProps {
show: Accessor<boolean>;
setShow: Setter<boolean>;
//runs when the dialog opens, to reset whatever state it holds. untracked, so reading a store in
//here cannot turn a later change to it into a second open
onOpen?: () => void;
//runs on every close path, including Escape and a backdrop click
onClose?: () => void;
style?: JSX.CSSProperties;
//a function, so the content can close the dialog without needing its own ref to the element
children: (close: () => void) => JSX.Element;
}
export function Modal(props: ModalProps) {
let dialogElement!: HTMLDialogElement;
createEffect(() => {
if (!props.show()) return;
dialogElement.showModal();
untrack(() => props.onOpen?.());
});
const close = () => dialogElement.close();
return (
<dialog
ref={dialogElement}
style={props.style}
//the close event covers every path, so the show signal cannot get out of sync with the element
onClose={() => {
props.onClose?.();
props.setShow(false);
}}
onclick={(e) => e.target.nodeName === "DIALOG" && close()}
>
{props.children(close)}
</dialog>
);
}
▾Mwebclient/src/components/OptionsModal.css
@@ -35,8 +35,10 @@
.cancel-button {
background-color: oklch(0.49 0.1742 26.51);
&:hover {
background-color: oklch(0.7 0.1742 26.51);
@media (hover: hover) {
&:hover {
background-color: oklch(0.7 0.1742 26.51);
}
}
}
}
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,9 +1,9 @@
import { type Accessor, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
import { type Accessor, createEffect, createSignal, For, type JSX, Match, type Setter, Show, Switch } from "solid-js";
import { type AppOptions, AUDIO_BITRATES, FileDisplay, StreamingMode, VIDEO_BITRATES } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { type AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { audioCodecHasBitrate } from "music-server-shared/utils";
import { createStore } from "solid-js/store";
import {
@@ -18,6 +18,7 @@ import {
selectableVideoCodecs,
} from "../capabilities";
import { handleUnauthorized, toast } from "../utils";
import { Modal } from "./Modal";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -49,13 +50,114 @@ function SupportedOption(props: { value: string; support: Support }) {
);
}
//all three display settings offer the same choices
function FileDisplaySelect(props: {
id: string;
label: string;
value: FileDisplay;
onPick: (value: FileDisplay) => void;
}) {
return (
<div>
<label for={props.id}>{props.label}</label>
<select id={props.id} value={props.value} onChange={(e) => props.onPick(Number.parseInt(e.target.value, 10))}>
<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>
);
}
//the parts of a transcoding profile that video and music have in common. the video-only controls are
//passed as children, so they land between the container and the audio codec like they did per-page
function ProfileSection(props: {
title: string;
idPrefix: string;
settings: AppOptions["videoSettings"] | AppOptions["audioSettings"];
setField: (field: "streamingMode" | "container" | "audioCodec" | "audioBitrate", value: string | number) => void;
children?: JSX.Element;
}) {
return (
<>
<div style={{ "font-size": "1.2rem" }} class="title">
{props.title}
</div>
<div>
<label for={`${props.idPrefix}-streaming-mode`}>Streaming mode:</label>
<select
id={`${props.idPrefix}-streaming-mode`}
title={STREAMING_MODE_HINTS[props.settings.streamingMode]}
value={props.settings.streamingMode}
onChange={(e) => props.setField("streamingMode", e.target.value)}
>
<For each={Object.values(StreamingMode)}>
{(mode) => (
<option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
{mode}
</option>
)}
</For>
</select>
</div>
<div>
<label for={`${props.idPrefix}-container`}>Container:</label>
<select
id={`${props.idPrefix}-container`}
title={checkSupport(props.settings).reason}
value={props.settings.container}
onChange={(e) => props.setField("container", e.target.value)}
>
<For each={Object.values(MediaContainer)}>
{(format) => (
<SupportedOption
value={format}
support={checkContainerSupport({ ...props.settings, container: format })}
/>
)}
</For>
</select>
</div>
{props.children}
<div>
<label for={`${props.idPrefix}-audio-codec`}>Audio codec:</label>
<select
id={`${props.idPrefix}-audio-codec`}
value={props.settings.audioCodec}
onChange={(e) => props.setField("audioCodec", e.target.value)}
>
<For each={containerAudioCodecs(props.settings.container)}>
{(codec) => (
<SupportedOption value={codec} support={checkSupport({ ...props.settings, audioCodec: codec })} />
)}
</For>
</select>
</div>
<Show when={audioCodecHasBitrate(props.settings.audioCodec)}>
<div>
<label for={`${props.idPrefix}-audio-bitrate`}>Audio bitrate:</label>
<select
id={`${props.idPrefix}-audio-bitrate`}
value={props.settings.audioBitrate}
onChange={(e) => props.setField("audioBitrate", Number.parseInt(e.target.value, 10))}
>
<For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
</select>
</div>
</Show>
</>
);
}
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;
//the last two pages only exist while transcoding is on
const pages = () => (newOptions.enableTranscoding ? [1, 2, 3, 4, 5] : [1, 2, 3]);
//keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
//pick and never the one they are already on. the store setter merges key by key and skips values it
//already holds, so a settled selection writes nothing, notifies nobody and this re-run is the last
@@ -65,27 +167,11 @@ export default function OptionsModal(props: OptionsModalProps) {
createEffect(() => coerceProfile("videoSettings"));
createEffect(() => coerceProfile("audioSettings"));
createEffect(
on(props.showOptions, () => {
if (props.showOptions()) {
dialogElement.showModal();
setNewOptions(props.options);
setOptionsPage(1);
}
}),
);
function handleSave(e: Event) {
e.preventDefault();
function handleSave(close: () => void) {
if (!formElement.reportValidity()) return;
toast("Saved changes", "success");
props.setOptions(newOptions);
handleClose();
}
function handleClose() {
dialogElement.close();
props.setShowOptions(false);
close();
}
async function deleteServerCache() {
@@ -99,435 +185,272 @@ export default function OptionsModal(props: OptionsModalProps) {
}
return (
<dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<form class="option-content" ref={formElement}>
<div class="title">Player Options</div>
<hr />
<div class="options-page-buttons">
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(1);
}}
classList={{ "page-button-active": optionsPage() === 1 }}
>
1
</button>
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(2);
}}
classList={{ "page-button-active": optionsPage() === 2 }}
>
2
</button>
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(3);
}}
classList={{ "page-button-active": optionsPage() === 3 }}
>
3
</button>
{newOptions.enableTranscoding && (
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(4);
}}
classList={{ "page-button-active": optionsPage() === 4 }}
>
4
</button>
)}
{newOptions.enableTranscoding && (
<button
onClick={(e) => {
e.preventDefault();
return setOptionsPage(5);
}}
classList={{ "page-button-active": optionsPage() === 5 }}
>
5
</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="language-input">Subtitle language preference:</label>
<input
id="language-input"
type="text"
placeholder="en,de"
title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching subtitle is selected by default; no match leaves subtitles off."
value={newOptions.languages}
onChange={(e) => setNewOptions("languages", e.target.value)}
/>
</div>
<div>
<label for="audio-language-input">Audio language preference:</label>
<input
id="audio-language-input"
type="text"
placeholder="en,de"
title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching audio track is selected during transcoding; no match uses the source default track."
value={newOptions.audioLanguagePreference}
onChange={(e) => setNewOptions("audioLanguagePreference", e.target.value)}
disabled={!newOptions.enableTranscoding}
/>
</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
<Modal
show={props.showOptions}
setShow={props.setShowOptions}
onOpen={() => {
setNewOptions(props.options);
setOptionsPage(1);
}}
>
{(close) => (
<form class="option-content" ref={formElement}>
<div class="title">Player Options</div>
<hr />
<div class="options-page-buttons">
<For each={pages()}>
{(page) => (
<button
onClick={(e) => {
e.preventDefault();
setOptionsPage(page);
}}
classList={{ "page-button-active": optionsPage() === page }}
>
{page}
</button>
)}
</For>
</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="language-input">Subtitle language preference:</label>
<input
id="language-input"
type="text"
placeholder="en,de"
title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching subtitle is selected by default; no match leaves subtitles off."
value={newOptions.languages}
onChange={(e) => setNewOptions("languages", e.target.value)}
/>
</div>
<div>
<label for="audio-language-input">Audio language preference:</label>
<input
id="audio-language-input"
type="text"
placeholder="en,de"
title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching audio track is selected during transcoding; no match uses the source default track."
value={newOptions.audioLanguagePreference}
onChange={(e) => setNewOptions("audioLanguagePreference", e.target.value)}
disabled={!newOptions.enableTranscoding}
/>
</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>
<FileDisplaySelect
id="filebrowser-display-select"
label="Filebrowser:"
value={newOptions.filebrowserDisplay}
onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value, 10))}
>
<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
onPick={(value) => setNewOptions("filebrowserDisplay", value)}
/>
<FileDisplaySelect
id="playlist-display-select"
label="Playlist:"
value={newOptions.playlistDisplay}
onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value, 10))}
>
<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
onPick={(value) => setNewOptions("playlistDisplay", value)}
/>
<FileDisplaySelect
id="controls-display-select"
label="Current track:"
value={newOptions.controlsDisplay}
onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value, 10))}
>
<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, 10))}
>
<option value={10}>10px</option>
<option value={12}>12px</option>
<option value={16}>16px</option>
</select>
</div>
<div>
<label for="progress-height-input">Progress bar height:</label>
<input
id="progress-height-input"
type="number"
min={0}
required
value={newOptions.progressBarHeight}
onChange={(e) => setNewOptions("progressBarHeight", e.target.valueAsNumber)}
/>
</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)}
onPick={(value) => setNewOptions("controlsDisplay", value)}
/>
</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="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
<Match when={optionsPage() === 4}>
<div style={{ "font-size": "1.2rem" }} class="title">
Transcoding settings for videos
</div>
<div>
<label for="video-streaming-mode-select">Streaming mode:</label>
<select
id="video-streaming-mode-select"
title={STREAMING_MODE_HINTS[newOptions.videoSettings.streamingMode]}
value={newOptions.videoSettings.streamingMode}
onChange={(e) => setNewOptions("videoSettings", "streamingMode", e.target.value as StreamingMode)}
>
<For each={Object.values(StreamingMode)}>
{(mode) => (
<option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
{mode}
</option>
)}
</For>
</select>
</div>
<div>
<label for="container-select">Container:</label>
<select
id="container-select"
title={checkSupport(newOptions.videoSettings).reason}
value={newOptions.videoSettings.container}
onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
>
<For each={Object.values(MediaContainer)}>
{(format) => (
<SupportedOption
value={format}
support={checkContainerSupport({ ...newOptions.videoSettings, container: format })}
/>
)}
</For>
</select>
</div>
<Show when={selectableVideoCodecs(newOptions.videoSettings).length > 0}>
<div>
<label for="video-codec-select">Video codec:</label>
<label for="font-resolution-select">Font resolution:</label>
<select
id="video-codec-select"
value={newOptions.videoSettings.videoCodec}
onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
id="font-resolution-select"
value={newOptions.fontResolution}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value, 10))}
>
<For each={containerVideoCodecs(newOptions.videoSettings.container)}>
{(codec) => (
<SupportedOption
value={codec}
support={checkSupport({ ...newOptions.videoSettings, videoCodec: codec })}
/>
)}
</For>
<option value={10}>10px</option>
<option value={12}>12px</option>
<option value={16}>16px</option>
</select>
</div>
</Show>
<Show when={newOptions.videoSettings.videoCodec !== VideoCodec.none}>
<div>
<label for="video-settings-select">Video encoding settings:</label>
<select
id="video-settings-select"
value={newOptions.videoSettings.videoEncodingSetting}
onChange={(e) =>
setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
}
>
<For each={Object.values(VideoEncodingSetting)}>
{(setting) => {
return <option value={setting}>{setting}</option>;
}}
</For>
</select>
<label for="progress-height-input">Progress bar height:</label>
<input
id="progress-height-input"
type="number"
min={0}
required
value={newOptions.progressBarHeight}
onChange={(e) => setNewOptions("progressBarHeight", e.target.valueAsNumber)}
/>
</div>
</Match>
<Match when={optionsPage() === 3}>
<div>
<label for="video-bitrate-select">Video bitrate:</label>
<select
id="video-bitrate-select"
value={newOptions.videoSettings.videoBitrate}
onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))}
>
<For each={VIDEO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
</select>
<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>
</Show>
<div>
<label for="audio-codec-select">Audio codec:</label>
<select
id="audio-codec-select"
value={newOptions.videoSettings.audioCodec}
onChange={(e) => setNewOptions("videoSettings", "audioCodec", e.target.value as AudioCodec)}
>
<For each={containerAudioCodecs(newOptions.videoSettings.container)}>
{(codec) => (
<SupportedOption
value={codec}
support={checkSupport({ ...newOptions.videoSettings, audioCodec: codec })}
/>
)}
</For>
</select>
</div>
<Show when={audioCodecHasBitrate(newOptions.videoSettings.audioCodec)}>
<div>
<label for="audio-bitrate-select">Audio bitrate:</label>
<select
id="audio-bitrate-select"
value={newOptions.videoSettings.audioBitrate}
onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
>
<For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
</select>
<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>
</Show>
</Match>
<Match when={optionsPage() === 5}>
<div style={{ "font-size": "1.2rem" }} class="title">
Transcoding settings for music
</div>
<div>
<label for="audio-streaming-mode-select">Streaming mode:</label>
<select
id="audio-streaming-mode-select"
title={STREAMING_MODE_HINTS[newOptions.audioSettings.streamingMode]}
value={newOptions.audioSettings.streamingMode}
onChange={(e) => setNewOptions("audioSettings", "streamingMode", e.target.value as StreamingMode)}
>
<For each={Object.values(StreamingMode)}>
{(mode) => (
<option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
{mode}
</option>
)}
</For>
</select>
</div>
<div>
<label for="music-container-select">Container:</label>
<select
id="music-container-select"
title={checkSupport(newOptions.audioSettings).reason}
value={newOptions.audioSettings.container}
onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
>
<For each={Object.values(MediaContainer)}>
{(format) => (
<SupportedOption
value={format}
support={checkContainerSupport({ ...newOptions.audioSettings, container: format })}
/>
)}
</For>
</select>
</div>
<div>
<label for="music-audio-codec-select">Audio codec:</label>
<select
id="music-audio-codec-select"
value={newOptions.audioSettings.audioCodec}
onChange={(e) => setNewOptions("audioSettings", "audioCodec", e.target.value as AudioCodec)}
>
<For each={containerAudioCodecs(newOptions.audioSettings.container)}>
{(codec) => (
<SupportedOption
value={codec}
support={checkSupport({ ...newOptions.audioSettings, audioCodec: codec })}
/>
)}
</For>
</select>
</div>
<Show when={audioCodecHasBitrate(newOptions.audioSettings.audioCodec)}>
<div>
<label for="music-audio-bitrate-select">Audio bitrate:</label>
<select
id="music-audio-bitrate-select"
value={newOptions.audioSettings.audioBitrate}
onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
>
<For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
</select>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Show>
</Match>
</Switch>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
</button>
<button title="Save settings" type="button" onClick={handleSave}>
Save
</button>
<button title="Cancel changes settings" type="button" class="cancel-button" onClick={handleClose}>
Cancel
</button>
</div>
</form>
</dialog>
</Match>
<Match when={optionsPage() === 4}>
<ProfileSection
title="Transcoding settings for videos"
idPrefix="video"
settings={newOptions.videoSettings}
setField={(field, value) =>
setNewOptions("videoSettings", field as "container", value as MediaContainer)
}
>
<Show when={selectableVideoCodecs(newOptions.videoSettings).length > 0}>
<div>
<label for="video-codec-select">Video codec:</label>
<select
id="video-codec-select"
value={newOptions.videoSettings.videoCodec}
onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
>
<For each={containerVideoCodecs(newOptions.videoSettings.container)}>
{(codec) => (
<SupportedOption
value={codec}
support={checkSupport({ ...newOptions.videoSettings, videoCodec: codec })}
/>
)}
</For>
</select>
</div>
</Show>
<Show when={newOptions.videoSettings.videoCodec !== VideoCodec.none}>
<div>
<label for="video-settings-select">Video encoding settings:</label>
<select
id="video-settings-select"
value={newOptions.videoSettings.videoEncodingSetting}
onChange={(e) =>
setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
}
>
<For each={Object.values(VideoEncodingSetting)}>
{(setting) => <option value={setting}>{setting}</option>}
</For>
</select>
</div>
<div>
<label for="video-bitrate-select">Video bitrate:</label>
<select
id="video-bitrate-select"
value={newOptions.videoSettings.videoBitrate}
onChange={(e) =>
setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))
}
>
<For each={VIDEO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
</select>
</div>
</Show>
</ProfileSection>
</Match>
<Match when={optionsPage() === 5}>
<ProfileSection
title="Transcoding settings for music"
idPrefix="music"
settings={newOptions.audioSettings}
setField={(field, value) =>
setNewOptions("audioSettings", field as "container", value as MediaContainer)
}
/>
</Match>
</Switch>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
</button>
<button title="Save settings" type="button" onClick={() => handleSave(close)}>
Save
</button>
<button title="Cancel changes settings" type="button" class="cancel-button" onClick={close}>
Cancel
</button>
</div>
</form>
)}
</Modal>
);
}
▾Mwebclient/src/components/PlayerControls.tsx
@@ -1,8 +1,8 @@
import "./PlayerControls.css";
import { type Accessor, createEffect, createSignal, For, onMount, type Setter, Show } from "solid-js";
import { type Accessor, createEffect, createSignal, For, type Setter, Show } from "solid-js";
import type { SetStoreFunction } from "solid-js/store";
import type { AppOptions, LoadedVideoExtras, PlayerState } from "../types";
import { formatFilename, formatTime, LocalStorageValues } from "../utils";
import { formatFilename, formatTime, LocalStorageValues, persistedToggle } from "../utils";
import { LoadingSpinner } from "./LoadingScreen";
import {
ClearSVG,
@@ -23,6 +23,8 @@ import {
type SubtitleSelection = "off" | number;
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
interface PlayerControlsProps {
playerState: PlayerState;
setPlayerState: SetStoreFunction<PlayerState>;
@@ -47,10 +49,7 @@ interface PlayerControlsProps {
}
export default function PlayerControls(props: PlayerControlsProps) {
const [controlsDisabled, setControlsDisabled] = createSignal(false);
createEffect(() => {
setControlsDisabled(!props.playerState.currentTrack);
});
const controlsDisabled = () => !props.playerState.currentTrack;
function setVolume(e: Event) {
const input = e.target as HTMLInputElement;
@@ -96,20 +95,18 @@ export default function PlayerControls(props: PlayerControlsProps) {
return Math.max(0, Math.min(100, (start / duration) * 100));
}
//how far along the current track is, in percent
const playedPercent = () => {
const track = props.playerState.currentTrack;
if (!track) return 0;
return Math.min(100, (props.playerState.currentTime / (track.metadata.duration || Number.POSITIVE_INFINITY)) * 100);
};
const [isDragging, setIsDragging] = createSignal(false);
const [dragPos, setDragPos] = createSignal(0);
//the handle follows playback except while it is being dragged, when it follows the pointer instead
createEffect(() => {
if (isDragging()) return;
setDragPos(
props.playerState.currentTrack
? Math.min(
100,
(props.playerState.currentTime /
(props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0,
);
setDragPos(playedPercent());
});
let progressContainerRef!: HTMLDivElement;
let handleRef!: HTMLDivElement;
@@ -147,12 +144,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
handleRef.releasePointerCapture(e.pointerId);
}
const [showPlayerControls, setShowPlayerControls] = createSignal(true);
onMount(() => {
const savePlayerControls = localStorage.getItem(LocalStorageValues.playerControls);
if (savePlayerControls !== null) setShowPlayerControls(savePlayerControls === "true");
createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
});
const [showPlayerControls, setShowPlayerControls] = persistedToggle(LocalStorageValues.playerControls, true);
let metadataDialogRef!: HTMLDialogElement;
@@ -160,20 +152,34 @@ export default function PlayerControls(props: PlayerControlsProps) {
metadataDialogRef.close();
}
function MetadataEntry(label: string, value: unknown, suffix?: string) {
if (!value) return undefined;
return (
<div>
<strong>{label}</strong>: {value.toString()}
{suffix}
<br />
</div>
);
}
function bitrateDisplay(bitrate: number | undefined, divisor: number) {
if (!bitrate) return undefined;
return bitrate / divisor;
//every row of the metadata dialog, in display order. a falsy value drops its row
function metadataEntries(): [label: string, value: unknown, suffix?: string][] {
const track = props.playerState.currentTrack;
if (!track) return [];
const metadata = track.metadata;
const perThousand = (value: number | undefined, divisor: number) => (value ? value / divisor : undefined);
return [
["Path", track.path],
["Title", metadata.title],
["Track number", metadata.trackNumber],
["Album", metadata.album],
["Artist", metadata.artist],
["Genre", metadata.genre?.join(", ")],
["Duration", metadata.duration.toFixed(1), " seconds"],
["Container format", metadata.container],
["Audio codec", metadata.audioCodec],
["Audio bitrate", perThousand(metadata.audioBitrate, 1000), " kbps"],
["Video codec", metadata.videoCodec],
["Video bitrate", perThousand(metadata.videoBitrate, 1000 * 1000), " mbps"],
[
"Video dimensions",
metadata.videoWidth && metadata.videoHeight ? `${metadata.videoWidth} × ${metadata.videoHeight}` : undefined,
],
["Frame rate", metadata.frameRate, " fps"],
["Interlaced", metadata.interlaced === undefined ? undefined : metadata.interlaced ? "yes" : "no"],
["Subtitle tracks", props.videoExtras()?.subtitleTracks.length || undefined],
["Chapters", props.videoExtras()?.chapters.length || undefined],
];
}
return (
@@ -189,44 +195,15 @@ export default function PlayerControls(props: PlayerControlsProps) {
</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")}
{MetadataEntry("Container format", props.playerState.currentTrack?.metadata.container)}
{MetadataEntry("Audio codec", props.playerState.currentTrack?.metadata.audioCodec)}
{MetadataEntry(
"Audio bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.audioBitrate, 1000),
" kbps",
)}
{MetadataEntry("Video codec", props.playerState.currentTrack?.metadata.videoCodec)}
{MetadataEntry(
"Video bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.videoBitrate, 1000 * 1000),
" mbps",
)}
{MetadataEntry(
"Video dimensions",
props.playerState.currentTrack?.metadata.videoWidth &&
props.playerState.currentTrack?.metadata.videoHeight
? `${props.playerState.currentTrack.metadata.videoWidth} × ${props.playerState.currentTrack.metadata.videoHeight}`
: undefined,
)}
{MetadataEntry("Frame rate", props.playerState.currentTrack?.metadata.frameRate, " fps")}
{MetadataEntry(
"Interlaced",
props.playerState.currentTrack?.metadata.interlaced === undefined
? undefined
: props.playerState.currentTrack.metadata.interlaced
? "yes"
: "no",
)}
{MetadataEntry("Subtitle tracks", props.videoExtras()?.subtitleTracks.length || undefined)}
{MetadataEntry("Chapters", props.videoExtras()?.chapters.length || undefined)}
<For each={metadataEntries().filter(([, value]) => value)}>
{([label, value, suffix]) => (
<div>
<strong>{label}</strong>: {value?.toString()}
{suffix}
<br />
</div>
)}
</For>
</div>
</div>
</dialog>
@@ -293,21 +270,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
);
}}
</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 class="progress-fill" style={{ width: `${playedPercent()}%` }} />
<div
class="progress-handle"
style={{
@@ -361,16 +324,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
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>
<For each={PLAYBACK_RATES}>{(rate) => <option value={rate}>{rate}x</option>}</For>
</select>
</div>
<Show when={props.isVideoTrack() && props.videoExtras()?.subtitleTracks.length}>
▾Mwebclient/src/components/Playlist.css
@@ -47,8 +47,10 @@
font-weight: bold;
}
&:not(.drop-target-top-border):not(.drop-target-bottom-border):hover {
background-color: var(--color-c-highlight);
@media (hover: hover) {
&:not(.drop-target-top-border):not(.drop-target-bottom-border):hover {
background-color: var(--color-c-highlight);
}
}
&.drop-target-top-border,
&.drop-target-bottom-border {
@@ -94,8 +96,10 @@
& > button {
background: none;
padding: 0;
&:hover {
background: none;
@media (hover: hover) {
&:hover {
background: none;
}
}
}
}
@@ -140,8 +144,10 @@
z-index: 3;
padding: 0;
background: rgba(0, 0, 0, 0.6);
&:hover {
background: rgba(0, 0, 0, 0.85);
@media (hover: hover) {
&:hover {
background: rgba(0, 0, 0, 0.85);
}
}
}
▾Mwebclient/src/components/Playlist.tsx
@@ -1,13 +1,11 @@
import {
type Accessor,
batch,
createEffect,
createMemo,
createResource,
createSelector,
createSignal,
For,
on,
onMount,
type ResourceActions,
type Setter,
Show,
@@ -16,7 +14,7 @@ import "./Playlist.css";
import type { FlatFileListing } from "../App";
import { download, getCover, savePlaylist, storedFilesLive } from "../offline";
import type { AppOptions, LoadedVideoExtras, PlayerState, PlaylistItem } from "../types";
import { formatFilename, formatTime, handleUnauthorized, LocalStorageValues, toast } from "../utils";
import { formatFilename, formatTime, handleUnauthorized, LocalStorageValues, persistedToggle, toast } from "../utils";
import { DraggableList } from "./DraggableList";
import { LoadingScreen } from "./LoadingScreen";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
@@ -39,7 +37,7 @@ interface PlaylistProps {
}
export default function Playlist(props: PlaylistProps) {
const [showActionButtons, setShowActionButtons] = createSignal(true);
const [showActionButtons, setShowActionButtons] = persistedToggle(LocalStorageValues.playlistButtons, true);
//TODO: add a way to refetch cover
const [cover] = createResource<string | undefined, { filePath?: string; offline: boolean }>(
@@ -77,9 +75,7 @@ export default function Playlist(props: PlaylistProps) {
toast("Started playlist download", "info");
const a = document.createElement("a");
a.href = `/download-playlist/${id}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
toast("Copied playlist link to clipboard", "info");
const urlPath = location.href.split(/\?|#/)[0];
@@ -142,17 +138,9 @@ export default function Playlist(props: PlaylistProps) {
allowOverwriteCheckbox.checked = false;
}
const [syncedCount, setSyncedCount] = createSignal(0);
createEffect(
on([props.playlist, storedFilesLive], ([playlist, storedFiles]) => {
const stored = new Set(storedFiles.map(String));
setSyncedCount(playlist.filter((item) => stored.has(item.path)).length);
}),
);
onMount(() => {
const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons);
if (savedPlaylistButtons !== null) setShowActionButtons(savedPlaylistButtons === "true");
createEffect(() => localStorage.setItem(LocalStorageValues.playlistButtons, showActionButtons().toString()));
const syncedCount = createMemo(() => {
const stored = new Set(storedFilesLive().map(String));
return props.playlist().filter((item) => stored.has(item.path)).length;
});
return (
▾Mwebclient/src/components/PlaylistManager.css
@@ -16,8 +16,10 @@
gap: 0.2rem;
padding: 0.5rem;
background-color: var(--color-c);
&:hover {
background-color: var(--color-c-highlight);
@media (hover: hover) {
&:hover {
background-color: var(--color-c-highlight);
}
}
& > span {
▾Mwebclient/src/components/PlaylistManager.tsx
@@ -1,11 +1,12 @@
import { type Accessor, createEffect, createResource, createSignal, For, type Setter, Show } from "solid-js";
import { type Accessor, createResource, createSignal, For, type Setter, Show } from "solid-js";
import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import type { AppOptions, Playlist, PlaylistItem } from "../types";
import { formatFilename, formatTime, generateUUID, toast } from "../utils";
import { formatFilename, formatTime, toast } from "../utils";
import { LoadingScreen } from "./LoadingScreen";
import { Modal } from "./Modal";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
interface PlaylistManagerProps {
@@ -25,19 +26,6 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
{ initialValue: [] },
);
const [loadingActions, setLoadingActions] = createSignal(0);
let dialogElement!: HTMLDialogElement;
createEffect(() => {
if (props.showPlaylistManager()) {
dialogElement.showModal();
fetchPlaylists.refetch();
}
});
function handleClose() {
dialogElement.close();
props.setShowPlaylistManager(false);
}
function getTotalDuration(playlist: Playlist) {
if (playlist.items.length === 0) return 0;
@@ -54,9 +42,7 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "pico-playlists.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
@@ -71,8 +57,7 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
try {
toast("Importing playlists...", "info");
setLoadingActions((prev) => prev + 1);
const decoder = new TextDecoder();
const imports = JSON.parse(decoder.decode(await file.bytes())) as Playlist[];
const imports = JSON.parse(await file.text()) as Playlist[];
for (const imported of imports) {
await savePlaylist(imported, true);
}
@@ -87,101 +72,107 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
}
function generateIds(items: Omit<PlaylistItem, "id">[]): PlaylistItem[] {
return items.map((item) => ({ ...item, id: generateUUID() }));
return items.map((item) => ({ ...item, id: crypto.randomUUID() }));
}
return (
<dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<div>
<div class="playlistmanager-header title">
Playlists
<div class="spacer" />
<button title="Export playlists" onClick={exportPlaylists}>
<DownloadSVG />
</button>
<button title="Import playlists" onClick={importPlaylists}>
<UploadSVG />
</button>
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<div class="playlistmanager-content">
<Show when={playlists.loading || loadingActions() > 0}>
<LoadingScreen />
</Show>
<Show when={!playlists.loading}>
<For each={playlists()}>
{(item) => {
const [showSongs, setShowSongs] = createSignal(false);
return (
<div class="playlistmanager-item">
<span>{item.name}</span>
<hr />
<div class="playlistmanager-buttons">
<span>
{item.items.length} Items ({formatTime(getTotalDuration(item))})
</span>
<button
title="Show playlist items"
onClick={() => {
setShowSongs(!showSongs());
}}
>
Show
</button>
<button
title="Replace current playlist with this one"
onClick={() => {
const playlist = generateIds(item.items);
props.setPlaylist(playlist);
if (item.items.length > 0) props.playTrack(playlist[0]);
handleClose();
}}
>
Play
</button>
<button
title="Add items from this playlist to current one"
onClick={() => props.setPlaylist(props.playlist().concat(generateIds(item.items)))}
>
Queue
</button>
<button
title="Delete playlist"
onClick={async () => {
setLoadingActions((prev) => prev + 1);
try {
await deletePlaylist(item.name);
fetchPlaylists.refetch();
} finally {
setLoadingActions((prev) => prev - 1);
}
}}
>
<DeleteSVG />
</button>
<Modal
show={props.showPlaylistManager}
setShow={props.setShowPlaylistManager}
onOpen={() => fetchPlaylists.refetch()}
>
{(close) => (
<div>
<div class="playlistmanager-header title">
Playlists
<div class="spacer" />
<button title="Export playlists" onClick={exportPlaylists}>
<DownloadSVG />
</button>
<button title="Import playlists" onClick={importPlaylists}>
<UploadSVG />
</button>
<button title="Close dialog" onClick={close}>
<ClearSVG />
</button>
</div>
<hr />
<div class="playlistmanager-content">
<Show when={playlists.loading || loadingActions() > 0}>
<LoadingScreen />
</Show>
<Show when={!playlists.loading}>
<For each={playlists()}>
{(item) => {
const [showSongs, setShowSongs] = createSignal(false);
return (
<div class="playlistmanager-item">
<span>{item.name}</span>
<hr />
<div class="playlistmanager-buttons">
<span>
{item.items.length} Items ({formatTime(getTotalDuration(item))})
</span>
<button
title="Show playlist items"
onClick={() => {
setShowSongs(!showSongs());
}}
>
Show
</button>
<button
title="Replace current playlist with this one"
onClick={() => {
const playlist = generateIds(item.items);
props.setPlaylist(playlist);
if (item.items.length > 0) props.playTrack(playlist[0]);
close();
}}
>
Play
</button>
<button
title="Add items from this playlist to current one"
onClick={() => props.setPlaylist(props.playlist().concat(generateIds(item.items)))}
>
Queue
</button>
<button
title="Delete playlist"
onClick={async () => {
setLoadingActions((prev) => prev + 1);
try {
await deletePlaylist(item.name);
fetchPlaylists.refetch();
} finally {
setLoadingActions((prev) => prev - 1);
}
}}
>
<DeleteSVG />
</button>
</div>
<Show when={showSongs()}>
<For each={item.items}>
{(item) => {
return (
<>
{formatFilename(item, props.options.playlistDisplay)}
<br />
</>
);
}}
</For>
</Show>
</div>
<Show when={showSongs()}>
<For each={item.items}>
{(item) => {
return (
<>
{formatFilename(item, props.options.playlistDisplay)}
<br />
</>
);
}}
</For>
</Show>
</div>
);
}}
</For>
</Show>
);
}}
</For>
</Show>
</div>
</div>
</div>
</dialog>
)}
</Modal>
);
}
▾Mwebclient/src/components/SignIn.tsx
@@ -1,8 +1,9 @@
import { type Accessor, createEffect, type Setter } from "solid-js";
import type { Accessor, Setter } from "solid-js";
import { ClearSVG } from "./SVG";
import "./SignIn.css";
import { toast } from "../utils";
import { LoadingSpinner } from "./LoadingScreen";
import { Modal } from "./Modal";
interface SignInProps {
showSignIn: Accessor<boolean>;
@@ -11,27 +12,18 @@ interface SignInProps {
}
export function SignIn(props: SignInProps) {
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
let usernameInput!: HTMLInputElement;
let passwordInput!: HTMLInputElement;
let loadingElement!: HTMLDivElement;
let submitButton!: HTMLButtonElement;
createEffect(() => {
if (props.showSignIn()) {
dialogElement.showModal();
}
});
function handleClose() {
dialogElement.close();
function clearInputs() {
usernameInput.value = "";
passwordInput.value = "";
props.setShowSignIn(false);
}
async function doSignIn(e: Event) {
async function doSignIn(e: Event, close: () => void) {
e.preventDefault();
usernameInput.setCustomValidity("");
if (!formElement.reportValidity()) return;
@@ -50,7 +42,7 @@ export function SignIn(props: SignInProps) {
if (response.status === 200) {
toast("Login successful", "success");
props.onLoggedIn();
handleClose();
close();
} else if (response.status === 401) {
toast("Login failed: wrong credentials", "error");
} else {
@@ -63,42 +55,28 @@ export function SignIn(props: SignInProps) {
}
return (
<dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<form ref={formElement} onSubmit={doSignIn}>
<div class="signin-header title">
Sign in
<button type="button" title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<label for="username-input">Username:</label>
<input
ref={usernameInput}
id="username-input"
type="text"
minlength={1}
required
value=""
onChange={() => {}}
/>
<label for="password-input">Password:</label>
<input
ref={passwordInput}
id="password-input"
type="password"
minlength={1}
required
value=""
onChange={() => {}}
/>
<button ref={submitButton} type="submit" class="signin-button" title="Submit sign in">
Submit
<div ref={loadingElement} style={{ display: "none", width: "24px", height: "24px" }}>
<LoadingSpinner center />
<Modal show={props.showSignIn} setShow={props.setShowSignIn} onClose={clearInputs}>
{(close) => (
<form ref={formElement} onSubmit={(e) => doSignIn(e, close)}>
<div class="signin-header title">
Sign in
<button type="button" title="Close dialog" onClick={close}>
<ClearSVG />
</button>
</div>
</button>
</form>
</dialog>
<hr />
<label for="username-input">Username:</label>
<input ref={usernameInput} id="username-input" type="text" minlength={1} required value="" />
<label for="password-input">Password:</label>
<input ref={passwordInput} id="password-input" type="password" minlength={1} required value="" />
<button ref={submitButton} type="submit" class="signin-button" title="Submit sign in">
Submit
<div ref={loadingElement} style={{ display: "none", width: "24px", height: "24px" }}>
<LoadingSpinner center />
</div>
</button>
</form>
)}
</Modal>
);
}
▾Mwebclient/src/components/StorageManager.css
@@ -57,8 +57,10 @@
& > .storagemanager-armed {
background-color: oklch(0.48 0.085 102);
&:hover {
background-color: oklch(0.53 0.085 102);
@media (hover: hover) {
&:hover {
background-color: oklch(0.53 0.085 102);
}
}
}
▾Mwebclient/src/components/StorageManager.tsx
@@ -1,6 +1,5 @@
import {
type Accessor,
createEffect,
createMemo,
createSignal,
For,
@@ -15,6 +14,7 @@ import "toastify-js/src/toastify.css";
import type { FlatFileListing } from "../App";
import { clearAllDownloads, clearLocalData, storageStatsLive } from "../offline";
import { formatBytes, toast } from "../utils";
import { Modal } from "./Modal";
import { ClearSVG } from "./SVG";
interface StorageManagerProps {
@@ -65,42 +65,19 @@ function ConfirmButton(props: { label: string; title: string; onConfirm: () => v
}
export default function StorageManager(props: StorageManagerProps) {
let dialogElement!: HTMLDialogElement;
//whether the browser has these APIs at all cannot change while the page is open
const estimateSupported = !!navigator.storage?.estimate;
const persistedSupported = !!navigator.storage?.persisted;
const [estimate, setEstimate] = createSignal<StorageEstimateWithDetails>();
const [estimateSupported, setEstimateSupported] = createSignal(true);
const [persisted, setPersisted] = createSignal<boolean>();
const [persistedSupported, setPersistedSupported] = createSignal(true);
const [requestingPersist, setRequestingPersist] = createSignal(false);
const [resetError, setResetError] = createSignal<string>();
const [resetting, setResetting] = createSignal(false);
async function refreshStorageInfo() {
if (navigator.storage?.estimate) {
setEstimateSupported(true);
setEstimate(await navigator.storage.estimate().catch(() => undefined));
} else {
setEstimateSupported(false);
}
if (navigator.storage?.persisted) {
setPersistedSupported(true);
setPersisted(await navigator.storage.persisted().catch(() => undefined));
} else {
setPersistedSupported(false);
}
}
createEffect(() => {
if (props.showStorage()) {
dialogElement.showModal();
refreshStorageInfo();
setResetError(undefined);
}
});
function handleClose() {
dialogElement.close();
props.setShowStorage(false);
if (estimateSupported) setEstimate(await navigator.storage.estimate().catch(() => undefined));
if (persistedSupported) setPersisted(await navigator.storage.persisted().catch(() => undefined));
}
const usagePercent = createMemo(() => {
@@ -151,90 +128,95 @@ export default function StorageManager(props: StorageManagerProps) {
}
return (
<dialog
ref={dialogElement}
onClose={handleClose}
onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
<Modal
show={props.showStorage}
setShow={props.setShowStorage}
onOpen={() => {
refreshStorageInfo();
setResetError(undefined);
}}
>
<div>
<div class="storagemanager-header title">
<span>Storage</span>
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<Show
when={estimateSupported()}
fallback={<div class="storagemanager-section">Storage estimate API not available in this browser</div>}
>
<div class="storagemanager-section">
<div class="storagemanager-row">
<span>Usage: {formatSize(estimate()?.usage ?? 0)}</span>
<span>Quota: {quotaLabel()}</span>
{(close) => (
<div>
<div class="storagemanager-header title">
<span>Storage</span>
<button title="Close dialog" onClick={close}>
<ClearSVG />
</button>
</div>
<hr />
<Show
when={estimateSupported}
fallback={<div class="storagemanager-section">Storage estimate API not available in this browser</div>}
>
<div class="storagemanager-section">
<div class="storagemanager-row">
<span>Usage: {formatSize(estimate()?.usage ?? 0)}</span>
<span>Quota: {quotaLabel()}</span>
</div>
<div class="storagemanager-bar" title={`${usagePercent().toFixed(1)}% of quota used`}>
<div class="storagemanager-bar-fill" style={{ width: `${usagePercent()}%` }} />
<span>{usagePercent().toFixed(1)}%</span>
</div>
<Show when={usageDetails().length > 0}>
<For each={usageDetails()}>
{([key, bytes]) => (
<div class="storagemanager-row storagemanager-detail">
<span>{USAGE_DETAIL_LABELS[key] ?? key}</span>
<span>{formatSize(bytes)}</span>
</div>
)}
</For>
</Show>
</div>
<div class="storagemanager-bar" title={`${usagePercent().toFixed(1)}% of quota used`}>
<div class="storagemanager-bar-fill" style={{ width: `${usagePercent()}%` }} />
<span>{usagePercent().toFixed(1)}%</span>
</Show>
<Show when={persistedSupported}>
<div class="storagemanager-section">
<div class="storagemanager-row">
<span>Persistent storage: {persisted() === undefined ? "unknown" : persisted() ? "yes" : "no"}</span>
<Show when={persisted() === false}>
<button
title="Ask the browser to never evict this site's stored files when space runs low"
disabled={requestingPersist()}
onClick={requestPersistence}
>
Request persistent storage
</button>
</Show>
</div>
</div>
<Show when={usageDetails().length > 0}>
<For each={usageDetails()}>
{([key, bytes]) => (
<div class="storagemanager-row storagemanager-detail">
<span>{USAGE_DETAIL_LABELS[key] ?? key}</span>
<span>{formatSize(bytes)}</span>
</div>
)}
</For>
</Show>
</div>
</Show>
</Show>
<Show when={persistedSupported()}>
<div class="storagemanager-section">
<div class="storagemanager-row">
<span>Persistent storage: {persisted() === undefined ? "unknown" : persisted() ? "yes" : "no"}</span>
<Show when={persisted() === false}>
<button
title="Ask the browser to never evict this site's stored files when space runs low"
disabled={requestingPersist()}
onClick={requestPersistence}
>
Request persistent storage
</button>
</Show>
<span>Synced files: {storageStatsLive().fileCount}</span>
<span>{formatSize(storageStatsLive().fileBytes)}</span>
</div>
<div class="storagemanager-row">
<span>Covers: {storageStatsLive().coverCount}</span>
<span>Playlists: {storageStatsLive().playlistCount}</span>
</div>
</div>
</Show>
<div class="storagemanager-section">
<div class="storagemanager-row">
<span>Synced files: {storageStatsLive().fileCount}</span>
<span>{formatSize(storageStatsLive().fileBytes)}</span>
</div>
<div class="storagemanager-row">
<span>Covers: {storageStatsLive().coverCount}</span>
<span>Playlists: {storageStatsLive().playlistCount}</span>
<div class="storagemanager-section storagemanager-actions">
<ConfirmButton
label="Clear synced files"
title="Delete all synced files and covers from local storage. Playlists and settings are kept"
onConfirm={clearSynced}
/>
<ConfirmButton
label={resetting() ? "Resetting..." : "Full reset"}
title="Delete everything this app has stored locally - files, covers, playlists, settings and caches - then reload the page"
onConfirm={fullReset}
/>
<Show when={resetError()}>
<div class="storagemanager-error">{resetError()}</div>
</Show>
</div>
</div>
<div class="storagemanager-section storagemanager-actions">
<ConfirmButton
label="Clear synced files"
title="Delete all synced files and covers from local storage. Playlists and settings are kept"
onConfirm={clearSynced}
/>
<ConfirmButton
label={resetting() ? "Resetting..." : "Full reset"}
title="Delete everything this app has stored locally - files, covers, playlists, settings and caches - then reload the page"
onConfirm={fullReset}
/>
<Show when={resetError()}>
<div class="storagemanager-error">{resetError()}</div>
</Show>
</div>
</div>
</dialog>
)}
</Modal>
);
}
▾Dwebclient/src/generate-build-hash.sh
-4
@@ -1,4 +0,0 @@
#!/bin/bash
UUID=$(hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
echo "export const BUILD_HASH = '$UUID';" > src/build-info.ts
▾Mwebclient/src/index.tsx
@@ -19,36 +19,6 @@ if (!(root instanceof HTMLElement)) {
navigator?.storage?.persist()?.catch(() => {});
function hasTouch() {
return (
"ontouchstart" in document.documentElement ||
navigator.maxTouchPoints > 0 ||
("msMaxTouchPoints" in navigator &&
typeof navigator.msMaxTouchPoints === "number" &&
navigator.msMaxTouchPoints > 0)
);
}
if (hasTouch()) {
// remove all the :hover stylesheets
try {
// prevent exception on browsers not supporting DOM styleSheets properly
for (const si in document.styleSheets) {
const styleSheet = document.styleSheets[si];
if (!styleSheet.cssRules) continue;
for (let ri = styleSheet.cssRules.length - 1; ri >= 0; ri--) {
const rule = styleSheet.cssRules[ri];
if (!("selectorText" in rule) || typeof rule.selectorText !== "string" || !rule.selectorText) continue;
if (rule.selectorText.match(":hover")) {
styleSheet.deleteRule(ri);
}
}
}
} catch {}
}
function ResetDialog() {
const [error, setError] = createSignal<string>();
const [resetting, setResetting] = createSignal(false);
▾Mwebclient/src/offline.ts
@@ -180,13 +180,11 @@ function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, off
}
}
//overloads for typechecking
function getDirInTree(path: string, create: true, offline: true): Directory;
function getDirInTree(path: string, create: true, offline: false): DirectoryWithStatus;
//one overload per call shape: creating always yields a node, looking one up may not find it, and
//which tree flavor comes back depends on the mode - the offline tree holds plain Directory nodes
function getDirInTree(path: string, create: true, offline: boolean): DirectoryWithStatus | Directory;
function getDirInTree(path: string, create: false, offline: true): Directory | null;
function getDirInTree(path: string, create: false, offline: false): DirectoryWithStatus | null;
function getDirInTree(path: string, create: true, offline: boolean): DirectoryWithStatus | Directory;
function getDirInTree(path: string, create: false, offline: boolean): DirectoryWithStatus | Directory | null;
// get a DirectoryNode from the directory tree matching the path, and optionally creating missing nodes it if they don't exist yet
function getDirInTree(path: string, create: boolean, offline: boolean): DirectoryWithStatus | Directory | null {
let current = offline ? offlineTreeRoot : onlineTreeRoot;
@@ -426,9 +424,7 @@ export function revokeVideoExtras(extras: LoadedVideoExtras | undefined): void {
//Unlike normal playback metadata, sync treats extras as part of the file request: a failed chapter probe
//or a failed subtitle conversion rejects before the media blob is committed to IndexedDB.
async function downloadVideoExtras(path: string, signal: AbortSignal): Promise<StoredVideoExtras> {
const response = await fetch(videoInfoUrl(path), { credentials: "same-origin", signal }).catch((error) => {
throw error;
});
const response = await fetch(videoInfoUrl(path), { credentials: "same-origin", signal });
checkVideoExtrasResponse(response, path);
const extras = (await response.json()) as VideoExtras;
const subtitles = await Promise.all(
@@ -436,8 +432,6 @@ async function downloadVideoExtras(path: string, signal: AbortSignal): Promise<S
const subtitleResponse = await fetch(subtitleUrl(path, track.streamIndex), {
credentials: "same-origin",
signal,
}).catch((error) => {
throw error;
});
if (handleUnauthorized(subtitleResponse) || !subtitleResponse.ok) {
throw new Error(
@@ -709,7 +703,7 @@ export async function download(
redownload = false,
) {
if ("metadata" in item) {
await downloadFile(fullPath, options, item, fetchFiles, redownload || false);
await downloadFile(fullPath, options, item, fetchFiles, redownload);
} else {
updateRecursiveStatus(fullPath, 1, true);
try {
▾Mwebclient/src/style.css
@@ -34,16 +34,21 @@ option {
background-color: var(--color-d);
color: oklch(0.26 0 0);
&:hover {
background-color: oklch(from var(--color-d) 0.85 c h);
/* hover styles only where a pointer can actually hover, so a tap does not stick */
@media (hover: hover) {
&:hover {
background-color: oklch(from var(--color-d) 0.85 c h);
}
}
}
select:disabled {
background-color: var(--color-d);
color: oklch(0.5 0 0);
&:hover {
background-color: var(--color-d);
@media (hover: hover) {
&:hover {
background-color: var(--color-d);
}
}
}
@@ -65,8 +70,10 @@ svg {
button:has(> svg) {
padding: 0;
background: none;
&:hover {
background: none;
@media (hover: hover) {
&:hover {
background: none;
}
}
}
▾Mwebclient/src/utils.ts
@@ -1,8 +1,16 @@
import { type Accessor, createEffect, createSignal, onMount, type Setter } from "solid-js";
import Toastify from "toastify-js";
import { FileDisplay, type PlaylistItem } from "./types";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
//a boolean toggle that restores itself from localStorage on mount and persists every later change
export function persistedToggle(key: string, fallback: boolean): [Accessor<boolean>, Setter<boolean>] {
const [value, setValue] = createSignal(fallback);
onMount(() => {
const saved = localStorage.getItem(key);
if (saved !== null) setValue(saved === "true");
createEffect(() => localStorage.setItem(key, value().toString()));
});
return [value, setValue];
}
//join string and dedup slashes and make sure it doesn't start or end with a slash
@@ -55,14 +63,6 @@ export const LocalStorageValues = {
playerControls: "PlayerControls",
};
export function generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function stringErr(e: unknown) {
return e && typeof e === "object" && "message" in e ? e.message : e;
}
@@ -147,21 +147,14 @@ 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 basename(item.path);
}
export function formatFilename(file: Omit<PlaylistItem, "id">, format: FileDisplay): string {
switch (format) {
case FileDisplay.FILENAME:
return getItemName(file);
return basename(file.path);
case FileDisplay.TITLE:
return file.metadata.title || getItemName(file);
return file.metadata.title || basename(file.path);
case FileDisplay.TRACKNO_TITLE: {
if (!file.metadata.title) return getItemName(file);
if (!file.metadata.title) return basename(file.path);
const prefix = file.metadata.trackNumber ? `${file.metadata.trackNumber}. ` : "";
return `${prefix}${file.metadata.title}`;
}
▾Mwebclient/tsconfig.json
@@ -13,10 +13,6 @@
"isolatedModules": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"alwaysStrict": true,
"skipLibCheck": true
}
}
▾Mwebclient/vite.common.config.ts
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import type { VitePWAOptions } from "vite-plugin-pwa";
export const pwaOptions: Partial<VitePWAOptions> = {
@@ -69,15 +69,10 @@ export const pwaOptions: Partial<VitePWAOptions> = {
},
};
//a fresh id per build, shown in the header so a running client can be matched to a deployment
export const hashPlugin = {
name: "pre-build-step",
name: "build-hash",
buildStart() {
console.log("Running pre-build step...");
try {
execSync('echo "Running pre-build script" && sh src/generate-build-hash.sh', { stdio: "inherit" });
} catch (error) {
console.error("Pre-build step failed:", error);
process.exit(1);
}
writeFileSync("src/build-info.ts", `export const BUILD_HASH = "${crypto.randomUUID().toUpperCase()}";\n`);
},
};