StorageManager.tsx
| 1 | import { |
| 2 | type Accessor, |
| 3 | createMemo, |
| 4 | createSignal, |
| 5 | For, |
| 6 | onCleanup, |
| 7 | type ResourceActions, |
| 8 | type Setter, |
| 9 | Show, |
| 10 | } from "solid-js"; |
| 11 | import "./StorageManager.css"; |
| 12 | import "./Dialog.css"; |
| 13 | import "toastify-js/src/toastify.css"; |
| 14 | import type { FlatFileListing } from "../App"; |
| 15 | import { clearAllDownloads, clearLocalData, storageStatsLive } from "../offline"; |
| 16 | import { formatBytes, toast } from "../utils"; |
| 17 | import { Modal } from "./Modal"; |
| 18 | import { ClearSVG } from "./SVG"; |
| 19 | |
| 20 | interface 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 |
| 27 | function 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 |
| 32 | type StorageEstimateWithDetails = StorageEstimate & { usageDetails?: Record<string, number> }; |
| 33 | |
| 34 | const 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 |
| 42 | function 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 | |
| 67 | export 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 |