mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: add hot playback cache (queue prefetch, alpha)
Ephemeral on-disk cache for upcoming queue tracks. Adds Rust commands (download/delete/purge), hotCacheStore with LRU eviction, serial prefetch worker, playback gate, and Settings UI. Disabled by default. - fix: boundary check before file delete in delete_hot_cache_track - fix: remove stale languageRu2 key from ru.ts - fix: restore accidentally removed lyricsServerFirst/fsLyricsToggle/lyricsSource* strings in ru.ts
This commit is contained in:
+184
-1
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
@@ -181,6 +182,12 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Align hot-cache size slider (step 32 MB) to valid values. */
|
||||
function snapHotCacheMb(v: number): number {
|
||||
const x = Math.min(20000, Math.max(32, Math.round(v)));
|
||||
return Math.round((x - 32) / 32) * 32 + 32;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
@@ -189,6 +196,13 @@ export default function Settings() {
|
||||
const gs = useGlobalShortcutsStore();
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
||||
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
|
||||
const hotCacheEntries = useHotCacheStore(s => s.entries);
|
||||
const hotCacheTrackCount = useMemo(() => {
|
||||
if (!serverId) return 0;
|
||||
const prefix = `${serverId}:`;
|
||||
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
|
||||
}, [hotCacheEntries, serverId]);
|
||||
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
|
||||
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -205,6 +219,7 @@ export default function Settings() {
|
||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||||
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 [contributorsOpen, setContributorsOpen] = useState(false);
|
||||
@@ -218,7 +233,8 @@ export default function Settings() {
|
||||
if (activeTab !== 'storage') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
}, [activeTab]);
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
@@ -339,6 +355,15 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
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') {
|
||||
@@ -788,6 +813,164 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{t('settings.hotCacheTitle')}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('settings.hotCacheAlphaBadge')}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.hotCacheDisclaimer')}
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheEnabled')}</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);
|
||||
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' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||||
style={{ flex: 1, 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={{ width: 140 }}
|
||||
id="hot-cache-max-mb-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>
|
||||
{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={{ width: 140 }}
|
||||
id="hot-cache-debounce-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||
{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>
|
||||
</section>
|
||||
|
||||
{/* ZIP Export & Archiving */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
|
||||
Reference in New Issue
Block a user