mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
418b25914a
* chore(cover): scaffold cover module and rust cover_cache stub Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands in cover_cache/mod.rs (no-op returns until phase B). * feat(cover): add unified cover module and tier resolver (phase A) Wave 1A: tiers, storage keys, resolveJs with cold/sibling races, useCoverArt, CoverArtImage, layoutSizes, playback scope helpers, coverSiblings tier ladder, deprecated shims on subsonicStreamUrl. * feat(cover): rust disk cache and tier-ready events (phase B) Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch, cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests. * feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C) Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB cover key clear, prefetch registry drain, MainApp wiring. * feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D) Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense, coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid. * feat(cover): migrate sparse surfaces and integrations (phase E sparse) Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover, MPRIS/Discord/export integrations, playback chrome and detail heroes. * feat(cover): revalidation scheduler and disk pressure gate (phase E+) Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure, useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks. * docs: CHANGELOG and credits for cover art pipeline PR #869 * fix(cover): stop webview getCoverArt storm on dense grids (429) Dense surfaces no longer put rotating getCoverArt URLs in img src; load disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead of invalidating IDB. Throttle background prefetch and cap Home registry. * fix(cover): omit empty img src until cover URL is ready React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB resolves; queue current track shows placeholder when src is still empty. * fix(cover): disk cache by host index key, parallel ensure, asset protocol Bind cover storage to serverIndexKey (library host), rename cover IPC/events, fix REST base URL and Tauri flat args, enable protocol-asset for disk paths, add prioritized ensure queue, and wipe legacy profile-UUID cache once. Limit Vite dep scan to index.html so research/target HTML is ignored. * fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage Encode lossy WebP (~82), write only missing tiers, library cover backfill, and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP asset protocol; no IDB fallback when server is up. Session Home feed cache with warm peek on return; BecauseYouLike deduped cover hook and high prefetch. * feat(cover): per-server cache strategy and native library backfill Move cover disk cache settings to Offline & cache with Lazy/Aggressive per server, per-server clear, and no size cap. Run full-catalog backfill on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes without flooding the webview). Drop global prefetch limits from auth store and waveform clear from the offline storage block. * fix(build): CSP connect-src for Subsonic API; quieter prod nix build Prod webview blocked axios ping after cover CSP (missing connect-src). Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo. * fix(cover): complete WebP ladder in library bulk backfill Aggressive backfill now writes all derived tiers (128–800), skips IDs only when the full ladder exists (not 800 alone), avoids fetch-failed markers on bulk HTTP errors, and stops the pass when the active server changes. * fix(cover,home): navigation-priority backfill and Because You Like UX Pause library cover backfill while navigating; split peek/ensure traffic so grids and rails win over bulk work. Disk src lookup, grid warm hooks, and non-blocking mainstage prime for faster visible covers. Because You Like: session snapshot, staggered horizontal skeleton row, text hidden until cover is ready, and layout aligned with loaded cards. * feat(random-albums,library): local-first album fetch + cover art pipeline Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM() LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер исчезает практически мгновенно; сеть используется только как фолбэк. - advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок - browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums - RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve прогревает следующий батч в фоне после каждого Refresh Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue, diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers, useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие компоненты/хуки). * fix(random-albums): prevent double-load on Zustand rehydration useEffect([selectedGenres, load]) fired twice on every visit: first with default store values, then again ~50 ms later when Zustand rehydrated mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage. Previously this was invisible because the first network fetch took ~1.5 s, so loadingRef.current was still true on the second fire. With the new local-first SQLite path the first load completes in ~50 ms, leaving the guard cleared before rehydration triggers a second random batch. Fix: ref-pattern — keep loadRef.current fresh on every render, effect depends only on selectedGenres. Manual Refresh and genre-filter changes still call the latest closure correctly. * fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает сетевые запросы к рейтингам артистов). - fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при consume резерва через primeAlbumCoversForDisplay - reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums (аналогично non-reserve пути; при уже прогретом кэше — мгновенно) * feat(because-you-like): reserve-first pattern — instant display on return visits Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку мгновенно, вместо spinner → сетевые запросы → контент. Архитектура: - resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из замыкания useEffect); читают текущий localStorage, возвращают { anchor, recs, nextAnchorHistory, nextPicksHistory } - fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу после отображения результата, кладёт следующий батч в _becauseReserve. Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не нужен); они прогреваются через primeAlbumCoversForDisplay при consume. - useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton (контент появляется без мигания) - useEffect: reserve-first path — consume → primeCovers → setState → fill; full-fetch path сохранён как fallback при первом визите или промахе Поведение: - Визит 1: full fetch (как раньше) → показ → fillReserve R1 - Визит 2+: consume R1 → мгновенный показ → fillReserve R2 - При сетевом сбое: restore из session cache (как раньше) * fix(because-you-like): initialise state from reserve — no skeleton flash on remount При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и показывал skeleton на один тик до того как useEffect отработает. Теперь useState() использует lazy initializers, которые читают _becauseReserve прямо в первом рендере: если reserve валиден — state сразу refreshing=false, anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache (из предыдущего показа) и появляются без дополнительных запросов. useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton только если reserve отсутствует (для случая navigation без ремаунта). * fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival Lazy initializers не могли применить reserve при первом рендере, потому что mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[] на первом рендере, poolKey не совпадает с reserve. useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает): когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false. При отсутствии reserve — сбрасывает в skeleton как прежде. * fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect, поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным poolKey → mismatch → lazy initializers запускали skeleton. Теперь двухуровневый fallback без зависимости от poolKey: 1. reserve (serverId + poolKey совпадают) → мгновенный новый батч 2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент доступен сразу с mount, обновляется тихо в фоне 3. skeleton → только при полном отсутствии данных (первый визит) Применяется одинаково в lazy useState initializers, useLayoutEffect и full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент). * fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey='' → mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал mostPlayed. Исправления: - BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms - hasValidReserve: проверяет только serverId - fillBecauseReserve: убран poolKey из сигнатуры и хранилища - useEffect: guard pool.length === 0 → возврат без fetch/consume; effect перезапустится когда pool заполнится (реальные deps изменятся) → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate) > skeleton (только первый визит) * fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId, потом mix-rating значения. Это вызывало двойной запуск эффекта: - Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра - Второй запуск (после rehydration): cache miss или повторный fetch с реальными mix-настройками → ~500ms → новый набор Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор первые ~500ms при каждом возврате на Mainstage. Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth() читается внутри эффекта через getState() — всегда актуальные значения без пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch, но не вызывают двойной запуск при rehydration. * feat(home): local-first discover songs via SQLite ORDER BY RANDOM() Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков) в browseTextSearch.ts — использует libraryAdvancedSearch с sort random, field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку. В Home.tsx: discoverSongs теперь сначала пробует локальный индекс, и только при недоступности (индекс не готов, ошибка) падает обратно на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки берутся из SSD вместо сети. * fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits Причина: Home.tsx размонтируется при навигации. При возврате первый рендер всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]). Решение: getInitialHomeFeed() читает homeFeedCache синхронно через useAuthStore.getState() (не hook) в lazy useState initializers. К моменту повторного визита store уже rehydrated — все state получают кэшированные данные до первого рендера. Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot в useEffect когда state уже заполнен — иначе новые ссылки на массивы вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными. * fix(mainstage): keep refresh without return flicker Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation. * fix(mainstage): stabilize because rail and hero background framing Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount. * fix(now-playing): prewarm track data and prevent stale carry-over Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track. * fix(cover,test): refresh playback scope and default tauri cover mocks Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI. * fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs. * test(cover): hoist mocked useCoverArt and clean EOF whitespace Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line. * fix(cover): align playback ensure auth and harden backfill retry flow Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch. * fix(ci): resolve clippy lint and update frontend node runtime Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20. * chore(settings): simplify cover and analytics strategy copy Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
418 lines
19 KiB
TypeScript
418 lines
19 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
|
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useHotCacheStore } from '../../store/hotCacheStore';
|
|
import { useOfflineStore } from '../../store/offlineStore';
|
|
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
|
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
|
import SettingsSubSection from '../SettingsSubSection';
|
|
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
|
|
|
export function StorageTab() {
|
|
const { t } = useTranslation();
|
|
const auth = useAuthStore();
|
|
const serverId = auth.activeServerId ?? '';
|
|
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
|
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
|
|
const hotCacheEntries = useHotCacheStore(s => s.entries);
|
|
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
|
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
|
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
|
const [clearing, setClearing] = useState(false);
|
|
|
|
const hotCacheTrackCount = useMemo(() => {
|
|
const prefix = `${serverId}:`;
|
|
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
|
|
}, [hotCacheEntries, serverId]);
|
|
|
|
// Load all three size readouts on mount.
|
|
useEffect(() => {
|
|
getImageCacheSize().then(setImageCacheBytes);
|
|
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
}, [auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
|
|
|
/** Live disk usage for hot cache (interval + refresh when index changes). */
|
|
useEffect(() => {
|
|
const customDir = auth.hotCacheDownloadDir || null;
|
|
const refresh = () => {
|
|
invoke<number>('get_hot_cache_size', { customDir })
|
|
.then(setHotCacheBytes)
|
|
.catch(() => setHotCacheBytes(0));
|
|
};
|
|
refresh();
|
|
if (!auth.hotCacheEnabled) return;
|
|
const interval = window.setInterval(refresh, 2000);
|
|
return () => window.clearInterval(interval);
|
|
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
|
|
|
useEffect(() => {
|
|
if (!auth.hotCacheEnabled) return;
|
|
const handle = window.setTimeout(() => {
|
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
|
.then(setHotCacheBytes)
|
|
.catch(() => setHotCacheBytes(0));
|
|
}, 400);
|
|
return () => window.clearTimeout(handle);
|
|
}, [hotCacheEntries, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
|
|
|
const handleClearCache = useCallback(async () => {
|
|
setClearing(true);
|
|
await clearImageCache();
|
|
await clearAllOffline(serverId);
|
|
const [imgBytes, offBytes] = await Promise.all([
|
|
getImageCacheSize(),
|
|
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
|
|
]);
|
|
setImageCacheBytes(imgBytes);
|
|
setOfflineCacheBytes(offBytes);
|
|
setShowClearConfirm(false);
|
|
setClearing(false);
|
|
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
|
|
|
|
const pickOfflineDir = async () => {
|
|
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
|
if (selected && typeof selected === 'string') {
|
|
auth.setOfflineDownloadDir(selected);
|
|
}
|
|
};
|
|
|
|
const pickHotCacheDir = async () => {
|
|
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') });
|
|
if (selected && typeof selected === 'string') {
|
|
auth.setHotCacheDownloadDir(selected);
|
|
useHotCacheStore.setState({ entries: {} });
|
|
invoke<number>('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
}
|
|
};
|
|
|
|
const pickDownloadFolder = async () => {
|
|
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
|
|
if (selected && typeof selected === 'string') {
|
|
auth.setDownloadFolder(selected);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Offline Library (In-App) — includes cache settings */}
|
|
<SettingsSubSection
|
|
title={t('settings.offlineDirTitle')}
|
|
icon={<Download size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
|
{t('settings.offlineDirDesc')}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
readOnly
|
|
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
|
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
|
/>
|
|
{auth.offlineDownloadDir && (
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => auth.setOfflineDownloadDir('')}
|
|
data-tooltip={t('settings.offlineDirClear')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
|
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
|
</button>
|
|
</div>
|
|
{auth.offlineDownloadDir && (
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
|
{t('settings.offlineDirHint')}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|
|
|
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
|
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
|
|
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
|
|
</div>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
|
|
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
min={100}
|
|
max={50000}
|
|
step={100}
|
|
value={auth.maxCacheMb}
|
|
onChange={e => {
|
|
const v = Number(e.target.value);
|
|
if (v >= 100) auth.setMaxCacheMb(v);
|
|
}}
|
|
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
|
|
id="cache-size-input"
|
|
/>
|
|
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
|
</div>
|
|
|
|
{showClearConfirm ? (
|
|
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
|
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button
|
|
className="btn btn-primary"
|
|
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
|
onClick={handleClearCache}
|
|
disabled={clearing}
|
|
>
|
|
{t('settings.cacheClearConfirm')}
|
|
</button>
|
|
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
|
{t('settings.cacheClearCancel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
|
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</SettingsSubSection>
|
|
|
|
<CoverCacheStrategySection />
|
|
|
|
{/* Buffering */}
|
|
<SettingsSubSection
|
|
title={t('settings.nextTrackBufferingTitle')}
|
|
icon={<Download size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
|
|
{t('settings.preloadHotCacheMutualExclusive')}
|
|
</div>
|
|
|
|
{/* Preload mode */}
|
|
<div className="settings-toggle-row">
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
|
</div>
|
|
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
|
|
<input
|
|
type="checkbox"
|
|
checked={auth.preloadMode !== 'off'}
|
|
onChange={e => {
|
|
if (e.target.checked) {
|
|
auth.setPreloadMode('balanced');
|
|
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
|
|
} else {
|
|
auth.setPreloadMode('off');
|
|
}
|
|
}}
|
|
/>
|
|
<span className="toggle-track" />
|
|
</label>
|
|
</div>
|
|
{auth.preloadMode !== 'off' && (
|
|
<>
|
|
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
|
style={{ fontSize: 12, padding: '3px 12px' }}
|
|
onClick={() => auth.setPreloadMode(mode)}
|
|
>
|
|
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{auth.preloadMode === 'custom' && (
|
|
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="range"
|
|
min={5} max={120} step={5}
|
|
value={auth.preloadCustomSeconds}
|
|
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
|
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
|
/>
|
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
|
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="divider" />
|
|
|
|
{/* Hot Cache */}
|
|
<div className="settings-toggle-row">
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
|
|
</div>
|
|
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
|
<input
|
|
type="checkbox"
|
|
checked={auth.hotCacheEnabled}
|
|
onChange={async e => {
|
|
const enabled = e.target.checked;
|
|
if (!enabled) {
|
|
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
|
setHotCacheBytes(0);
|
|
auth.setHotCacheEnabled(false);
|
|
} else {
|
|
auth.setHotCacheEnabled(true);
|
|
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
|
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
|
.then(setHotCacheBytes)
|
|
.catch(() => setHotCacheBytes(0));
|
|
}
|
|
}}
|
|
id="hot-cache-enabled-toggle"
|
|
/>
|
|
<span className="toggle-track" />
|
|
</label>
|
|
</div>
|
|
|
|
{auth.hotCacheEnabled && (
|
|
<div style={{ marginTop: '1.25rem' }}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
readOnly
|
|
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
|
style={{ flex: 1, minWidth: 0, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
|
/>
|
|
{auth.hotCacheDownloadDir && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() => {
|
|
auth.setHotCacheDownloadDir('');
|
|
useHotCacheStore.setState({ entries: {} });
|
|
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
}}
|
|
data-tooltip={t('settings.hotCacheDirClear')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
|
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
|
</button>
|
|
</div>
|
|
{auth.hotCacheDownloadDir && (
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
|
{t('settings.hotCacheDirHint')}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|
|
|
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
|
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
|
</div>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
|
{hotCacheTrackCount}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
|
</div>
|
|
</div>
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
|
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
|
? t('settings.hotCacheDebounceImmediate')
|
|
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
style={{ fontSize: 13 }}
|
|
onClick={async () => {
|
|
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
|
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
|
setHotCacheBytes(b);
|
|
}}
|
|
>
|
|
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</SettingsSubSection>
|
|
|
|
{/* ZIP Export & Archiving */}
|
|
<SettingsSubSection
|
|
title={t('settings.downloadsTitle')}
|
|
icon={<FolderOpen size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
|
{t('settings.downloadsFolderDesc')}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
readOnly
|
|
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
|
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
|
/>
|
|
{auth.downloadFolder && (
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => auth.setDownloadFolder('')}
|
|
aria-label={t('settings.clearFolder')}
|
|
data-tooltip={t('settings.clearFolder')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
|
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SettingsSubSection>
|
|
</>
|
|
);
|
|
}
|