StorageManager.tsx
⎇
Raw
1import {
2 type Accessor,
3 createMemo,
4 createSignal,
5 For,
6 onCleanup,
7 type ResourceActions,
8 type Setter,
9 Show,
10} from "solid-js";
11import "./StorageManager.css";
12import "./Dialog.css";
13import "toastify-js/src/toastify.css";
14import type { FlatFileListing } from "../App";
15import { clearAllDownloads, clearLocalData, storageStatsLive } from "../offline";
16import { formatBytes, toast } from "../utils";
17import { Modal } from "./Modal";
18import { ClearSVG } from "./SVG";
19
20interface StorageManagerProps {
21 showStorage: Accessor<boolean>;
22 setShowStorage: Setter<boolean>;
23 fetchFiles: ResourceActions<FlatFileListing>;
24}
25
26//formatBytes(0) returns "Queued...", which fits download progress but not a usage display
27function formatSize(bytes: number): string {
28 return bytes === 0 ? "0 B" : formatBytes(bytes);
29}
30
31//not part of the TS dom lib types yet, only implemented in Chromium
32type StorageEstimateWithDetails = StorageEstimate & { usageDetails?: Record<string, number> };
33
34const USAGE_DETAIL_LABELS: Record<string, string> = {
35 indexeddb: "IndexedDB",
36 caches: "Cache storage",
37 serviceWorkerRegistrations: "Service workers",
38};
39
40//two-step button for destructive actions: the first click arms it for a few seconds,
41//the second actually runs onConfirm
42function ConfirmButton(props: { label: string; title: string; onConfirm: () => void }) {
43 const [armed, setArmed] = createSignal(false);
44 let timer: ReturnType<typeof setTimeout> | undefined;
45 onCleanup(() => clearTimeout(timer));
46
47 return (
48 <button
49 title={props.title}
50 classList={{ "storagemanager-armed": armed() }}
51 onClick={() => {
52 if (armed()) {
53 clearTimeout(timer);
54 setArmed(false);
55 props.onConfirm();
56 } else {
57 setArmed(true);
58 timer = setTimeout(() => setArmed(false), 3000);
59 }
60 }}
61 >
62 {armed() ? "Click again to confirm" : props.label}
63 </button>
64 );
65}
66
67export default function StorageManager(props: StorageManagerProps) {
68 //whether the browser has these APIs at all cannot change while the page is open
69 const estimateSupported = !!navigator.storage?.estimate;
70 const persistedSupported = !!navigator.storage?.persisted;
71
72 const [estimate, setEstimate] = createSignal<StorageEstimateWithDetails>();
73 const [persisted, setPersisted] = createSignal<boolean>();
74 const [requestingPersist, setRequestingPersist] = createSignal(false);
75 const [resetError, setResetError] = createSignal<string>();
76 const [resetting, setResetting] = createSignal(false);
77
78 async function refreshStorageInfo() {
79 if (estimateSupported) setEstimate(await navigator.storage.estimate().catch(() => undefined));
80 if (persistedSupported) setPersisted(await navigator.storage.persisted().catch(() => undefined));
81 }
82
83 const usagePercent = createMemo(() => {
84 const current = estimate();
85 if (!current?.quota || !current.usage) return 0;
86 return Math.min(100, (current.usage / current.quota) * 100);
87 });
88
89 //usageDetails is Chromium-only; elsewhere this stays empty and the breakdown is skipped
90 const usageDetails = createMemo(() => Object.entries(estimate()?.usageDetails ?? {}));
91
92 const quotaLabel = createMemo(() => {
93 const quota = estimate()?.quota;
94 return quota === undefined ? "unknown" : formatSize(quota);
95 });
96
97 async function requestPersistence() {
98 setRequestingPersist(true);
99 try {
100 setPersisted(await navigator.storage.persist());
101 } catch (e) {
102 toast("Requesting persistent storage failed", "error", e);
103 }
104 setRequestingPersist(false);
105 }
106
107 async function clearSynced() {
108 try {
109 await clearAllDownloads(props.fetchFiles);
110 toast("Cleared all synced files", "success");
111 } catch (e) {
112 toast("Clearing synced files failed", "error", e);
113 }
114 refreshStorageInfo();
115 }
116
117 async function fullReset() {
118 setResetting(true);
119 setResetError(undefined);
120 const failure = await clearLocalData().catch((e) => e?.toString() ?? "Unknown error");
121 if (failure) {
122 setResetError(failure);
123 setResetting(false);
124 return;
125 }
126 location.hash = "";
127 location.reload();
128 }
129
130 return (
131 <Modal
132 show={props.showStorage}
133 setShow={props.setShowStorage}
134 onOpen={() => {
135 refreshStorageInfo();
136 setResetError(undefined);
137 }}
138 >
139 {(close) => (
140 <div>
141 <div class="storagemanager-header title">
142 <span>Storage</span>
143 <button title="Close dialog" onClick={close}>
144 <ClearSVG />
145 </button>
146 </div>
147 <hr />
148
149 <Show
150 when={estimateSupported}
151 fallback={<div class="storagemanager-section">Storage estimate API not available in this browser</div>}
152 >
153 <div class="storagemanager-section">
154 <div class="storagemanager-row">
155 <span>Usage: {formatSize(estimate()?.usage ?? 0)}</span>
156 <span>Quota: {quotaLabel()}</span>
157 </div>
158 <div class="storagemanager-bar" title={`${usagePercent().toFixed(1)}% of quota used`}>
159 <div class="storagemanager-bar-fill" style={{ width: `${usagePercent()}%` }} />
160 <span>{usagePercent().toFixed(1)}%</span>
161 </div>
162 <Show when={usageDetails().length > 0}>
163 <For each={usageDetails()}>
164 {([key, bytes]) => (
165 <div class="storagemanager-row storagemanager-detail">
166 <span>{USAGE_DETAIL_LABELS[key] ?? key}</span>
167 <span>{formatSize(bytes)}</span>
168 </div>
169 )}
170 </For>
171 </Show>
172 </div>
173 </Show>
174
175 <Show when={persistedSupported}>
176 <div class="storagemanager-section">
177 <div class="storagemanager-row">
178 <span>Persistent storage: {persisted() === undefined ? "unknown" : persisted() ? "yes" : "no"}</span>
179 <Show when={persisted() === false}>
180 <button
181 title="Ask the browser to never evict this site's stored files when space runs low"
182 disabled={requestingPersist()}
183 onClick={requestPersistence}
184 >
185 Request persistent storage
186 </button>
187 </Show>
188 </div>
189 </div>
190 </Show>
191
192 <div class="storagemanager-section">
193 <div class="storagemanager-row">
194 <span>Synced files: {storageStatsLive().fileCount}</span>
195 <span>{formatSize(storageStatsLive().fileBytes)}</span>
196 </div>
197 <div class="storagemanager-row">
198 <span>Covers: {storageStatsLive().coverCount}</span>
199 <span>Playlists: {storageStatsLive().playlistCount}</span>
200 </div>
201 </div>
202
203 <div class="storagemanager-section storagemanager-actions">
204 <ConfirmButton
205 label="Clear synced files"
206 title="Delete all synced files and covers from local storage. Playlists and settings are kept"
207 onConfirm={clearSynced}
208 />
209 <ConfirmButton
210 label={resetting() ? "Resetting..." : "Full reset"}
211 title="Delete everything this app has stored locally - files, covers, playlists, settings and caches - then reload the page"
212 onConfirm={fullReset}
213 />
214 <Show when={resetError()}>
215 <div class="storagemanager-error">{resetError()}</div>
216 </Show>
217 </div>
218 </div>
219 )}
220 </Modal>
221 );
222}
223