storage panel, small fixes

AuthorKonata <konata@posteo.jp>
Date
Commitc21d4c35850640a06ee13e26b22bc30d5bb646aa
Parenta809b18
8 files changed, 411 insertions(+), 35 deletions(-)
▾Mwebclient/index.html
@@ -55,12 +55,13 @@
<noscript>Pico Pixel Player requires javascript to be enabled.</noscript>
<div id="root">
<div id="inital-loading-screen">
<span>
Loading required assets and local caches from database...
</span>
<span>
Loading required assets and local caches from database...<br>
Checking codec support; this can take a few seconds.
</span>
</div>
</div>
<script src="./src/index.tsx" type="module"></script>
</body>
</html>
</html>
▾Mwebclient/src/App.tsx
@@ -18,9 +18,11 @@ import PlayerControls from "./components/PlayerControls";
import Playlist from "./components/Playlist";
import PlaylistManager from "./components/PlaylistManager";
import { SignIn } from "./components/SignIn";
import StorageManager from "./components/StorageManager";
import {
ClosedFolderSVG,
CogSVG,
DatabaseSVG,
DownloadManagerSVG,
FullscreenSVG,
LoginSVG,
@@ -76,6 +78,7 @@ const App: Component = () => {
waitingForStart: false,
});
const [showOptions, setShowOptions] = createSignal(false);
const [showStorage, setShowStorage] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [showSignIn, setShowSignIn] = createSignal(false);
@@ -824,6 +827,9 @@ const App: Component = () => {
>
<FullscreenSVG />
</button>
<button title="Storage" onClick={() => setShowStorage(true)}>
<DatabaseSVG />
</button>
<button title="Settings" onClick={() => setShowOptions(true)}>
<CogSVG />
</button>
@@ -896,6 +902,7 @@ const App: Component = () => {
options={options}
/>
<DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
<StorageManager showStorage={showStorage} setShowStorage={setShowStorage} fetchFiles={fetchFiles} />
</div>
);
};
▾Mwebclient/src/components/SVG.tsx
@@ -26,6 +26,27 @@ export function CogSVG() {
);
}
export function DatabaseSVG() {
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() ? "#FFF" : "#CCC"}
//scale the original glyph to match the visual weight of the other 24px toolbar icons
transform="translate(-3.625 0.75) scale(1.25)"
d="M19.504 11.502v1h-1v1H6.5v-1h-1v-1h-1v3.001h1v1h1v1h12.003v-1h1v-1h1v-3zM9.502 15.503v-1h6.001v1zM19.504 7.501v1h-1v1H6.5v-1h-1v-1h-1v3.001h1v1h1v1h12.003v-1h1v-1h1v-3zM9.502 11.502v-1h6.001v1zM19.504 3.5v-1h-1v-1H6.5v1h-1v1h-1v3.001h1v1h1v1h12.003v-1h1v-1h1v-3zm-12.003 0v1h10.003v-1h1v1h-1v1H7.5v-1h-1v-1zm2 4.001v-1h6.002v1z"
/>
</svg>
);
}
export function PlaySVG(props: StateProps) {
const [hovered, setHovered] = createSignal();
return (
▾Mwebclient/src/components/SignIn.tsx
@@ -64,10 +64,10 @@ export function SignIn(props: SignInProps) {
return (
<dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
<form ref={formElement}>
<form ref={formElement} onSubmit={doSignIn}>
<div class="signin-header title">
Sign in
<button title="Close dialog" onClick={handleClose}>
<button type="button" title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
@@ -92,7 +92,7 @@ export function SignIn(props: SignInProps) {
value=""
onChange={() => {}}
/>
<button ref={submitButton} onclick={doSignIn} class="signin-button" title="Submit sign in">
<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 />
▾Awebclient/src/components/StorageManager.css
@@ -0,0 +1,69 @@
.storagemanager-header {
display: flex;
gap: 1.5rem;
& > span {
min-width: 0;
overflow: hidden;
margin-right: auto;
}
}
.storagemanager-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
& > .storagemanager-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
& > .storagemanager-detail {
padding-left: 1rem;
opacity: 0.8;
}
}
.storagemanager-bar {
position: relative;
height: 1.25rem;
background-color: var(--color-c);
display: flex;
align-items: center;
justify-content: center;
& > .storagemanager-bar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: var(--color-d);
min-width: 0;
}
& > span {
position: relative;
font-size: 0.85em;
}
}
.storagemanager-actions {
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
& > .storagemanager-armed {
background-color: oklch(0.48 0.085 102);
&:hover {
background-color: oklch(0.53 0.085 102);
}
}
& > .storagemanager-error {
color: rgb(255, 24, 24);
flex-basis: 100%;
}
}
▾Awebclient/src/components/StorageManager.tsx
@@ -0,0 +1,240 @@
import {
type Accessor,
createEffect,
createMemo,
createSignal,
For,
onCleanup,
type ResourceActions,
type Setter,
Show,
} from "solid-js";
import "./StorageManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import type { FlatFileListing } from "../App";
import { clearAllDownloads, clearLocalData, storageStatsLive } from "../offline";
import { formatBytes, toast } from "../utils";
import { ClearSVG } from "./SVG";
interface StorageManagerProps {
showStorage: Accessor<boolean>;
setShowStorage: Setter<boolean>;
fetchFiles: ResourceActions<FlatFileListing>;
}
//formatBytes(0) returns "Queued...", which fits download progress but not a usage display
function formatSize(bytes: number): string {
return bytes === 0 ? "0 B" : formatBytes(bytes);
}
//not part of the TS dom lib types yet, only implemented in Chromium
type StorageEstimateWithDetails = StorageEstimate & { usageDetails?: Record<string, number> };
const USAGE_DETAIL_LABELS: Record<string, string> = {
indexeddb: "IndexedDB",
caches: "Cache storage",
serviceWorkerRegistrations: "Service workers",
};
//two-step button for destructive actions: the first click arms it for a few seconds,
//the second actually runs onConfirm
function ConfirmButton(props: { label: string; title: string; onConfirm: () => void }) {
const [armed, setArmed] = createSignal(false);
let timer: ReturnType<typeof setTimeout> | undefined;
onCleanup(() => clearTimeout(timer));
return (
<button
title={props.title}
classList={{ "storagemanager-armed": armed() }}
onClick={() => {
if (armed()) {
clearTimeout(timer);
setArmed(false);
props.onConfirm();
} else {
setArmed(true);
timer = setTimeout(() => setArmed(false), 3000);
}
}}
>
{armed() ? "Click again to confirm" : props.label}
</button>
);
}
export default function StorageManager(props: StorageManagerProps) {
let dialogElement!: HTMLDialogElement;
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);
}
const usagePercent = createMemo(() => {
const current = estimate();
if (!current?.quota || !current.usage) return 0;
return Math.min(100, (current.usage / current.quota) * 100);
});
//usageDetails is Chromium-only; elsewhere this stays empty and the breakdown is skipped
const usageDetails = createMemo(() => Object.entries(estimate()?.usageDetails ?? {}));
const quotaLabel = createMemo(() => {
const quota = estimate()?.quota;
return quota === undefined ? "unknown" : formatSize(quota);
});
async function requestPersistence() {
setRequestingPersist(true);
try {
setPersisted(await navigator.storage.persist());
} catch (e) {
toast("Requesting persistent storage failed", "error", e);
}
setRequestingPersist(false);
}
async function clearSynced() {
try {
await clearAllDownloads(props.fetchFiles);
toast("Cleared all synced files", "success");
} catch (e) {
toast("Clearing synced files failed", "error", e);
}
refreshStorageInfo();
}
async function fullReset() {
setResetting(true);
setResetError(undefined);
const failure = await clearLocalData().catch((e) => e?.toString() ?? "Unknown error");
if (failure) {
setResetError(failure);
setResetting(false);
return;
}
location.hash = "";
location.reload();
}
return (
<dialog
ref={dialogElement}
onClose={handleClose}
onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
>
<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>
</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>
</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>
<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>
</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>
);
}
▾Mwebclient/src/index.tsx
@@ -7,7 +7,7 @@ import "./components/Dialog.css";
import { registerSW } from "virtual:pwa-register";
import App from "./App";
import { initCapabilities } from "./capabilities";
import { loadDataFromDB } from "./offline";
import { clearLocalData, loadDataFromDB } from "./offline";
import { toast } from "./utils";
const root = document.getElementById("root");
@@ -49,33 +49,6 @@ if (hasTouch()) {
} catch {}
}
//deletes every local trace of the app, so the next load starts from a clean slate.
//resolves to an error message when something could not be cleared, undefined on success
async function clearLocalData(): Promise<string | undefined> {
localStorage.clear();
//unregister first: the old service worker would otherwise keep serving the stale precached
//assets after the reload, defeating the whole point of the reset
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
}
if ("caches" in window) {
const cacheNames = await caches.keys().catch(() => []);
//awaited, otherwise the reload races the cleanup and can leave caches behind
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName).catch(() => false)));
}
//onsuccess alone can hang forever: another open tab blocks the delete, and errors never resolve
return await new Promise<string | undefined>((resolve) => {
const request = indexedDB.deleteDatabase("music-files");
request.onsuccess = () => resolve(undefined);
request.onerror = () => resolve("Deleting the local database failed, close all other tabs and try again.");
request.onblocked = () =>
resolve("Another tab still has this app open, close it and try again to delete the local database.");
});
}
function ResetDialog() {
const [error, setError] = createSignal<string>();
const [resetting, setResetting] = createSignal(false);
▾Mwebclient/src/offline.ts
@@ -629,7 +629,72 @@ export async function download(
}
}
//aborts all in-progress downloads and deletes every synced file and cover from indexedDB.
export async function clearAllDownloads(fetchFiles: ResourceActions<FlatFileListing>) {
//abort first, so in-flight fetches reject with AbortError instead of writing to a cleared
//database. a download already past its fetch (sitting at db.files.put) cannot be aborted this
//way and may re-insert one row after the clear - harmless, and gone on the next clear/reload
for (const status of Object.values(downloadStatusMap)) {
if (status && typeof status === "object" && "status" in status && typeof status.status === "number") {
status.abort();
}
}
await db.files.clear();
await db.covers.clear();
coverCache.clear();
offlineTreeRoot.files = {};
batch(() => {
for (const path of Object.keys(downloadStatusMap)) {
setDownloadStatusMap(path, undefined);
}
});
fetchFiles.refetch();
}
//deletes every local trace of the app
export async function clearLocalData(): Promise<string | undefined> {
localStorage.clear();
db.close();
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
}
if ("caches" in window) {
const cacheNames = await caches.keys().catch(() => []);
//awaited, otherwise the reload races the cleanup and can leave caches behind
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName).catch(() => false)));
}
//onsuccess alone can hang forever: another open tab blocks the delete, and errors never resolve
return await new Promise<string | undefined>((resolve) => {
const request = indexedDB.deleteDatabase("music-files");
request.onsuccess = () => resolve(undefined);
request.onerror = () => resolve("Deleting the local database failed, close all other tabs and try again.");
request.onblocked = () =>
resolve("Another tab still has this app open, close it and try again to delete the local database.");
});
}
export const storedFilesLive = from(
liveQuery(async () => await db.files.toCollection().keys()),
[],
);
export const storageStatsLive = from(
liveQuery(async () => {
let fileBytes = 0;
await db.files.each((file) => {
fileBytes += file.data.size;
});
return {
fileCount: await db.files.count(),
fileBytes,
coverCount: await db.covers.count(),
playlistCount: await db.playlists.count(),
};
}),
{ fileCount: 0, fileBytes: 0, coverCount: 0, playlistCount: 0 },
);