add FAQ, font resolution seting some refactoring

AuthorKonata <konata@posteo.jp>
Date
Commit70809ccbcaf124db2fa023830764d7f0e62eb137
Parentca61259
12 files changed, 94 insertions(+), 41 deletions(-)
▾MREADME.md
@@ -36,6 +36,12 @@ docker-compose up # or podman-compose up
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this
- **Multiple users**: Right now everything besides the actual file hosting is stored on the server. When syncing is implemented, it will only be single storage. Permissison systems are also not planned. If distinct permissions/syncs are absouletely required, hosting multiple instances is an option
## FAQ
- What is the "Disable chunked transcoding" option for?
- By default the backend sends the transcoded media as it progresses, which uses "chunked transfer", i.e. without the usual content-length of normal HTTP requests. Some browser (safari cough cough) seem to be very selective about the sources of \<audio\> elements, and choke on sources that are transferred via this method. This option forces the server to transcode the whole file before sending it, improving compatibility with these browser, at the cost of increased latency.
- 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/)
## Attributions
- Font: https://github.com/TakWolf/ark-pixel-font
- Icons: https://github.com/hackernoon/pixel-icon-library
▾Mwebclient/package.json
@@ -17,7 +17,6 @@
"lightningcss": "^1.29.3",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
"sortablejs": "^1.15.6",
"toastify-js": "^1.12.0",
"vite-plugin-pwa": "^1.0.0"
}
▾Awebclient/public/assets/ark-pixel-10px-proportional-latin.woff2
Bin 0 → 56364 bytes@ 70809cc@ master
Binary file — not shown
▾Awebclient/public/assets/ark-pixel-12px-proportional-latin.woff2
Bin 0 → 432568 bytes@ 70809cc@ master
Binary file — not shown
▾Mwebclient/src/App.tsx
@@ -50,6 +50,7 @@ const App: Component = () => {
format: MediaFormat["opus/webm"],
hideVolume: false,
duplicateWarning: true,
fontResolution: 12,
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -71,6 +72,15 @@ const App: Component = () => {
window.location.hash = encodeURI(currentDir());
});
createEffect(() => {
const new_font = new FontFace(
"ark-latin",
`url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
);
document.fonts.clear();
document.fonts.add(new_font);
});
function stopPlaying() {
batch(() => {
setPlayerState("isPlaying", false);
▾Mwebclient/src/components/DownloadButton.tsx
@@ -42,8 +42,14 @@ export function DownloadButton(props: DownloadButtonProps) {
>
{(() => {
const progress = downloadStatusMap[fullPath];
//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)) {
if (props.isOffline()) {
return (
<button title="Purge this file/these files from local sync storage">
<DeleteSVG />
</button>
);
}
if (progress === undefined) {
return (
<button title="Sync this file/directory to your browser for offline use">
<SyncSVG />
@@ -51,14 +57,7 @@ export function DownloadButton(props: DownloadButtonProps) {
);
}
if (progress === "DirectoryProgress") return <LoadingSpinner center={true} />;
if (progress === "SyncedFile") {
if (props.isOffline()) {
return (
<button title="Purge this file/these files from local sync storage">
<DeleteSVG />
</button>
);
}
if (progress === "Synced") {
return (
<button title="Re-sync this file">
<RefreshSVG />
▾Mwebclient/src/components/FileBrowser.css
@@ -108,7 +108,7 @@
display: flex;
justify-content: center;
& > img {
padding: 2.5%;
padding: 0.5rem;
object-fit: contain;
}
}
▾Mwebclient/src/components/OptionsModal.tsx
@@ -116,25 +116,26 @@ export default function OptionsModal(props: OptionsModalProps) {
</select>
</div>
<div>
<label for="max-downloads-input">Max concurrent downloads:</label>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<input
id="max-downloads-input"
id="max-transcodes-input"
type="number"
min={1}
required
value={newOptions.maxDownloads}
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
value={newOptions.maxTranscodes}
disabled={!newOptions.enableTranscoding}
onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="max-transcodes-input">Max concurrent transcodes:</label>
<label for="max-downloads-input">Max concurrent downloads:</label>
<input
id="max-transcodes-input"
id="max-downloads-input"
type="number"
min={1}
required
value={newOptions.maxTranscodes}
onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
value={newOptions.maxDownloads}
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
@@ -155,6 +156,18 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
<div>
<label for="font-resolution-select">Font resolution:</label>
<select
id="font-resolution-select"
value={newOptions.fontResolution}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value))}
>
<option value={10}>10px</option>
<option value={12}>12px</option>
<option value={16}>16px</option>
</select>
</div>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
▾Mwebclient/src/components/Playlist.css
@@ -38,6 +38,8 @@
span {
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
}
&.active {
▾Mwebclient/src/offline.ts
@@ -16,8 +16,11 @@ interface Progress {
bytes: number;
}
export type DownloadStatus = Progress | "DirectoryProgress" | "SyncedFile" | undefined;
export type DownloadStatus = Progress | "DirectoryProgress" | "Synced" | undefined;
//used to display download status for directories/files in the filebrowser view
export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
//initialize filetrees and download statuses from indexedDB
export async function loadDataFromDB() {
try {
const db = await openDB();
@@ -27,7 +30,7 @@ export async function loadDataFromDB() {
let current = "";
for (let index = 0; index < parts.length; index++) {
current = joinPath(current, parts[index]);
setDownloadStatusMap(current, "SyncedFile");
if (index === parts.length - 1) setDownloadStatusMap(current, "Synced");
}
updateFileTree(file.path, { metadata: file.metadata }, true);
}
@@ -37,15 +40,17 @@ export async function loadDataFromDB() {
}
}
//contains all locally synced files
const offlineTreeRoot: Directory = {
files: new Map(),
};
//used for caching online file listings
const onlineTreeRoot: DirectoryWithStatus = {
files: new Map(),
status: "Unknown",
};
//merges "Scanned" directories from "other" to "target"
function mergeTrees(target: DirectoryWithStatus, other: DirectoryWithStatus) {
if (other.status === "Unknown") return;
target.status = other.status;
@@ -61,9 +66,12 @@ function mergeTrees(target: DirectoryWithStatus, other: DirectoryWithStatus) {
//TODO: improve typing to avoid "as"
//overloads for typecheking
//offline tree only stores files, directories are created ad-hoc
function updateFileTree(path: string, file: MediaFile, offline: true): void;
//online tree only stores cached directory listings
function updateFileTree(path: string, file: DirectoryWithStatus, offline: false): void;
function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, offline: boolean): void {
// Updates file tree structure with new data (offline or online)
function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, offline: boolean) {
if (offline && path === "") throw new Error("Trying to update root in offline mode");
const basePath = path.split("/").slice(0, -1).join("/");
const fileName = path.split("/").toReversed()[0];
@@ -89,8 +97,7 @@ function getDirInTree(path: string, create: false, offline: true): Directory | n
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, and optionally create it if it doesn't exist yet
// 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;
if (path === "") return current;
@@ -150,6 +157,7 @@ function listOfflineFiles(dir: string): FileListing {
return node.files;
}
//checks if this Directory is completely scanned, i.e. suitable for a recursive listing result
function fullyKnown(dir: DirectoryWithStatus) {
if (dir.status === "Unknown") {
return false;
@@ -169,6 +177,7 @@ function pruneChildren(listing: FileListing) {
return cloned;
}
//used for combining the online and offline results, to be able to show files that only exist locally
function combineFileListings(listing1: FileListing, listing2: FileListing): FileListing {
const result = new Map(listing1);
@@ -185,6 +194,11 @@ function combineFileListings(listing1: FileListing, listing2: FileListing): File
return result;
}
// File listing logic with hybrid online/offline support
// Combines:
// 1. Direct IndexedDB lookups for offline mode
// 2. Cached online listings
// 3. Merged online+offline results
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
if (offline) {
const files = listOfflineFiles(dir);
@@ -202,6 +216,7 @@ export async function listFiles(dir: string, offline: boolean, recursive: boolea
return recursive ? combined : pruneChildren(combined);
}
//get cover art for a directory with cache-first strategy
export async function getCover(dir: string, offline: boolean) {
const db = await openDB();
const dbResult = await db.get(CoverStore, dir);
@@ -227,6 +242,7 @@ export const clearCoverCache = async (path: string) => {
return db.delete(CoverStore, path);
};
//returns an object url to the blob if synced, otherwise the server url
export async function getSrc(path: string, options: AppOptions, offline: boolean) {
const db = await openDB();
const dbResult = (await db.get(FileStore, path)) as StoredFile;
@@ -247,6 +263,7 @@ export async function loadPlaylists() {
return await db.listPlaylists(PlaylistStore);
}
//save playlist to indexedDB, throws error if playlist with that name already exists and overwrite=false
export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
const db = await openDB();
playlist.items = playlist.items.map((item) => {
@@ -263,6 +280,7 @@ export async function deletePlaylist(name: string) {
await db.delete(PlaylistStore, name);
}
//deletes file or directory(+recursive contents) from indexedDB and updates their download statuses
export async function deleteFile(
fileName: string,
item: MediaFile | Directory,
@@ -270,7 +288,7 @@ export async function deleteFile(
fetchFiles: ResourceActions<FileListing>,
) {
const fullPath = joinPath(basePath, fileName);
if (!downloadStatusMap[fullPath]) return;
if ("metadata" in item && !downloadStatusMap[fullPath]) return;
if (!("metadata" in item)) {
const newBasePath = joinPath(basePath, fileName);
@@ -289,6 +307,8 @@ export async function deleteFile(
const downloadSemaphore = new AsyncSemaphore(1);
//Download file to indexedDB and updates the offline file tree
//concurrency control via the downloadSemaphore
async function downloadFile(
path: string,
currentDir: Accessor<string>,
@@ -304,9 +324,11 @@ async function downloadFile(
const db = await openDB();
let mimeType: string | undefined;
//options might change at any point, update the semaphore to the current value
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
let blob: Blob;
try {
//make sure to only allow the set amount of parallel downloads
await downloadSemaphore.acquire();
let res: Response;
if (options.enableTranscoding) {
@@ -330,6 +352,7 @@ async function downloadFile(
if (done) break;
received += value.length;
//debouncing logic to not update UI too fast
const now = Date.now();
if (now - lastUpdate < 250) continue;
lastUpdate = now;
@@ -346,10 +369,12 @@ async function downloadFile(
metadata: item.metadata,
} as StoredFile);
updateFileTree(path, item, true);
setDownloadStatusMap(path, "SyncedFile");
setDownloadStatusMap(path, "Synced");
const basePath = path.split("/").slice(0, -1).join("/");
const fileName = path.split("/").toReversed()[0];
//add files to listing if the containing directory is currently shown
if (currentDir() === basePath) {
//directly mutate files to not cause unnecessary rerenders
fetchFiles.mutate((prev) => {
prev.set(fileName, item);
//new map to force rerender
@@ -358,7 +383,8 @@ async function downloadFile(
}
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
setDownloadStatusMap(path, initialProgress === "SyncedFile" ? "SyncedFile" : undefined);
//restore original status
setDownloadStatusMap(path, initialProgress === "Synced" ? "Synced" : undefined);
}
}
@@ -374,24 +400,24 @@ export async function download(
if ("metadata" in item) {
await downloadFile(fullPath, currentDir, options, item, fetchFiles);
} else {
const initialProgress = downloadStatusMap[fullPath];
setDownloadStatusMap(fullPath, "DirectoryProgress");
await getCover(fullPath, false);
try {
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
for (const [childName, childItem] of await listFiles(fullPath, false, false)) {
promises.push(download(childName, childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
setDownloadStatusMap(fullPath, "SyncedFile");
} catch {
setDownloadStatusMap(fullPath, initialProgress === "SyncedFile" ? "SyncedFile" : undefined);
} finally {
//always set to undefined afterwards to show the normal sync icon, since we don't know for sure if the directory is completely synced
setDownloadStatusMap(fullPath, undefined);
}
}
}
export async function isFileSynced(path: string, status: DownloadStatus | undefined): Promise<boolean> {
if (status === "SyncedFile") return true;
if (status === "Synced") 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();
@@ -413,7 +439,6 @@ interface StoredCover {
data: Blob | null;
}
//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 FileStore = "fileblobs";
const CoverStore = "covers";
const PlaylistStore = "playlists";
▾Mwebclient/src/style.css
@@ -6,12 +6,6 @@
accent-color: var(--color-d);
}
@font-face {
font-display: swap;
font-family: ark-latin;
src: url("ark-pixel-16px-proportional-latin.woff2") format("woff2");
}
html,
button,
select {
@@ -53,6 +47,10 @@ select:disabled {
}
}
input:disabled {
color: oklch(0.5 0 0);
}
button {
cursor: pointer;
&:disabled {
▾Mwebclient/src/types.ts
@@ -36,4 +36,5 @@ export interface AppOptions {
rememberDir: boolean;
hideVolume: boolean;
duplicateWarning: boolean;
fontResolution: number;
}