update screenshots, hide cover option, allow hiding playlist and filebrowser buttons

AuthorKonata <konata@posteo.jp>
Date
Commit943b729e0486dd9080c6fa2b539db5552c25fd7e
Parentda47d78
16 files changed, 176 insertions(+), 70 deletions(-)
▾MREADME.md
@@ -29,6 +29,10 @@ MUSIC_ROOT=/path/to/your/music
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.
## Important Notes
- **No Authentication**: The app has no built-in auth. Run it behind a proxy with basic auth for security.
▾Mserver/src/index.ts
@@ -15,7 +15,7 @@ import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, pack
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream, ReadStream } from "node:fs";
import { createReadStream } from "node:fs";
import { MediaFormat } from "music-server-shared/types";
import { stringifyMap } from "music-server-shared/mapconversion";
import { randomUUIDv7 } from "bun";
@@ -23,6 +23,7 @@ import { randomUUIDv7 } from "bun";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
//TODO: better ffmpeg errors
//TODO: more cover detection
const { values } = parseArgs({
args: Bun.argv,
▾Mserver/src/shared.ts
@@ -2,7 +2,7 @@ import path from "node:path";
import type { ProbeData } from "./utils";
import commandExists from "command-exists";
export const coverRegex = process.env.COVER_REGEX || /(C|c)over.(png|jpg)$/;
export const coverRegex = process.env.COVER_REGEX === undefined ? /((C|c)over)|((F|f)older).(png|jpg)$/ : "";
export const musicRoot = process.env.MUSIC_ROOT || "";
if (musicRoot === "") {
console.error("Please define the environment variable MUSIC_ROOT");
▾Mserver/src/utils.ts
@@ -16,6 +16,7 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
type CoverResult = { path: string; info: PathInfo } | { info: ServerError };
export async function findCover(dirPath: string): Promise<CoverResult> {
if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
let coverFile: string | undefined;
try {
coverFile = await findAsync(await readdir(dirPath), async (file) => {
▾Mwebclient/public/assets/screenshot-narrow.png
Bin 324070 → 72560 bytes@ 943b729@ master
Binary file — not shown
▾Mwebclient/public/assets/screenshot-wide.png
Bin 275618 → 156952 bytes@ 943b729@ master
Binary file — not shown
▾Mwebclient/src/App.tsx
@@ -13,15 +13,12 @@ import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore } from "solid-js/store";
//TODO: add loading state to playercontrols
//TODO: search
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
//TODO: button to show metadata
//TODO: show cover of playing item
//TODO: delete confirmation?
//TODO: setting export/import
//TODO: video support
//TODO: virtual rendering for playlist
//TODO: custom serialization?
@@ -42,6 +39,7 @@ const App: Component = () => {
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
bitrate: 128000,
enableTranscoding: true,
disableChunkedTranscoding: false,
@@ -52,6 +50,7 @@ const App: Component = () => {
hideVolume: false,
duplicateWarning: true,
fontResolution: 12,
hideCover: false,
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -240,6 +239,7 @@ const App: Component = () => {
try {
const savedOptions = localStorage.getItem(LocalStorageValues.options);
if (savedOptions) {
//keep default settings when no override exists
setOptions({ ...options, ...JSON.parse(savedOptions) });
}
const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
@@ -271,6 +271,7 @@ const App: Component = () => {
createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
// Set initial audio element volume
audioElement.volume = playerState.volume;
▾Mwebclient/src/components/FileBrowser.css
@@ -97,6 +97,9 @@
text-decoration: underline;
}
}
& > .toggle-browser-actions {
margin-left: auto;
}
}
.cover {
▾Mwebclient/src/components/FileBrowser.tsx
@@ -16,7 +16,7 @@ import {
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
import { ClosedFolderSVG, DownSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
@@ -38,6 +38,7 @@ interface FileBrowserProps {
export default function FileBrowser(props: FileBrowserProps) {
const [loadingActions, setLoadingActions] = createSignal(0);
const [showActionButtons, setShowActionButtons] = createSignal(true);
const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
() => ({ dir: props.currentDir(), offline: props.isOffline() }),
async (args, info) => {
@@ -172,13 +173,14 @@ 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", () => {
scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
});
createEffect(() => {
createEffect(() => localStorage.setItem(LocalStorageValues.offline, props.isOffline().toString()));
});
});
return (
@@ -202,39 +204,52 @@ export default function FileBrowser(props: FileBrowserProps) {
</>
)}
</For>
<button
class="toggle-browser-actions"
title="Toggle filebrowser actions"
onclick={() => setShowActionButtons(!showActionButtons())}
>
{showActionButtons() ? <UpSVG /> : <DownSVG />}
</button>
</div>
<div class="browser-actions">
<div>
<button
title="Replace playlist with all files in current directory"
onClick={() => playFiles("", false, false)}
>
Play Directory
</button>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles("", true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles("", true, true)}
>
Queue All
</button>
<Show when={showActionButtons()}>
<div class="browser-actions">
<div>
<button
title="Replace playlist with all files in current directory"
onClick={() => playFiles("", false, false)}
>
Play Directory
</button>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles("", true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles("", true, true)}
>
Queue All
</button>
</div>
<div class="spacer" />
<Show when={!props.isOffline()}>
<button
class="refresh-button"
title="Refresh current directory, bypassing cache"
onClick={reloadDirectory}
>
<RefreshSVG />
</button>
</Show>
</div>
<div class="spacer" />
<Show when={!props.isOffline()}>
<button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
<RefreshSVG />
</button>
</Show>
</div>
</Show>
<div class="file-list-wrapper">
<Show when={props.files.loading}>
@@ -285,7 +300,7 @@ export default function FileBrowser(props: FileBrowserProps) {
</For>
</div>
</div>
<Show when={cover.loading || cover()}>
<Show when={!props.options.hideCover && (cover.loading || cover())}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
▾Mwebclient/src/components/OptionsModal.tsx
@@ -168,6 +168,15 @@ export default function OptionsModal(props: OptionsModalProps) {
<option value={16}>16px</option>
</select>
</div>
<div>
<label for="hide-cover-checkbox">Hide cover:</label>
<input
id="hide-cover-checkbox"
type="checkbox"
checked={newOptions.hideCover}
onChange={(e) => setNewOptions("hideCover", e.target.checked)}
/>
</div>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
▾Mwebclient/src/components/Playlist.css
@@ -75,6 +75,12 @@
border-bottom: 1px solid var(--color-c);
flex-wrap: wrap;
&> .playlist-header-title{
display: inherit;
gap: inherit;
margin-right: auto;
}
& > .button-container {
display: flex;
gap: inherit;
▾Mwebclient/src/components/Playlist.tsx
@@ -1,8 +1,17 @@
import { createSelector, type Accessor, type Setter, createSignal, type ResourceActions, createEffect } from "solid-js";
import {
createSelector,
type Accessor,
type Setter,
createSignal,
type ResourceActions,
createEffect,
onMount,
Show,
} from "solid-js";
import "./Playlist.css";
import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG } from "./SVG";
import { formatTime, LocalStorageValues, toast } from "../utils";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
import { savePlaylist, isFileSynced, download, downloadStatusMap } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FileListing } from "music-server-shared/types";
@@ -21,6 +30,7 @@ interface PlaylistProps {
}
export default function Playlist(props: PlaylistProps) {
const [showActionButtons, setShowActionButtons] = createSignal(true);
function removeFromPlaylist(id: string) {
props.playNext();
const newPlaylist = props.playlist().filter((i) => i.id !== id);
@@ -116,6 +126,13 @@ export default function Playlist(props: PlaylistProps) {
await Promise.all(promises);
setSyncedCount(count);
});
onMount(() => {
const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons);
if (savedPlaylistButtons !== null) setShowActionButtons(savedPlaylistButtons === "true");
createEffect(() => localStorage.setItem(LocalStorageValues.playlistButtons, showActionButtons().toString()));
});
return (
<div class="playlist-container">
<dialog class="save-dialog" ref={dialogElement}>
@@ -131,33 +148,42 @@ export default function Playlist(props: PlaylistProps) {
</dialog>
<div class="playlist-header">
<span>
Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
</span>
<div class="spacer" />
<div class="button-container">
<button title="Save playlist" onClick={() => dialogElement.showModal()}>
<SaveSVG />
</button>
<button title="Sync all unsynced files in playlist" onClick={syncPlaylist} disabled={syncing()}>
<SyncSVG />
</button>
<button
title="Copies and 1-hour valid link for the same action as the download button"
onClick={() => playlistDownload(false)}
>
<LinkSVG />
</button>
<div class="playlist-header-title">
<span>
Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
</span>
<button
title="Download all files in the playlist in their original format as a .tar file, or without archive if it contains just a single item"
onClick={() => playlistDownload(true)}
title="Toggle playlist actions"
onclick={() => setShowActionButtons(!showActionButtons())}
>
<DownloadSVG />
</button>
<button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
<ClearSVG />
{showActionButtons() ? <UpSVG /> : <DownSVG />}
</button>
</div>
<Show when={showActionButtons()}>
<div class="button-container">
<button title="Save playlist" onClick={() => dialogElement.showModal()}>
<SaveSVG />
</button>
<button title="Sync all unsynced files in playlist" onClick={syncPlaylist} disabled={syncing()}>
<SyncSVG />
</button>
<button
title="Copies and 1-hour valid link for the same action as the download button"
onClick={() => playlistDownload(false)}
>
<LinkSVG />
</button>
<button
title="Download all files in the playlist in their original format as a .tar file, or without archive if it contains just a single item"
onClick={() => playlistDownload(true)}
>
<DownloadSVG />
</button>
<button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
<ClearSVG />
</button>
</div>
</Show>
</div>
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist} id="playlist">
▾Mwebclient/src/components/SVG.tsx
@@ -415,3 +415,41 @@ export function SaveSVG() {
</svg>
);
}
export function DownSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M23 8v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-2v-1h-1v-1H9v-1H8v-1H7v-1H6v-1H5v-1H4v-1H3v-1H2V9H1V8h1V7h1V6h1v1h1v1h1v1h1v1h1v1h1v1h1v1h1v1h2v-1h1v-1h1v-1h1v-1h1V9h1V8h1V7h1V6h1v1h1v1z"
/>
</svg>
);
}
export function UpSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M23 16v1h-1v1h-1v1h-1v-1h-1v-1h-1v-1h-1v-1h-1v-1h-1v-1h-1v-1h-1v-1h-2v1h-1v1H9v1H8v1H7v1H6v1H5v1H4v1H3v-1H2v-1H1v-1h1v-1h1v-1h1v-1h1v-1h1v-1h1v-1h1V9h1V8h1V7h1V6h2v1h1v1h1v1h1v1h1v1h1v1h1v1h1v1h1v1h1v1z"
/>
</svg>
);
}
▾Mwebclient/src/types.ts
@@ -38,4 +38,5 @@ export interface AppOptions {
hideVolume: boolean;
duplicateWarning: boolean;
fontResolution: number;
hideCover: boolean;
}
▾Mwebclient/src/utils.ts
@@ -24,6 +24,8 @@ export const LocalStorageValues = {
repeat: "Repeat",
offline: "Offline",
path: "Path",
fileBrowserButtons: "FileBrowserButtons",
playlistButtons: "PlaylistButtons",
};
export function generateUUID() {
▾Mwebclient/vite.common.config.ts
@@ -21,12 +21,12 @@ export const pwaOptions: Partial<VitePWAOptions> = {
{
src: "/assets/screenshot-narrow.png",
form_factor: "narrow",
sizes: "2302x1921",
sizes: "600x800",
},
{
src: "/assets/screenshot-wide.png",
form_factor: "wide",
sizes: "2302x1278",
sizes: "1400x800",
},
],
background_color: "#3f4d4e",
@@ -68,7 +68,6 @@ export const hashPlugin = {
buildStart() {
console.log("Running pre-build step...");
try {
// Example: Run a shell command before the build starts
execSync('echo "Running pre-build script" && sh src/generate-build-hash.sh', { stdio: "inherit" });
} catch (error) {
console.error("Pre-build step failed:", error);