add extension exclusion, improve progressbar click handling & rendering, allow collapsing filepath breadcrumbs, fix loading-spinner size

AuthorKonata <konata@posteo.jp>
Date
Commit6e6ea81c77c2644dd1eea889d6394713bfd81253
Parent093ccef
9 files changed, 44 insertions(+), 17 deletions(-)
▾MREADME.md
@@ -32,6 +32,7 @@ docker-compose up # or podman-compose up
## Configuration
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
- `EXCLUDE_EXTENSION`: comma-separated list of of file extensions to ignore completely, i.e. "txt,log,nfo". If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
## Important Notes
- **No Authentication**: The app has no built-in auth. Run it behind a proxy with basic auth for security.
@@ -46,12 +47,13 @@ You can configure the backend with the following environment variables:
- The playback stops automatically at some point on my mobile device when I put the browser in the background or turning my screen off
- In my testing at least mobile firefox behaves this way, with no apparent solution. It seems to stop after changing the sources of the audio element at some point. Either use a browser that doesn't behave like this (Chrome on Android seems to work), or wrap the PWA in a native app (e.g. https://www.pwabuilder.com/)
- I'm getting "Quota exceeded" errors when trying to sync files *or*
- My synced files randomly don't work, are missing
- My synced files randomly don't work, are missing
- By default websites aren't granted infinite storage or have persisten data. The frontend requests these permissions by calling `navigator.storage.persist()` on startup, which causes some browsers to show a permissions popup (e.g. firefox), while others just seem to ignore this request and use a heuristic like interaction amount and bookmarking instead. So if you have the error described above you either are running out of actual disk storage, or you need to check what heuristics your browser uses and trigger them.
## Known issues
- Range requests are not correctly handled in Bun right now, and e.g. doesn't send the total byte length correctly. I worked around this by sending a chunked transfer-encoded response, and setting the range and content-length header manually, which seems to work with the browsers I tested, but still might cause issues in some cases
- Some of the transcoded formats behave weirdly in some browsers, e.g. safari seems to dislike ogg containers, and chrome has seeking issues with some webm transcoded files. If you have any code suggestions to improve the handling, feel free to open a PR at https://gitlab.com/Konata390/pico-pixel-player
- Some files (especially mp3) fail to either be listed or to be played. I encountered some mp3 files that could be played fine, but had some weird metadata issues that caused the file-type/music-metadata libraries to not be able to scan them, which blocks them from being played in the browsers. The solution is to run `ffmpeg -i INPUTFILE -c copy OUTPUTFILE` on them, afterwards they are scanned just fine
## Attributions
▾Mserver/src/index.ts
@@ -15,7 +15,6 @@ import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, pack
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream } from "node:fs";
import { MediaFormat } from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import { stringifyMap } from "music-server-shared/mapconversion";
▾Mserver/src/shared.ts
@@ -14,6 +14,9 @@ if (!path.isAbsolute(musicRoot)) {
process.exit(1);
}
export const excludeExtension =
process.env.EXCLUDE_EXTENSION === undefined ? ["txt", "log", "nfo", "m3u"] : process.env.EXCLUDE_EXTENSION.split(",");
if (!commandExists.sync("ffmpeg")) {
console.error("Required command ffmpeg not found in PATH");
process.exit(1);
▾Mserver/src/utils.ts
@@ -2,8 +2,17 @@ import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import { type FileListingWithStatus, MediaFormat } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
import path, { extname } from "node:path";
import {
coverRegex,
excludeExtension,
fileTypeCache,
mediaTypes,
musicRoot,
PathInfo,
probeCache,
ServerError,
} from "./shared";
import { parseFile } from "music-metadata";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
@@ -60,6 +69,7 @@ export async function probeFile(filePath: string): Promise<ProbeData> {
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
try {
if (excludeExtension.includes(extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
const stats = await stat(filePath);
if (stats.isDirectory()) {
return undefined;
▾Mwebclient/src/App.tsx
@@ -18,7 +18,7 @@ import { getCover, getSrc, listFiles } from "./offline";
import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore } from "solid-js/store";
import { createStore, reconcile } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
@@ -38,7 +38,7 @@ export type FlatFileListing = [string, Directory | MediaFile][];
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1)));
const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
const [playerState, setPlayerState] = createStore<PlayerState>({
isPlaying: false,
@@ -255,7 +255,7 @@ const App: Component = () => {
// Update progress when the time updates
function updateProgress() {
setPlayerState("currentTime", audioElement.currentTime);
setPlayerState("buffered", timeRangesToArray(audioElement.buffered));
setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
}
// Initialize app
@@ -304,7 +304,7 @@ const App: Component = () => {
// Set up audio element event handlers
audioElement.addEventListener("ended", playNext);
audioElement.addEventListener("timeupdate", updateProgress);
audioElement.addEventListener("durationchange", updateProgress);
// audioElement.addEventListener("durationchange", updateProgress);
audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
▾Mwebclient/src/components/FileBrowser.css
@@ -8,8 +8,8 @@
}
.file-browser {
flex-grow: 1;
flex-basis: 0;
flex: 1;
min-width: 0;
height: 100%;
background-color: var(--color-a);
display: flex;
@@ -89,6 +89,18 @@
border-bottom: 1px solid var(--color-c);
display: flex;
flex-wrap: wrap;
&.breadcrumb-nowrap {
flex-wrap: nowrap;
text-wrap: nowrap;
& > span {
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
max-width: fit-content;
}
}
gap: 0.5rem;
& > span {
cursor: pointer;
▾Mwebclient/src/components/FileBrowser.tsx
@@ -203,11 +203,13 @@ export default function FileBrowser(props: FileBrowserProps) {
</div>
</dialog>
<div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
<div class="breadcrumb">
<div classList={{ breadcrumb: true, "breadcrumb-nowrap": !showActionButtons() }}>
<For each={props.breadcrumbs()}>
{(crumb, index) => (
<>
<span onClick={() => handleBreadcrumbClick(index())}>{crumb}</span>
<span title={crumb} onClick={() => handleBreadcrumbClick(index())}>
{crumb}
</span>
{index() < props.breadcrumbs().length - 1 && "/"}
</>
)}
▾Mwebclient/src/components/PlayerControls.css
@@ -23,7 +23,7 @@
text-overflow: ellipsis;
text-wrap: nowrap;
}
& > .loading-spinner {
.loading-spinner {
width: 24px !important;
height: 24px !important;
}
@@ -56,11 +56,11 @@
& > .buffered-fill,
& > .progress-fill {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
height: 100%;
cursor: pointer;
background-color: #0cc069;
}
▾Mwebclient/src/components/Playlist.css
@@ -1,9 +1,8 @@
.playlist-container {
display: flex;
flex-grow: 1;
flex-basis: 0;
flex-direction: column;
flex: 1;
min-width: 0;
flex-direction: column;
.save-dialog {
max-width: 300px;
.save-buttons {