css improvements, merge metadata and blob tables, change options and playerstate signals to stores

AuthorKonata <konata@posteo.jp>
Date
Commit2d42d2c13c932d0e80adb8fe749c1365ca2a6a0b
Parent1bfb72c
13 files changed, 274 insertions(+), 320 deletions(-)
▾Mserver/src/index.ts
@@ -36,7 +36,7 @@ const { values } = parseArgs({
});
if (!values.serve) {
console.error("Please define the serve option");
console.error("Missing --serve, set it to the directory containing the frontend code");
process.exit(1);
}
▾Mserver/src/shared.ts
@@ -1,5 +1,5 @@
import path from "node:path";
import type { FfprobeData } from "./utils";
import type { ProbeData } from "./utils";
import commandExists from "command-exists";
export const coverRegex = process.env.COVER_REGEX || /(C|c)over.(png|jpg)$/;
@@ -31,7 +31,7 @@ export const mediaTypes = ["audio", "video"];
export const allowedTypes = [...mediaTypes, "image"];
export const fileTypeCache = new Map<string, string>();
export const probeCache = new Map<string, FfprobeData>();
export const probeCache = new Map<string, ProbeData>();
export const generatedPlaylistIds = new Map<string, string[]>();
export class PathInfo {
▾Mserver/src/utils.ts
@@ -3,7 +3,7 @@ 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, ServerError } from "./shared";
import { coverRegex, 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>) {
@@ -43,14 +43,18 @@ export async function findCover(dirPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
export type FfprobeData = {
export type ProbeData = {
duration: number;
bitrate?: number;
};
export async function probeFile(filePath: string): Promise<FfprobeData> {
export async function probeFile(filePath: string): Promise<ProbeData> {
const cached = probeCache.get(filePath);
if (cached) return cached;
const parsed = await parseFile(filePath, { duration: true, skipCovers: true });
return { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate };
const probeData = { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate };
probeCache.set(filePath, probeData);
return probeData;
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
▾Mwebclient/src/App.tsx
@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, on, createResource } from "solid-js";
import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js";
import type { Component } from "solid-js";
import type { PlaylistItem, PlayerState, AppOptions } from "./types";
import OptionsModal from "./components/OptionsModal";
@@ -11,10 +11,12 @@ import { getSrc, listFiles } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore } from "solid-js/store";
//TODO: use more store
//TODO: show offline-only files in online mode as well
//TODO: add loading state to playercontrols
//TODO: search
//TODO: delete entries in downloadMap instead of setting status to undefined
//TODO: merge fileblobs and filemetadata
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
@@ -30,7 +32,7 @@ const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
const [playerState, setPlayerState] = createSignal<PlayerState>({
const [playerState, setPlayerState] = createStore<PlayerState>({
isPlaying: false,
currentTrack: null,
currentTime: 0,
@@ -40,7 +42,7 @@ const App: Component = () => {
});
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [options, setOptions] = createSignal<AppOptions>({
const [options, setOptions] = createStore<AppOptions>({
bitrate: 128000,
enableTranscoding: true,
disableChunkedTranscoding: false,
@@ -56,7 +58,6 @@ const App: Component = () => {
const [isRepeated, setRepeated] = createSignal(false);
const [isOffline, setOffline] = createSignal(true);
const [files, fetchFiles] = createResource(
//TODO: try using store
() => ({ dir: currentDir(), offline: isOffline() }),
async (args) => {
//new map to force rerender
@@ -73,13 +74,13 @@ const App: Component = () => {
});
function stopPlaying() {
setPlayerState({
...playerState(),
currentTrack: null,
isPlaying: false,
currentTime: 0,
batch(() => {
setPlayerState("isPlaying", false);
setPlayerState("currentTime", 0);
setPlayerState("currentTrack", null);
});
audioElement.pause();
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
audioElement.srcObject = null;
}
@@ -108,7 +109,7 @@ const App: Component = () => {
// Play a track from the playlist
async function playTrack(track: PlaylistItem) {
try {
const src = await getSrc(track.path, options(), isOffline());
const src = await getSrc(track.path, options, isOffline());
if (!src) return;
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
@@ -118,29 +119,28 @@ const App: Component = () => {
audioElement.removeAttribute("src");
audioElement.srcObject = null;
audioElement.src = src;
setPlayerState({
...playerState(),
currentTrack: track,
isPlaying: true,
currentTime: 0,
batch(() => {
setPlayerState("isPlaying", true);
setPlayerState("currentTime", 0);
setPlayerState("buffered", 0);
//clone, otherwise it shares the reference
setPlayerState("currentTrack", { ...track });
});
await audioElement.play();
} catch (e) {
setPlayerState({
...playerState(),
isPlaying: false,
currentTime: 0,
});
setPlayerState("isPlaying", false);
handlePlayerError(e);
}
}
// Play next track
function playNext() {
const currentTrack = playerState().currentTrack;
const currentTrack = playerState.currentTrack;
if (!currentTrack) return;
let nextTrack: PlaylistItem | null = null;
const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
if (isShuffled()) {
// Pick a different random track
@@ -148,14 +148,13 @@ const App: Component = () => {
const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
} else {
nextTrack = currentTrack;
nextTrack = isRepeated() ? currentTrack : null;
}
} else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
nextTrack = playlist()[currentIndex + 1];
} else if (isRepeated()) {
nextTrack = playlist()[0];
}
if (nextTrack) {
playTrack(nextTrack);
} else {
@@ -165,25 +164,22 @@ const App: Component = () => {
// Play previous track
function playPrevious() {
const currentTrack = playerState().currentTrack;
const currentTrack = playerState.currentTrack;
if (!currentTrack) return;
const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
const track = playlist().find((item) => item.id === currentTrack.id);
if (track) playTrack(track);
}
// Toggle play/pause
function togglePlay() {
if (playerState().currentTrack === null) return;
const newIsPlaying = !playerState().isPlaying;
setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
if (playerState.currentTrack === null) return;
const newIsPlaying = !playerState.isPlaying;
setPlayerState("isPlaying", newIsPlaying);
if (newIsPlaying) {
audioElement.play().catch((e) => {
setPlayerState({
...playerState(),
isPlaying: false,
currentTime: 0,
});
setPlayerState("isPlaying", false);
setPlayerState("currentTime", 0);
handlePlayerError(e);
});
} else {
@@ -193,11 +189,11 @@ const App: Component = () => {
// Update progress when the time updates
function updateProgress() {
setPlayerState({
...playerState(),
currentTime: audioElement.currentTime,
buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
});
setPlayerState("currentTime", audioElement.currentTime);
setPlayerState(
"buffered",
audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
);
}
// Initialize app
@@ -206,26 +202,24 @@ const App: Component = () => {
try {
const savedOptions = localStorage.getItem(LocalStorageValues.options);
if (savedOptions) {
setOptions({ ...options(), ...JSON.parse(savedOptions) });
setOptions({ ...options, ...JSON.parse(savedOptions) });
}
const savedVolume = localStorage.getItem(LocalStorageValues.volume);
if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) {
setPlayerState({ ...playerState(), volume: Number.parseFloat(savedVolume) });
const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
if (savedVolume && !Number.isNaN(savedVolume)) {
setPlayerState("volume", savedVolume);
}
const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
if (savedPlaylist) {
setPlaylist(JSON.parse(savedPlaylist));
}
const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
if (savedShuffle !== null) {
setShuffled(savedShuffle === "true");
}
setShuffled(savedShuffle === "true");
const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
if (savedRepeat !== null) {
setRepeated(savedRepeat === "true");
}
setRepeated(savedRepeat === "true");
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
setOffline(savedOffline === "true");
const savedPath = localStorage.getItem(LocalStorageValues.path);
if (options().rememberDir && savedPath !== null && !window.location.hash) {
if (options.rememberDir && savedPath !== null && !window.location.hash) {
setCurrentDir(savedPath);
}
} catch (e) {
@@ -233,38 +227,23 @@ const App: Component = () => {
localStorage.clear();
}
// Sync to localStorage
createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options())));
createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState().volume.toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
// Set initial audio element volume
audioElement.volume = playerState().volume;
audioElement.volume = playerState.volume;
// Set up audio element event handlers
audioElement.addEventListener("ended", playNext);
audioElement.addEventListener("timeupdate", updateProgress);
audioElement.addEventListener("durationchange", updateProgress);
audioElement.addEventListener("pause", () =>
setPlayerState({
...playerState(),
isPlaying: false,
}),
);
audioElement.addEventListener("play", () =>
setPlayerState({
...playerState(),
isPlaying: true,
}),
);
audioElement.addEventListener("volumechange", () =>
setPlayerState({
...playerState(),
volume: audioElement.volume,
}),
);
audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
window.addEventListener("hashchange", () => {
setCurrentDir(decodeURI(window.location.hash.slice(1)));
@@ -276,41 +255,53 @@ const App: Component = () => {
});
createEffect(() => {
audioElement.volume = playerState().volume;
audioElement.playbackRate = playerState().rate;
audioElement.volume = playerState.volume;
audioElement.playbackRate = playerState.rate;
});
return (
<div class="app-container">
<div class="header">
<div class="app-header">
<div style={{ "min-width": 0 }}>
<div class="title">Pico Pixel Player</div>
<sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
</div>
<div class="spacer" />
<button
title="Toggle File Browser"
class="filebrowser-toggle"
onClick={() => setShowFileBrowser((prev) => !prev)}
>
{showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
</button>
<button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
<PlaylistSVG />
</button>
<button
title="Toggle Fullscreen"
onClick={() =>
document.fullscreenElement
? document.exitFullscreen()
: document.documentElement.requestFullscreen({ navigationUI: "hide" })
}
>
<FullscreenSVG />
</button>
<button title="Settings" onClick={() => setShowOptions(true)}>
<CogSVG />
</button>
<div class="app-buttons">
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: isOffline() ? "lightgreen" : "indianred" }}
textContent={isOffline() ? "Go online" : "Go offline"}
onclick={() => setOffline(!isOffline())}
/>
<div>
<button
title="Toggle File Browser"
class="filebrowser-toggle"
onClick={() => setShowFileBrowser((prev) => !prev)}
>
{showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
</button>
<button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
<PlaylistSVG />
</button>
<button
title="Toggle Fullscreen"
onClick={() =>
document.fullscreenElement
? document.exitFullscreen()
: document.documentElement.requestFullscreen({ navigationUI: "hide" })
}
>
<FullscreenSVG />
</button>
<button title="Settings" onClick={() => setShowOptions(true)}>
<CogSVG />
</button>
</div>
</div>
</div>
<div class="main-content">
@@ -325,7 +316,6 @@ const App: Component = () => {
setShowFileBrowser={setShowFileBrowser}
options={options}
isOffline={isOffline}
setOffline={setOffline}
files={files}
fetchFiles={fetchFiles}
/>
@@ -337,6 +327,7 @@ const App: Component = () => {
playerState={playerState}
setPlayerState={setPlayerState}
playTrack={playTrack}
playNext={playNext}
audioElement={audioElement}
options={options}
fetchFiles={fetchFiles}
▾Mwebclient/src/components/DownloadButton.tsx
@@ -1,6 +1,6 @@
import "./DownloadButton.css";
import type { MediaFile, Directory, FileListing } from "music-server-shared/types";
import { deleteFile, download, downloadStatusMap, type DownloadStatus } from "../offline";
import { deleteFile, download, downloadStatusMap } from "../offline";
import type { Accessor, ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { joinPath } from "../utils";
@@ -13,7 +13,7 @@ interface DownloadButtonProps {
fetchFiles: ResourceActions<FileListing>;
isOffline: Accessor<boolean>;
currentDir: Accessor<string>;
options: Accessor<AppOptions>;
options: AppOptions;
}
function formatProgress(bytes: number): string {
@@ -36,21 +36,14 @@ export function DownloadButton(props: DownloadButtonProps) {
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline())
download(props.fileName, props.item, props.currentDir(), props.currentDir, props.options(), props.fetchFiles);
download(props.fileName, props.item, props.currentDir(), props.currentDir, props.options, props.fetchFiles);
else deleteFile(props.fileName, props.item, props.currentDir(), props.fetchFiles);
}}
>
{(() => {
const progress = downloadStatusMap[fullPath];
if (progress === undefined) {
if (props.isOffline()) {
//only happens if this is a directory
return (
<button title="Purge the sync storage from all files recursively in this directory">
<DeleteSVG />
</button>
);
}
//show sync icon for synced directories as well, since we don't know for sure if everything is downloaded in them
if (progress === undefined || (progress === "SyncedFile" && !props.isOffline() && "files" in props.item)) {
return (
<button title="Sync this file/directory to your browser for offline use">
<SyncSVG />
@@ -61,7 +54,7 @@ export function DownloadButton(props: DownloadButtonProps) {
if (progress === "SyncedFile") {
if (props.isOffline()) {
return (
<button title="Purge this file from local sync storage">
<button title="Purge this file/these files from local sync storage">
<DeleteSVG />
</button>
);
▾Mwebclient/src/components/FileBrowser.css
@@ -21,6 +21,7 @@
.browser-actions {
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--color-c);
padding: 0.5rem;
@@ -38,12 +39,6 @@
& > .refresh-button {
background: none;
}
& > .offline-button {
text-wrap: nowrap;
width: min-content;
margin-left: 1rem;
}
}
.file-list-wrapper {
▾Mwebclient/src/components/FileBrowser.tsx
@@ -30,9 +30,8 @@ interface FileBrowserProps {
setPlaylist: Setter<PlaylistItem[]>;
showFileBrowser: Accessor<boolean>;
setShowFileBrowser: Setter<boolean>;
options: Accessor<AppOptions>;
options: AppOptions;
isOffline: Accessor<boolean>;
setOffline: Setter<boolean>;
files: InitializedResource<FileListing>;
fetchFiles: ResourceActions<FileListing>;
}
@@ -111,13 +110,13 @@ export default function FileBrowser(props: FileBrowserProps) {
oldLength = oldPlaylist.length;
}
}
if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
const addAction = () => {
if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
props.setPlaylist([...oldPlaylist, ...newPlaylist]);
if (queue) scrollPlaylistToBottom();
};
if (
props.options().duplicateWarning &&
props.options.duplicateWarning &&
oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
) {
addAnywayButton.onclick = () => {
@@ -173,14 +172,6 @@ export default function FileBrowser(props: FileBrowserProps) {
);
onMount(() => {
try {
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
props.setOffline(savedOffline === "true");
} catch (e) {
toast("loading values from localStorage failed", "error", e);
localStorage.clear();
}
filelistElement.addEventListener("scrollend", () => {
scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
});
@@ -243,13 +234,6 @@ export default function FileBrowser(props: FileBrowserProps) {
<RefreshSVG />
</button>
</Show>
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
textContent={props.isOffline() ? "Go online" : "Go offline"}
onclick={() => props.setOffline(!props.isOffline())}
/>
</div>
<div class="file-list-wrapper">
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,20 +1,22 @@
import { type Accessor, createEffect, createSignal, type Setter, on } from "solid-js";
import { type Accessor, createEffect, type Setter, on } from "solid-js";
import type { AppOptions } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
import { MediaFormat } from "music-server-shared/types";
import { createStore } from "solid-js/store";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
setShowOptions: Setter<boolean>;
options: Accessor<AppOptions>;
options: AppOptions;
setOptions: (options: AppOptions) => void;
}
export default function OptionsModal(props: OptionsModalProps) {
const [newOptions, setNewOptions] = createSignal<AppOptions>(props.options());
//clone, otherwise it shares the reference
const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
@@ -22,7 +24,7 @@ export default function OptionsModal(props: OptionsModalProps) {
on(props.showOptions, () => {
if (props.showOptions()) {
dialogElement.showModal();
setNewOptions({ ...props.options() });
setNewOptions(props.options);
}
}),
);
@@ -30,7 +32,7 @@ export default function OptionsModal(props: OptionsModalProps) {
function handleSave() {
if (!formElement.reportValidity()) return;
toast("Saved changes", "success");
props.setOptions(newOptions());
props.setOptions(newOptions);
handleClose();
}
@@ -58,8 +60,8 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="remember-dir-checkbox"
type="checkbox"
checked={newOptions()?.rememberDir}
onChange={(e) => setNewOptions({ ...newOptions(), rememberDir: e.target.checked })}
checked={newOptions.rememberDir}
onChange={(e) => setNewOptions("rememberDir", e.target.checked)}
/>
</div>
<div>
@@ -67,8 +69,8 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="transcoding-checkbox"
type="checkbox"
checked={newOptions()?.enableTranscoding}
onChange={(e) => setNewOptions({ ...newOptions(), enableTranscoding: e.target.checked })}
checked={newOptions.enableTranscoding}
onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
/>
</div>
<div>
@@ -76,23 +78,18 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="transcoding-buffer-checkbox"
type="checkbox"
checked={newOptions()?.disableChunkedTranscoding}
onChange={(e) => setNewOptions({ ...newOptions(), disableChunkedTranscoding: e.target.checked })}
disabled={!newOptions()?.enableTranscoding}
checked={newOptions.disableChunkedTranscoding}
onChange={(e) => setNewOptions("disableChunkedTranscoding", e.target.checked)}
disabled={!newOptions.enableTranscoding}
/>
</div>
<div>
<label for="bitrate-select">Bitrate:</label>
<select
id="bitrate-select"
value={newOptions()?.bitrate}
onChange={(e) =>
setNewOptions({
...newOptions(),
bitrate: Number.parseInt(e.target.value),
})
}
disabled={!newOptions()?.enableTranscoding}
value={newOptions.bitrate}
onChange={(e) => setNewOptions("bitrate", Number.parseInt(e.target.value))}
disabled={!newOptions.enableTranscoding}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
@@ -107,14 +104,9 @@ export default function OptionsModal(props: OptionsModalProps) {
<label for="codec-select">Format:</label>
<select
id="codec-select"
value={newOptions()?.format}
onChange={(e) =>
setNewOptions({
...newOptions(),
format: e.target.value as MediaFormat,
})
}
disabled={!newOptions()?.enableTranscoding}
value={newOptions.format}
onChange={(e) => setNewOptions("format", e.target.value as MediaFormat)}
disabled={!newOptions.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
@@ -130,8 +122,8 @@ export default function OptionsModal(props: OptionsModalProps) {
type="number"
min={1}
required
value={newOptions()?.maxDownloads}
onChange={(e) => setNewOptions({ ...newOptions(), maxDownloads: e.target.valueAsNumber })}
value={newOptions.maxDownloads}
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
@@ -141,8 +133,8 @@ export default function OptionsModal(props: OptionsModalProps) {
type="number"
min={1}
required
value={newOptions()?.maxTranscodes}
onChange={(e) => setNewOptions({ ...newOptions(), maxTranscodes: e.target.valueAsNumber })}
value={newOptions.maxTranscodes}
onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
/>
</div>
<div>
@@ -150,8 +142,8 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="hide-volume-checkbox"
type="checkbox"
checked={newOptions()?.hideVolume}
onChange={(e) => setNewOptions({ ...newOptions(), hideVolume: e.target.checked })}
checked={newOptions.hideVolume}
onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
/>
</div>
<div>
@@ -159,8 +151,8 @@ export default function OptionsModal(props: OptionsModalProps) {
<input
id="duplicate-checkbox"
type="checkbox"
checked={newOptions()?.duplicateWarning}
onChange={(e) => setNewOptions({ ...newOptions(), duplicateWarning: e.target.checked })}
checked={newOptions.duplicateWarning}
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
<div class="option-buttons">
▾Mwebclient/src/components/PlayerControls.tsx
@@ -1,12 +1,13 @@
import "./PlayerControls.css";
import { type AppOptions, getItemName, type PlayerState } from "../types";
import { type Accessor, createEffect, createSignal, type Setter, Show } from "solid-js";
import { type Accessor, createEffect, createSignal, type Setter } from "solid-js";
import { NextTrackSVG, PauseSVG, PlaySVG, PreviousTrackSVG, RepeatSVG, ShuffleSVG, VolumeSVG } from "./SVG";
import { formatTime } from "../utils";
import type { SetStoreFunction } from "solid-js/store";
interface PlayerControlsProps {
playerState: Accessor<PlayerState>;
setPlayerState: Setter<PlayerState>;
playerState: PlayerState;
setPlayerState: SetStoreFunction<PlayerState>;
togglePlay: () => void;
playPrevious: () => void;
playNext: () => void;
@@ -14,25 +15,20 @@ interface PlayerControlsProps {
setShuffled: Setter<boolean>;
isRepeated: Accessor<boolean>;
setRepeated: Setter<boolean>;
options: Accessor<AppOptions>;
options: AppOptions;
audioElement: HTMLAudioElement;
}
export default function PlayerControls(props: PlayerControlsProps) {
const [controlsDisabled, setControlsDisabled] = createSignal(false);
createEffect(() => {
setControlsDisabled(!props.playerState().currentTrack);
setControlsDisabled(!props.playerState.currentTrack);
});
// Set volume
function setVolume(e: Event) {
const input = e.target as HTMLInputElement;
const volume = Number.parseFloat(input.value);
props.setPlayerState({
...props.playerState(),
volume,
});
props.setPlayerState("volume", volume);
if (props.audioElement) {
props.audioElement.volume = volume;
@@ -41,7 +37,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
// Seek to position
function seekTo(e: MouseEvent) {
const playerState = props.playerState();
const playerState = props.playerState;
if (!props.audioElement || !playerState.currentTrack) return;
const progressBar = e.currentTarget as HTMLDivElement;
@@ -50,26 +46,23 @@ export default function PlayerControls(props: PlayerControlsProps) {
const duration = playerState.currentTrack.metadata.duration;
props.audioElement.currentTime = Math.min(pos * duration, playerState.buffered - 1);
props.setPlayerState({
...playerState,
currentTime: pos * duration,
});
props.setPlayerState("currentTime", pos * duration)
}
return (
<div class="player-controls">
<div class="track-info">{getItemName(props.playerState().currentTrack) || "No track selected"}</div>
<div class="track-info">{getItemName(props.playerState.currentTrack) || "No track selected"}</div>
<div class="progress-container">
<div class="progress-bar" onClick={seekTo} style={controlsDisabled() ? { cursor: "default" } : {}}>
<div
class="buffered-fill"
style={{
width: `${
props.playerState().currentTrack
props.playerState.currentTrack
? Math.min(
100,
(props.playerState().buffered /
(props.playerState().currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
(props.playerState.buffered /
(props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
@@ -80,11 +73,11 @@ export default function PlayerControls(props: PlayerControlsProps) {
class="progress-fill"
style={{
width: `${
props.playerState().currentTrack
props.playerState.currentTrack
? Math.min(
100,
(props.playerState().currentTime /
(props.playerState().currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
(props.playerState.currentTime /
(props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
@@ -94,8 +87,8 @@ export default function PlayerControls(props: PlayerControlsProps) {
</div>
</div>
<div class="time-container">
<span>{formatTime(props.playerState().currentTime)}</span>/
<span>{formatTime(props.playerState().currentTrack?.metadata.duration || 0)}</span>
<span>{formatTime(props.playerState.currentTime)}</span>/
<span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
</div>
<div class="control-buttons">
@@ -103,7 +96,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
<PreviousTrackSVG disabled={controlsDisabled} />
</button>
<button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
{props.playerState().isPlaying ? (
{props.playerState.isPlaying ? (
<PauseSVG disabled={controlsDisabled} />
) : (
<PlaySVG disabled={controlsDisabled} />
@@ -121,14 +114,14 @@ export default function PlayerControls(props: PlayerControlsProps) {
<ShuffleSVG disabled={() => !props.isShuffled()} />
</button>
<div class="volume-control" style={props.options().hideVolume ? { display: "none" } : {}}>
<div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
<VolumeSVG />
<input type="range" min="0" max="1" step="0.01" value={props.playerState().volume} onInput={setVolume} />
<input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
</div>
<div>
<select
value={props.playerState().rate}
onchange={(e) => props.setPlayerState({ ...props.playerState(), rate: Number.parseFloat(e.target.value) })}
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>
▾Mwebclient/src/components/Playlist.css
@@ -71,18 +71,18 @@
padding: 0.5rem;
font-weight: bold;
border-bottom: 1px solid var(--color-c);
flex-wrap: wrap;
span {
text-overflow: ellipsis;
text-wrap-mode: nowrap;
overflow: hidden;
}
button {
background: none;
padding: 0;
&:hover {
& > .button-container {
display: flex;
gap: inherit;
flex-direction: row;
& > button {
background: none;
padding: 0;
&:hover {
background: none;
}
}
}
}
▾Mwebclient/src/components/Playlist.tsx
@@ -3,7 +3,7 @@ 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 { savePlaylist, isFileCached, download, downloadStatusMap } from "../offline";
import { savePlaylist, isFileSynced, download, downloadStatusMap } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FileListing } from "music-server-shared/types";
@@ -11,24 +11,19 @@ interface PlaylistProps {
currentDir: Accessor<string>;
playlist: Accessor<PlaylistItem[]>;
setPlaylist: Setter<PlaylistItem[]>;
playerState: Accessor<PlayerState>;
playerState: PlayerState;
setPlayerState: Setter<PlayerState>;
playTrack: (item: PlaylistItem) => void;
playNext: () => void;
audioElement: HTMLAudioElement;
options: Accessor<AppOptions>;
options: AppOptions;
fetchFiles: ResourceActions<FileListing>;
}
export default function Playlist(props: PlaylistProps) {
function removeFromPlaylist(id: string) {
const currentTrack = props.playerState().currentTrack;
props.playNext();
const newPlaylist = props.playlist().filter((i) => i.id !== id);
if (currentTrack && currentTrack.id === id) {
const idx = props.playlist().findIndex((i) => i.id === id);
if (newPlaylist.length > 0) {
props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]);
}
}
props.setPlaylist(newPlaylist);
}
@@ -64,12 +59,12 @@ export default function Playlist(props: PlaylistProps) {
try {
const itemsToSync: PlaylistItem[] = [];
for (const item of props.playlist()) {
if (!(await isFileCached(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
if (!(await isFileSynced(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
}
const syncPromises = itemsToSync.map((item) => {
const basePath = item.path.split("/").slice(0, -1).join("/");
const fileName = item.path.split("/").toReversed()[0];
return download(fileName, item, basePath, props.currentDir, props.options(), props.fetchFiles);
return download(fileName, item, basePath, props.currentDir, props.options, props.fetchFiles);
});
await Promise.allSettled(syncPromises);
progressToast.hideToast();
@@ -113,7 +108,7 @@ export default function Playlist(props: PlaylistProps) {
const promises = [];
for (const item of playlist) {
promises.push(
isFileCached(item.path, downloadStatusMap[item.path]).then((res) => {
isFileSynced(item.path, downloadStatusMap[item.path]).then((res) => {
if (res) count++;
}),
);
@@ -140,33 +135,35 @@ export default function Playlist(props: PlaylistProps) {
Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
</span>
<div class="spacer" />
<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 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>
</div>
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist} id="playlist">
{(item, _, { dragHandlers, isDragging, dropPosition }) => {
const isActive = createSelector(
() => props.playerState().currentTrack?.id,
() => props.playerState.currentTrack?.id,
(itemId, currentId) => currentId === itemId,
);
return (
▾Mwebclient/src/offline.ts
@@ -8,7 +8,7 @@ import type {
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist, PlaylistItem } from "./types";
import { joinPath, toast } from "./utils";
import { type Accessor, createSignal, type ResourceActions, type Signal } from "solid-js";
import type { Accessor, ResourceActions } from "solid-js";
import { createStore } from "solid-js/store";
import { AsyncSemaphore } from "./semaphore";
@@ -16,16 +16,20 @@ interface Progress {
bytes: number;
}
export type DownloadStatus = undefined | Progress | "DirectoryProgress" | "SyncedFile";
export type DownloadStatus = Progress | "DirectoryProgress" | "SyncedFile" | undefined;
export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
export async function loadDataFromDB() {
try {
const db = await openDB();
const files = await db.listFiles(FileMetadataStore);
const files = await db.listFiles(FileStore);
for (const file of files) {
setDownloadStatusMap(file.path, "SyncedFile");
updateFileTree(file.path, { metadata: { duration: file.duration } }, true);
const parts = file.path.split("/");
let current = "";
for (let index = 0; index < parts.length; index++) {
current = joinPath(current, parts[index]);
setDownloadStatusMap(current, "SyncedFile");
}
updateFileTree(file.path, { metadata: file.metadata }, true);
}
} catch (e) {
toast("Loading offline files failed", "error", e);
@@ -207,7 +211,7 @@ export const clearCoverCache = async (path: string) => {
export async function getSrc(path: string, options: AppOptions, offline: boolean) {
const db = await openDB();
const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
const dbResult = (await db.get(FileStore, path)) as StoredFile;
if (dbResult) return URL.createObjectURL(dbResult.data);
if (offline) {
@@ -250,8 +254,7 @@ export async function deleteFile(
const fullPath = joinPath(basePath, fileName);
if (!downloadStatusMap[fullPath]) return;
//TODO: can techinally remove the recursion, but need to update the signals
if (!("duration" in item)) {
if (!("metadata" in item)) {
const newBasePath = joinPath(basePath, fileName);
const result = listOfflineFiles(newBasePath);
for (const [childName, childItem] of result) {
@@ -261,8 +264,7 @@ export async function deleteFile(
deleteOfflinePath(fullPath);
const db = await openDB();
await db.delete(FileBlobStore, fullPath);
await db.delete(FileMetadataStore, fullPath);
await db.delete(FileStore, fullPath);
setDownloadStatusMap(fullPath, undefined);
fetchFiles.refetch();
}
@@ -320,14 +322,11 @@ async function downloadFile(
downloadSemaphore.release();
}
await db.put(FileBlobStore, {
await db.put(FileStore, {
path,
data: blob,
} as StoredFileBlob);
await db.put(FileMetadataStore, {
path,
duration: item.metadata.duration,
} as StoredFileMetadata);
metadata: item.metadata,
} as StoredFile);
updateFileTree(path, item, true);
setDownloadStatusMap(path, "SyncedFile");
const basePath = path.split("/").slice(0, -1).join("/");
@@ -366,20 +365,29 @@ export async function download(
promises.push(download(childName, childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
} finally {
setDownloadStatusMap(fullPath, "SyncedFile");
} catch {
setDownloadStatusMap(fullPath, undefined);
}
}
}
interface StoredFileBlob {
path: string;
data: Blob;
export async function isFileSynced(path: string, status: DownloadStatus | undefined): Promise<boolean> {
if (status === "SyncedFile") return true;
//if in progress, it might already exist anyway and just is being re-synced right now
if (typeof status === "object" && "bytes" in status) {
const db = await openDB();
return await db.exist(FileStore, path);
}
return false;
}
interface StoredFileMetadata {
interface StoredFile {
path: string;
duration: number;
data: Blob;
metadata: {
duration: number;
};
}
interface StoredCover {
@@ -388,8 +396,7 @@ interface StoredCover {
}
//separate blobs and metadata, so we don't have to load all file contents at startup, which would consume huge amounts of ram, and be very slow
const FileBlobStore = "fileblobs";
const FileMetadataStore = "filemetadata";
const FileStore = "fileblobs";
const CoverStore = "covers";
const PlaylistStore = "playlists";
@@ -399,9 +406,7 @@ async function openDB() {
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(FileBlobStore)) db.createObjectStore(FileBlobStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(FileMetadataStore))
db.createObjectStore(FileMetadataStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(FileStore)) db.createObjectStore(FileStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(CoverStore)) db.createObjectStore(CoverStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(PlaylistStore)) db.createObjectStore(PlaylistStore, { keyPath: "name" });
};
@@ -410,20 +415,16 @@ async function openDB() {
}
interface DatabaseWrapper {
get(store: typeof CoverStore | typeof FileBlobStore, key: string): Promise<unknown>;
put(store: typeof FileBlobStore, value: StoredFileBlob): Promise<void>;
put(store: typeof FileMetadataStore, value: StoredFileMetadata): Promise<void>;
get(store: typeof CoverStore | typeof FileStore, key: string): Promise<unknown>;
put(store: typeof FileStore, value: StoredFile): Promise<void>;
put(store: typeof CoverStore, value: StoredCover): Promise<void>;
put(store: typeof PlaylistStore, value: Playlist): Promise<void>;
add(store: typeof PlaylistStore, value: Playlist): Promise<void>;
delete(
store: typeof FileBlobStore | typeof CoverStore | typeof FileMetadataStore | typeof PlaylistStore,
key: string,
): Promise<void>;
delete(store: typeof FileStore | typeof CoverStore | typeof PlaylistStore, key: string): Promise<void>;
clear(store: string): Promise<void>;
listFiles(store: typeof FileMetadataStore): Promise<StoredFileMetadata[]>;
listFiles(store: typeof FileStore): Promise<StoredFile[]>;
listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
exist(store: typeof FileMetadataStore, key: string): Promise<boolean>;
exist(store: typeof FileStore, key: string): Promise<boolean>;
}
function wrapDB(db: IDBDatabase): DatabaseWrapper {
@@ -463,7 +464,7 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
listFiles: (store: typeof FileMetadataStore) =>
listFiles: (store: typeof FileStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).getAll();
@@ -477,7 +478,7 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
exist: (store: typeof FileMetadataStore, key: string) =>
exist: (store: typeof FileStore, key: string) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).count(key);
@@ -486,13 +487,3 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
}),
};
}
export async function isFileCached(path: string, status: DownloadStatus): Promise<boolean> {
if (status === "SyncedFile") return true;
//if in progress, it might already exist anyway and just is being re-synced right now
if (typeof status === "object" && "bytes" in status) {
const db = await openDB();
return await db.exist(FileMetadataStore, path);
}
return false;
}
▾Mwebclient/src/style.css
@@ -92,15 +92,36 @@ body,
margin: 0 auto;
background-color: var(--color-a);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
}
.header {
display: flex;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-c);
& > .app-header {
display: flex;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-c);
& > .app-buttons {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: end;
& > .offline-button {
text-wrap: nowrap;
width: min-content;
}
& > div {
display: flex;
gap: 1.5rem;
}
}
}
& > .main-content {
position: relative; /* for the loading screen in the file browser*/
display: flex;
flex: 1;
overflow: hidden;
}
}
.title {
@@ -108,13 +129,6 @@ body,
font-weight: bold;
}
.main-content {
position: relative; /* for the loading screen in the file browser*/
display: flex;
flex: 1;
overflow: hidden;
}
@media (min-width: 601px) {
.filebrowser-toggle {
display: none;