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:
cucadmuh
2026-04-07 11:52:26 +03:00
committed by GitHub
parent d49af977ed
commit 0f3033d84e
16 changed files with 940 additions and 57 deletions
+165
View File
@@ -578,6 +578,167 @@ async fn delete_offline_track(
Ok(()) Ok(())
} }
// ─── Hot playback cache (ephemeral; queue-based prefetch) ─────────────────────
fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
) -> Result<std::path::PathBuf, String> {
if let Some(ref cd) = custom_dir.filter(|s| !s.is_empty()) {
let base = std::path::PathBuf::from(cd);
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
Ok(base.join("psysonic-hot-cache"))
} else {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-hot-cache"))
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HotCacheDownloadResult {
path: String,
size: u64,
}
/// Downloads a single track into the hot playback cache (separate from offline library).
/// Optional `custom_dir`: parent folder; files go under `<custom_dir>/psysonic-hot-cache/<server_id>/`.
/// Returns absolute path and file size for `psysonic-local://` URLs.
#[tauri::command]
async fn download_track_hot_cache(
track_id: String,
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<HotCacheDownloadResult, String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
let cache_dir = root.join(&server_id);
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
if file_path.exists() {
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
return Ok(HotCacheDownloadResult {
path: path_str,
size,
});
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
let size = bytes.len() as u64;
Ok(HotCacheDownloadResult {
path: path_str,
size,
})
}
#[tauri::command]
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
resolve_hot_cache_root(custom_dir, &app)
.map(|root| dir_size(root))
.unwrap_or(0)
}
#[tauri::command]
async fn delete_hot_cache_track(
local_path: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
if std::fs::remove_dir(&dir).is_err() {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
#[tauri::command]
async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let dir = resolve_hot_cache_root(custom_dir, &app)?;
if dir.exists() {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers. /// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation). /// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> { fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
@@ -866,6 +1027,10 @@ pub fn run() {
download_track_offline, download_track_offline,
delete_offline_track, delete_offline_track,
get_offline_cache_size, get_offline_cache_size,
download_track_hot_cache,
get_hot_cache_size,
delete_hot_cache_track,
purge_hot_cache,
relaunch_after_update, relaunch_after_update,
toggle_tray_icon, toggle_tray_icon,
]) ])
+5
View File
@@ -54,6 +54,7 @@ import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { useOfflineStore } from './store/offlineStore'; import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore'; import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore'; import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore'; import { useFontStore } from './store/fontStore';
@@ -461,6 +462,10 @@ export default function App() {
return initAudioListeners(); return initAudioListeners();
}, []); }, []);
useEffect(() => {
return initHotCachePrefetch();
}, []);
useEffect(() => { useEffect(() => {
useGlobalShortcutsStore.getState().registerAll(); useGlobalShortcutsStore.getState().registerAll();
}, []); }, []);
+184
View File
@@ -0,0 +1,184 @@
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from './api/subsonic';
import { useAuthStore } from './store/authStore';
import { useHotCacheStore } from './store/hotCacheStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore } from './store/playerStore';
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
const PREFETCH_AHEAD = 5;
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pendingQueue: PrefetchJob[] = [];
let workerRunning = false;
function debounceMs(): number {
const s = useAuthStore.getState().hotCacheDebounceSec;
if (!Number.isFinite(s) || s < 0) return 0;
return Math.min(600, s) * 1000;
}
function enqueueJobs(jobs: PrefetchJob[]) {
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
for (const j of jobs) {
const k = `${j.serverId}:${j.trackId}`;
if (seen.has(k)) continue;
seen.add(k);
pendingQueue.push(j);
}
void runWorker();
}
async function runWorker() {
if (workerRunning) return;
workerRunning = true;
try {
while (pendingQueue.length > 0) {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
pendingQueue.length = 0;
break;
}
while (getDeferHotCachePrefetch()) {
await new Promise(r => setTimeout(r, 150));
}
const job = pendingQueue.shift();
if (!job) break;
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
if (maxBytes <= 0) continue;
const offline = useOfflineStore.getState();
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
const { queue, queueIndex } = usePlayerStore.getState();
const wantIds = new Set(
queue
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
.map(t => t.id),
);
if (!wantIds.has(job.trackId)) continue;
const url = buildStreamUrl(job.trackId);
try {
const customDir = auth.hotCacheDownloadDir || null;
const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', {
trackId: job.trackId,
serverId: job.serverId,
url,
suffix: job.suffix,
customDir,
});
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
const fresh = usePlayerStore.getState();
await useHotCacheStore.getState().evictToFit(
fresh.queue,
fresh.queueIndex,
maxBytes,
auth.activeServerId,
customDir,
);
} catch {
/* network / HTTP — skip */
}
}
} finally {
workerRunning = false;
if (pendingQueue.length > 0) void runWorker();
}
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function scheduleReplan() {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
return;
}
if (debounceTimer) clearTimeout(debounceTimer);
const ms = debounceMs();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void replanNow();
}, ms);
}
async function replanNow() {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) return;
const serverId = auth.activeServerId;
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
const customDir = auth.hotCacheDownloadDir || null;
if (maxBytes <= 0) return;
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
if (currentRadio) return;
const offline = useOfflineStore.getState();
const hot = useHotCacheStore.getState();
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
const jobs: PrefetchJob[] = [];
for (const t of targets) {
if (offline.isDownloaded(t.id, serverId)) continue;
if (hot.entries[entryKey(serverId, t.id)]) continue;
jobs.push({
trackId: t.id,
serverId,
suffix: t.suffix || 'mp3',
});
}
enqueueJobs(jobs);
}
/**
* Subscribe to queue/auth changes and run debounced prefetch.
* Call once from the app shell.
*/
export function initHotCachePrefetch(): () => void {
let lastQueueRef: unknown = null;
let lastQueueIndex = -1;
const unsubPlayer = usePlayerStore.subscribe(state => {
const q = state.queue;
const i = state.queueIndex;
if (q === lastQueueRef && i === lastQueueIndex) return;
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
lastQueueRef = q;
lastQueueIndex = i;
if (onlyIndexMoved) void replanNow();
else scheduleReplan();
});
let lastAuthSig = '';
const unsubAuth = useAuthStore.subscribe(state => {
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
if (sig === lastAuthSig) return;
lastAuthSig = sig;
if (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan();
});
void replanNow();
return () => {
unsubPlayer();
unsubAuth();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
pendingQueue.length = 0;
};
}
+15
View File
@@ -404,6 +404,8 @@ export const deTranslation = {
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.', cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
cacheUsedImages: 'Bilder:', cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Offline-Tracks:', cacheUsedOffline: 'Offline-Tracks:',
cacheUsedHot: 'Belegter Speicher:',
hotCacheTrackCount: 'Titel im Cache:',
cacheMaxLabel: 'Max. Größe', cacheMaxLabel: 'Max. Größe',
cacheClearBtn: 'Cache leeren', cacheClearBtn: 'Cache leeren',
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.', cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
@@ -415,6 +417,19 @@ export const deTranslation = {
offlineDirChange: 'Verzeichnis ändern', offlineDirChange: 'Verzeichnis ändern',
offlineDirClear: 'Auf Standard zurücksetzen', offlineDirClear: 'Auf Standard zurücksetzen',
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.', offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
hotCacheTitle: 'Hot-Playback-Cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Lädt kommende Warteschlangen-Titel vor und behält zuletzt gespielte. Bei aktivierter Option: Speicherplatz und Netzwerk.',
hotCacheDirDefault: 'Standard (App-Daten)',
hotCacheDirChange: 'Ordner ändern',
hotCacheDirClear: 'Auf Standard zurücksetzen',
hotCacheDirHint: 'Beim Ordnerwechsel wird der App-Index zurückgesetzt; alte Dateien bleiben am alten Ort, bis Sie sie löschen.',
hotCacheEnabled: 'Hot-Playback-Cache aktivieren',
hotCacheMaxMb: 'Maximale Cache-Größe',
hotCacheDebounce: 'Debounce bei Warteschlangen-Änderungen',
hotCacheDebounceImmediate: 'Sofort',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Hot-Cache leeren',
showArtistImages: 'Künstlerbilder anzeigen', showArtistImages: 'Künstlerbilder anzeigen',
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.', showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
showTrayIcon: 'Tray-Icon anzeigen', showTrayIcon: 'Tray-Icon anzeigen',
+15
View File
@@ -404,6 +404,8 @@ export const enTranslation = {
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.', cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
cacheUsedImages: 'Images:', cacheUsedImages: 'Images:',
cacheUsedOffline: 'Offline tracks:', cacheUsedOffline: 'Offline tracks:',
cacheUsedHot: 'Size on disk:',
hotCacheTrackCount: 'Tracks in cache:',
cacheMaxLabel: 'Max. size', cacheMaxLabel: 'Max. size',
cacheClearBtn: 'Clear Cache', cacheClearBtn: 'Clear Cache',
cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearWarning: 'This will also remove all offline albums from the library.',
@@ -415,6 +417,19 @@ export const enTranslation = {
offlineDirChange: 'Change Directory', offlineDirChange: 'Change Directory',
offlineDirClear: 'Reset to Default', offlineDirClear: 'Reset to Default',
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.', offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
hotCacheTitle: 'Hot playback cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Preloads upcoming queue tracks and keeps previous ones. When enabled, uses disk space and network.',
hotCacheDirDefault: 'Default (App Data)',
hotCacheDirChange: 'Change folder',
hotCacheDirClear: 'Reset to default',
hotCacheDirHint: 'Changing the folder resets the in-app index; files already on disk stay where they are until you remove them.',
hotCacheEnabled: 'Enable hot playback cache',
hotCacheMaxMb: 'Maximum cache size',
hotCacheDebounce: 'Queue change debounce',
hotCacheDebounceImmediate: 'Immediate',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Clear hot cache',
showArtistImages: 'Show Artist Images', showArtistImages: 'Show Artist Images',
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.', showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
showTrayIcon: 'Show Tray Icon', showTrayIcon: 'Show Tray Icon',
+15
View File
@@ -404,6 +404,8 @@ export const frTranslation = {
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.', cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
cacheUsedImages: 'Images :', cacheUsedImages: 'Images :',
cacheUsedOffline: 'Pistes hors ligne :', cacheUsedOffline: 'Pistes hors ligne :',
cacheUsedHot: 'Espace disque :',
hotCacheTrackCount: 'Pistes en cache :',
cacheMaxLabel: 'Taille max.', cacheMaxLabel: 'Taille max.',
cacheClearBtn: 'Vider le cache', cacheClearBtn: 'Vider le cache',
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.', cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
@@ -415,6 +417,19 @@ export const frTranslation = {
offlineDirChange: 'Changer le dossier', offlineDirChange: 'Changer le dossier',
offlineDirClear: 'Réinitialiser', offlineDirClear: 'Réinitialiser',
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.', offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
hotCacheTitle: 'Cache de lecture à chaud',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Précharge les titres à venir dans la file et conserve les précédents. Si activé : espace disque et réseau.',
hotCacheDirDefault: 'Par défaut (données d\'application)',
hotCacheDirChange: 'Changer le dossier',
hotCacheDirClear: 'Réinitialiser',
hotCacheDirHint: 'Changer de dossier réinitialise lindex dans lapp ; les fichiers déjà écrits restent à lancien emplacement.',
hotCacheEnabled: 'Activer le cache de lecture à chaud',
hotCacheMaxMb: 'Taille maximale du cache',
hotCacheDebounce: 'Délai après changement de file',
hotCacheDebounceImmediate: 'Immédiat',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Vider le cache à chaud',
showArtistImages: 'Afficher les images d\'artistes', showArtistImages: 'Afficher les images d\'artistes',
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.', showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
showTrayIcon: 'Afficher l\'icône dans la barre système', showTrayIcon: 'Afficher l\'icône dans la barre système',
+15
View File
@@ -405,6 +405,8 @@ export const nbTranslation = {
cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor', cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor',
cacheUsedImages: 'Bilder:', cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Frakoblede spor:', cacheUsedOffline: 'Frakoblede spor:',
cacheUsedHot: 'Plass brukt:',
hotCacheTrackCount: 'Spor i buffer:',
cacheMaxLabel: 'Maks størrelse', cacheMaxLabel: 'Maks størrelse',
cacheClearBtn: 'Tøm hurtigbuffer', cacheClearBtn: 'Tøm hurtigbuffer',
cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.', cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.',
@@ -416,6 +418,19 @@ export const nbTranslation = {
offlineDirChange: 'Endre katalog', offlineDirChange: 'Endre katalog',
offlineDirClear: 'Tilbakestill til standard', offlineDirClear: 'Tilbakestill til standard',
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.', offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
hotCacheTitle: 'Varm avspillingsbuffer',
hotCacheAlphaBadge: 'Alfa',
hotCacheDisclaimer: 'Forhåndshenter neste spor i køen og beholder tidligere. Når aktivert: diskplass og nettverk.',
hotCacheDirDefault: 'Standard (App-data)',
hotCacheDirChange: 'Endre mappe',
hotCacheDirClear: 'Tilbakestill til standard',
hotCacheDirHint: 'Bytte mappe nullstiller indeksen i appen; gamle filer blir liggende til du sletter dem.',
hotCacheEnabled: 'Aktiver varm avspillingsbuffer',
hotCacheMaxMb: 'Maks bufferstørrelse',
hotCacheDebounce: 'Utsettelse ved køendring',
hotCacheDebounceImmediate: 'Umiddelbart',
hotCacheDebounceSeconds: '{{n}} sek',
hotCacheClearBtn: 'Tøm varm buffer',
showArtistImages: 'Vis artistbilder', showArtistImages: 'Vis artistbilder',
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.', showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen', minimizeToTray: 'Minimer til oppgavelinjen',
+15
View File
@@ -404,6 +404,8 @@ export const nlTranslation = {
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.', cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
cacheUsedImages: 'Afbeeldingen:', cacheUsedImages: 'Afbeeldingen:',
cacheUsedOffline: 'Offline nummers:', cacheUsedOffline: 'Offline nummers:',
cacheUsedHot: 'Schijfgebruik:',
hotCacheTrackCount: 'Nummers in cache:',
cacheMaxLabel: 'Max. grootte', cacheMaxLabel: 'Max. grootte',
cacheClearBtn: 'Cache wissen', cacheClearBtn: 'Cache wissen',
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.', cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
@@ -415,6 +417,19 @@ export const nlTranslation = {
offlineDirChange: 'Map wijzigen', offlineDirChange: 'Map wijzigen',
offlineDirClear: 'Terugzetten naar standaard', offlineDirClear: 'Terugzetten naar standaard',
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.', offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
hotCacheTitle: 'Warme afspeelcache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Laadt aankomende wachtrijtracks voor en bewaart eerdere. Indien ingeschakeld: schijfruimte en netwerk.',
hotCacheDirDefault: 'Standaard (app-gegevens)',
hotCacheDirChange: 'Map wijzigen',
hotCacheDirClear: 'Terugzetten naar standaard',
hotCacheDirHint: 'Een andere map kiest: de index in de app wordt gereset; oude bestanden blijven staan tot je ze verwijdert.',
hotCacheEnabled: 'Warme afspeelcache inschakelen',
hotCacheMaxMb: 'Maximale cachegrootte',
hotCacheDebounce: 'Uitstel bij wachtrijwijziging',
hotCacheDebounceImmediate: 'Direct',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Warme cache wissen',
showArtistImages: 'Artiestafbeeldingen weergeven', showArtistImages: 'Artiestafbeeldingen weergeven',
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.', showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
showTrayIcon: 'Tray-pictogram weergeven', showTrayIcon: 'Tray-pictogram weergeven',
+62 -51
View File
@@ -1,8 +1,8 @@
/** Russian UI strings — natural phrasing for a music desktop app */ /** Russian UI strings — natural phrasing for a music desktop app */
export const ruTranslation = { export const ruTranslation = {
sidebar: { sidebar: {
library: 'Библиотека', library: 'Медиатека',
mainstage: 'Главная', mainstage: 'Для вас',
newReleases: 'Новинки', newReleases: 'Новинки',
allAlbums: 'Все альбомы', allAlbums: 'Все альбомы',
randomAlbums: 'Случайные альбомы', randomAlbums: 'Случайные альбомы',
@@ -26,8 +26,8 @@ export const ruTranslation = {
radio: 'Онлайн-радио', radio: 'Онлайн-радио',
}, },
home: { home: {
hero: 'Рекомендации', hero: 'Подборка',
starred: 'Избранное', starred: 'Личное избранное',
recent: 'Недавно добавлено', recent: 'Недавно добавлено',
mostPlayed: 'Чаще всего', mostPlayed: 'Чаще всего',
recentlyPlayed: 'Недавно проиграно', recentlyPlayed: 'Недавно проиграно',
@@ -38,14 +38,14 @@ export const ruTranslation = {
discoverArtistsMore: 'Все исполнители', discoverArtistsMore: 'Все исполнители',
}, },
hero: { hero: {
eyebrow: 'Рекомендуемый альбом', eyebrow: 'Альбом дня',
playAlbum: 'Воспроизвести альбом', playAlbum: 'Воспроизвести альбом',
enqueue: 'В очередь', enqueue: 'В очередь',
enqueueTooltip: 'Добавить весь альбом в очередь', enqueueTooltip: 'Добавить весь альбом в очередь',
}, },
search: { search: {
placeholder: 'Исполнитель, альбом или трек…', placeholder: 'Исполнитель, альбом или трек…',
noResults: 'Нет результатов для «{{query}}»', noResults: 'Ничего не найдено по запросу «{{query}}»',
artists: 'Исполнители', artists: 'Исполнители',
albums: 'Альбомы', albums: 'Альбомы',
songs: 'Треки', songs: 'Треки',
@@ -93,11 +93,11 @@ export const ruTranslation = {
addToQueue: 'В конец очереди', addToQueue: 'В конец очереди',
enqueueAlbum: 'Альбом в очередь', enqueueAlbum: 'Альбом в очередь',
startRadio: 'Радио по похожим', startRadio: 'Радио по похожим',
lfmLove: 'Нравится на Last.fm', lfmLove: 'Любимое на Last.fm',
lfmUnlove: 'Убрать лайк с Last.fm', lfmUnlove: 'Убрать с Last.fm',
favorite: 'В избранное', favorite: 'В избранное',
favoriteArtist: 'Добавить исполнителя в избранное', favoriteArtist: 'Исполнитель в избранное',
favoriteAlbum: 'Добавить альбом в избранное', favoriteAlbum: 'Альбом в избранное',
unfavorite: 'Убрать из избранного', unfavorite: 'Убрать из избранного',
unfavoriteArtist: 'Убрать исполнителя из избранного', unfavoriteArtist: 'Убрать исполнителя из избранного',
unfavoriteAlbum: 'Убрать альбом из избранного', unfavoriteAlbum: 'Убрать альбом из избранного',
@@ -121,7 +121,7 @@ export const ruTranslation = {
offlineDownloading: 'Кэширование… ({{n}} из {{total}})', offlineDownloading: 'Кэширование… ({{n}} из {{total}})',
removeOffline: 'Удалить офлайн-копию', removeOffline: 'Удалить офлайн-копию',
offlineStorageFull: offlineStorageFull:
'Место для офлайн-кэша закончилось (лимит {{mb}} МБ). Освободите место или увеличьте лимит в настройках.', 'Место для офлайн закончилось (лимит {{mb}} МБ). Освободите место в офлайн-библиотеке или увеличьте лимит в настройках.',
offlineStorageGoToLibrary: 'Офлайн-библиотека', offlineStorageGoToLibrary: 'Офлайн-библиотека',
offlineStorageGoToSettings: 'Настройки', offlineStorageGoToSettings: 'Настройки',
favoriteAdd: 'В избранное', favoriteAdd: 'В избранное',
@@ -129,7 +129,7 @@ export const ruTranslation = {
favorite: 'Избранное', favorite: 'Избранное',
noBio: 'Биография недоступна.', noBio: 'Биография недоступна.',
moreByArtist: 'Ещё от {{artist}}', moreByArtist: 'Ещё от {{artist}}',
tracksCount: '{{n}} треков', tracksCount: 'Композиций: {{n}}',
goToArtist: 'Перейти к {{artist}}', goToArtist: 'Перейти к {{artist}}',
moreLabelAlbums: 'Другие альбомы на {{label}}', moreLabelAlbums: 'Другие альбомы на {{label}}',
trackTitle: 'Название', trackTitle: 'Название',
@@ -140,7 +140,7 @@ export const ruTranslation = {
trackRating: 'Оценка', trackRating: 'Оценка',
trackDuration: 'Длительность', trackDuration: 'Длительность',
trackTotal: 'Итого', trackTotal: 'Итого',
columns: 'Столбцы', columns: 'Колонки',
notFound: 'Альбом не найден.', notFound: 'Альбом не найден.',
bioModal: 'Биография исполнителя', bioModal: 'Биография исполнителя',
bioClose: 'Закрыть', bioClose: 'Закрыть',
@@ -174,18 +174,18 @@ export const ruTranslation = {
featuredOn: 'Также участвует в', featuredOn: 'Также участвует в',
similarArtists: 'Похожие исполнители', similarArtists: 'Похожие исполнители',
cacheOffline: 'Сохранить дискографию офлайн', cacheOffline: 'Сохранить дискографию офлайн',
offlineCached: 'Дискография сохранена в кэш', offlineCached: 'Дискография сохранена',
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)', offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
uploadImage: 'Загрузить фото исполнителя', uploadImage: 'Загрузить фото исполнителя',
uploadImageError: 'Не удалось загрузить изображение', uploadImageError: 'Не удалось загрузить изображение',
}, },
favorites: { favorites: {
title: 'Избранное', title: 'Избранное',
empty: 'Здесь пока пусто... \nПора добавить что-нибудь в избранное.', empty: 'Пока ничего не отмечено звёздочкой.',
artists: 'Исполнители', artists: 'Исполнители',
albums: 'Альбомы', albums: 'Альбомы',
songs: 'Треки', songs: 'Треки',
enqueueAll: 'Добавить всё в очередь', enqueueAll: 'Всё в очередь',
playAll: 'Воспроизвести всё', playAll: 'Воспроизвести всё',
removeSong: 'Убрать из избранного', removeSong: 'Убрать из избранного',
stations: 'Радиостанции', stations: 'Радиостанции',
@@ -204,7 +204,7 @@ export const ruTranslation = {
loading: 'Загрузка жанров…', loading: 'Загрузка жанров…',
empty: 'Жанры не найдены.', empty: 'Жанры не найдены.',
albumsLoading: 'Загрузка альбомов…', albumsLoading: 'Загрузка альбомов…',
albumsEmpty: 'Альбомы в этом жанре не найдены.', albumsEmpty: 'В этом жанре альбомов нет.',
loadMore: 'Ещё', loadMore: 'Ещё',
back: 'Назад', back: 'Назад',
}, },
@@ -220,7 +220,7 @@ export const ruTranslation = {
trackDuration: 'Длительность', trackDuration: 'Длительность',
favoriteAdd: 'В избранное', favoriteAdd: 'В избранное',
favoriteRemove: 'Убрать из избранного', favoriteRemove: 'Убрать из избранного',
play: 'Воспроизвести', play: 'Играть',
trackGenre: 'Жанр', trackGenre: 'Жанр',
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли', excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
excludeAudiobooksDesc: excludeAudiobooksDesc:
@@ -275,18 +275,18 @@ export const ruTranslation = {
serverNamePlaceholder: 'Мой Navidrome', serverNamePlaceholder: 'Мой Navidrome',
serverUrl: 'Адрес сервера', serverUrl: 'Адрес сервера',
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com', serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
username: 'Имя пользователя', username: 'Логин',
usernamePlaceholder: 'admin', usernamePlaceholder: 'admin',
password: 'Пароль', password: 'Пароль',
showPassword: 'Показать пароль', showPassword: 'Показать пароль',
hidePassword: 'Скрыть пароль', hidePassword: 'Скрыть пароль',
connect: 'Подключиться', connect: 'Подключиться',
connecting: 'Подключение…', connecting: 'Подключение…',
connected: 'Подключено!', connected: 'Готово!',
error: 'Не удалось подключиться — проверьте данные.', error: 'Не удалось подключиться — проверьте данные.',
urlRequired: 'Укажите адрес сервера.', urlRequired: 'Укажите адрес сервера.',
savedServers: 'Сохранённые серверы', savedServers: 'Сохранённые серверы',
addNew: 'Или добавьте новый сервер', addNew: 'Или добавить новый сервер',
}, },
connection: { connection: {
connected: 'Подключено', connected: 'Подключено',
@@ -297,7 +297,7 @@ export const ruTranslation = {
extern: 'Внешний', extern: 'Внешний',
offlineTitle: 'Нет связи с сервером', offlineTitle: 'Нет связи с сервером',
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.', offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
offlineModeBanner: 'Воспроизведение из локального кэша', offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
offlineLibraryTitle: 'Офлайн-библиотека', offlineLibraryTitle: 'Офлайн-библиотека',
offlineLibraryEmpty: offlineLibraryEmpty:
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».', 'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
@@ -342,7 +342,7 @@ export const ruTranslation = {
bulkRemoveFromPlaylist: 'Убрать из плейлиста', bulkRemoveFromPlaylist: 'Убрать из плейлиста',
bulkClear: 'Снять выделение', bulkClear: 'Снять выделение',
updaterAvailable: 'Доступно обновление', updaterAvailable: 'Доступно обновление',
updaterVersion: 'Версия {{version}} готова к установке', updaterVersion: 'Версия {{version}} готова',
updaterInstall: 'Установить и перезапустить', updaterInstall: 'Установить и перезапустить',
updaterDownloading: 'Скачивание…', updaterDownloading: 'Скачивание…',
updaterInstalling: 'Установка…', updaterInstalling: 'Установка…',
@@ -359,6 +359,7 @@ export const ruTranslation = {
languageZh: 'Китайский', languageZh: 'Китайский',
languageNb: 'Норвежский', languageNb: 'Норвежский',
languageRu: 'Русский', languageRu: 'Русский',
languageRu2: 'Русский 2',
font: 'Шрифт', font: 'Шрифт',
theme: 'Тема', theme: 'Тема',
appearance: 'Оформление', appearance: 'Оформление',
@@ -379,14 +380,14 @@ export const ruTranslation = {
serverFailed: 'Ошибка подключения.', serverFailed: 'Ошибка подключения.',
testBtn: 'Проверить', testBtn: 'Проверить',
testingBtn: 'Проверка…', testingBtn: 'Проверка…',
serverCompatible: 'Совместимо с: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Подключено', connected: 'Подключено',
failed: 'Ошибка', failed: 'Ошибка',
eqTitle: 'Эквалайзер', eqTitle: 'Эквалайзер',
eqEnabled: 'Включить эквалайзер', eqEnabled: 'Включить эквалайзер',
eqPreset: 'Пресет', eqPreset: 'Пресет',
eqPresetCustom: 'Свой', eqPresetCustom: 'Свой',
eqPresetBuiltin: 'Встроенные пресеты', eqPresetBuiltin: 'Встроенные',
eqPresetCustomGroup: 'Мои пресеты', eqPresetCustomGroup: 'Мои пресеты',
eqSavePreset: 'Сохранить как пресет', eqSavePreset: 'Сохранить как пресет',
eqPresetName: 'Название пресета…', eqPresetName: 'Название пресета…',
@@ -408,21 +409,23 @@ export const ruTranslation = {
lfmConnected: 'Аккаунт', lfmConnected: 'Аккаунт',
lfmDisconnect: 'Отключить', lfmDisconnect: 'Отключить',
lfmConnectDesc: lfmConnectDesc:
одключите аккаунт Last.fm для включения скробблинга и обновления "Сейчас играет" напрямую из Psysonic — без необходимости настройки Navidrome.', ривяжите Last.fm для скробблинга и статуса «Сейчас слушаю» прямо из Psysonic — настраивать Navidrome не нужно.',
lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.', lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.',
lfmScrobbles: '{{n}} скробблов', lfmScrobbles: 'Скробблов: {{n}}',
lfmMemberSince: 'Участник с {{year}} года', lfmMemberSince: 'С {{year}} года',
scrobbleEnabled: 'Скробблинг включён', scrobbleEnabled: 'Скробблинг включён',
scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% воспроизведения', scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% трека',
behavior: 'Поведение', behavior: 'Поведение',
cacheTitle: 'Макс. размер кэша', cacheTitle: 'Макс. размер кэша',
cacheDesc: cacheDesc:
'Обложки и фото исполнителей. При переполнении старые записи удаляются. Сохраненные альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.', 'Обложки и фото исполнителей. При переполнении старые записи удаляются. Офлайн-альбомы не трогаются автоматически, но сотрутся при полной очистке кэша.',
cacheUsedImages: 'Изображения:', cacheUsedImages: 'Изображения:',
cacheUsedOffline: 'Сохраненные треки:', cacheUsedOffline: 'Офлайн-треки:',
cacheUsedHot: 'На диске:',
hotCacheTrackCount: 'Треков в кэше:',
cacheMaxLabel: 'Лимит', cacheMaxLabel: 'Лимит',
cacheClearBtn: 'Очистить кэш', cacheClearBtn: 'Очистить кэш',
cacheClearWarning: 'Будут удалены и все сохраненные альбомы.', cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
cacheClearConfirm: 'Очистить всё', cacheClearConfirm: 'Очистить всё',
cacheClearCancel: 'Отмена', cacheClearCancel: 'Отмена',
offlineDirTitle: 'Офлайн-библиотека (в приложении)', offlineDirTitle: 'Офлайн-библиотека (в приложении)',
@@ -431,6 +434,19 @@ export const ruTranslation = {
offlineDirChange: 'Сменить папку', offlineDirChange: 'Сменить папку',
offlineDirClear: 'Сбросить по умолчанию', offlineDirClear: 'Сбросить по умолчанию',
offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.', offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.',
hotCacheTitle: 'Горячий кэш воспроизведения',
hotCacheAlphaBadge: 'Альфа',
hotCacheDisclaimer: 'Заранее подгружает следующие треки из очереди и сохраняет предыдущие. При включении использует место на диске и сеть.',
hotCacheDirDefault: 'По умолчанию (данные приложения)',
hotCacheDirChange: 'Сменить папку',
hotCacheDirClear: 'Сбросить по умолчанию',
hotCacheDirHint: 'При смене папки сбрасывается список в приложении; уже скачанные файлы остаются на старом пути, пока вы их не удалите.',
hotCacheEnabled: 'Включить горячий кэш воспроизведения',
hotCacheMaxMb: 'Максимальный размер кэша',
hotCacheDebounce: 'Задержка после изменения очереди',
hotCacheDebounceImmediate: 'Сразу',
hotCacheDebounceSeconds: '{{n}} с',
hotCacheClearBtn: 'Очистить горячий кэш',
showArtistImages: 'Фото исполнителей', showArtistImages: 'Фото исполнителей',
showArtistImagesDesc: showArtistImagesDesc:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
@@ -438,16 +454,14 @@ export const ruTranslation = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей', minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.', minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
discordRichPresence: 'Статус в Discord (Rich Presence)', discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc: discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord (Rich Presence). Нужен запущенный клиент Discord.', 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
nowPlayingEnabled: 'Показывать в «Сейчас играет»', nowPlayingEnabled: 'Показывать в «Сейчас играет»',
nowPlayingEnabledDesc: nowPlayingEnabledDesc:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.', 'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
lyricsServerFirst: 'Приоритет серверного текста', downloadsTitle: 'Экспорт ZIP и архивы',
lyricsServerFirstDesc: 'Сначала запрашивать текст с сервера (теги, sidecar-файлы), затем LRCLIB. Отключите, чтобы использовать LRCLIB первым.', downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
downloadsTitle: 'Экспорт в ZIP и архивирование',
downloadsFolderDesc: 'Куда сохранять альбомы в виде ZIP на диск.',
downloadsDefault: 'Папка «Загрузки» по умолчанию', downloadsDefault: 'Папка «Загрузки» по умолчанию',
pickFolder: 'Выбрать', pickFolder: 'Выбрать',
pickFolderTitle: 'Папка для загрузок', pickFolderTitle: 'Папка для загрузок',
@@ -455,9 +469,9 @@ export const ruTranslation = {
logout: 'Выйти', logout: 'Выйти',
aboutTitle: 'О Psysonic', aboutTitle: 'О Psysonic',
aboutDesc: aboutDesc:
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). Построен на Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain (выравнивание громкости), жанры и много тем оформления.', 'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
aboutFeatures: aboutFeatures:
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain (выравнивание громкости) · жанры · десятки тем · несколько языков', 'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
aboutLicense: 'Лицензия', aboutLicense: 'Лицензия',
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.', aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
aboutRepo: 'Исходный код на GitHub', aboutRepo: 'Исходный код на GitHub',
@@ -534,7 +548,7 @@ export const ruTranslation = {
preloadBalanced: 'Умеренно (за 30 с до конца)', preloadBalanced: 'Умеренно (за 30 с до конца)',
preloadEarly: 'Рано (через 5 с после старта)', preloadEarly: 'Рано (через 5 с после старта)',
preloadCustom: 'Свой интервал', preloadCustom: 'Свой интервал',
preloadCustomSeconds: '{{n}} секунд до конца', preloadCustomSeconds: 'Секунд до конца: {{n}}',
infiniteQueue: 'Бесконечная очередь', infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится', infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально', experimental: 'Экспериментально',
@@ -590,7 +604,7 @@ export const ruTranslation = {
s5: 'Скробблинг', s5: 'Скробблинг',
q16: 'Как устроен скробблинг?', q16: 'Как устроен скробблинг?',
a16: a16:
'Psysonic отправляет прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.', 'Psysonic шлёт прослушивания напрямую на Last.fm — в Navidrome ничего настраивать не нужно. Подключите аккаунт в настройках.',
q17: 'Когда уходит скроббл?', q17: 'Когда уходит скроббл?',
a17: 'После прослушивания примерно половины трека.', a17: 'После прослушивания примерно половины трека.',
q22: 'Что за волна внизу?', q22: 'Что за волна внизу?',
@@ -613,13 +627,13 @@ export const ruTranslation = {
s6: 'Если что-то не так', s6: 'Если что-то не так',
q18: 'Медленно грузятся обложки.', q18: 'Медленно грузятся обложки.',
a18: a18:
'Первый раз картинки загружаются с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.', 'Первый раз картинки тянутся с сервера и кэшируются на ~30 дней. Медленный диск на сервере даёт задержку только при первом заходе.',
q19: 'Тест соединения не проходит.', q19: 'Тест соединения не проходит.',
a19: a19:
'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локальной сети иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.', 'Проверьте адрес с портом (http://192.168.1.100:4533), файрвол, для локалки иногда нужен http вместо https. Логин и пароль должны совпадать с сервером.',
q20: 'Нет звука в Linux.', q20: 'Нет звука в Linux.',
a20: a20:
'Убедитесь, что запущены PipeWire или PulseAudio.', 'Есть пакеты .deb, .rpm и AUR. Убедитесь, что запущены PipeWire или PulseAudio.',
q21: 'Чёрный экран в Linux.', q21: 'Чёрный экран в Linux.',
a21: a21:
'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.', 'Часто драйвер GPU / WebKitGTK. Запуск с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. В официальных пакетах это часто уже задано.',
@@ -640,7 +654,7 @@ export const ruTranslation = {
q35: 'Как сохранить альбом офлайн?', q35: 'Как сохранить альбом офлайн?',
a35: a35:
'Страница альбома → иконка загрузки в шапке. Прогресс на кнопке, после загрузки — индикатор готовности. Список — в офлайн-библиотеке.', 'Страница альбома → иконка загрузки в шапке. Прогресс на кнопке, после загрузки — индикатор готовности. Список — в офлайн-библиотеке.',
q36: 'Сколько места занимает кэш?', q36: 'Сколько места занимает офлайн?',
a36: a36:
'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.', 'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.',
}, },
@@ -748,18 +762,15 @@ export const ruTranslation = {
volume: 'Громкость', volume: 'Громкость',
toggleQueue: 'Очередь', toggleQueue: 'Очередь',
lyrics: 'Текст', lyrics: 'Текст',
fsLyricsToggle: 'Текст в полноэкранном режиме',
lyricsLoading: 'Загрузка текста…', lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден', lyricsNotFound: 'Текст не найден',
lyricsSourceServer: 'Источник: сервер',
lyricsSourceLrclib: 'Источник: LRCLIB',
}, },
songInfo: { songInfo: {
title: 'О треке', title: 'О треке',
songTitle: 'Название', songTitle: 'Название',
artist: 'Исполнитель', artist: 'Исполнитель',
album: 'Альбом', album: 'Альбом',
albumArtist: 'Исполнитель альбома', albumArtist: 'Альбомный исполнитель',
year: 'Год', year: 'Год',
genre: 'Жанр', genre: 'Жанр',
duration: 'Длительность', duration: 'Длительность',
@@ -803,7 +814,7 @@ export const ruTranslation = {
noSuggestions: 'Пока нет рекомендаций.', noSuggestions: 'Пока нет рекомендаций.',
titleBadge: 'Плейлист', titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку', refreshSuggestions: 'Обновить подборку',
addSong: 'Добавить в плейлист', addSong: 'В плейлист',
cacheOffline: 'Сохранить плейлист офлайн', cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён', offlineCached: 'Плейлист сохранён',
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)', offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
+15
View File
@@ -400,6 +400,8 @@ export const zhTranslation = {
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。', cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
cacheUsedImages: '图片:', cacheUsedImages: '图片:',
cacheUsedOffline: '离线曲目:', cacheUsedOffline: '离线曲目:',
cacheUsedHot: '占用空间:',
hotCacheTrackCount: '缓存曲目数:',
cacheMaxLabel: '最大容量', cacheMaxLabel: '最大容量',
cacheClearBtn: '清除缓存', cacheClearBtn: '清除缓存',
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。', cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
@@ -411,6 +413,19 @@ export const zhTranslation = {
offlineDirChange: '更改目录', offlineDirChange: '更改目录',
offlineDirClear: '重置为默认', offlineDirClear: '重置为默认',
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。', offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
hotCacheTitle: '热播放缓存',
hotCacheAlphaBadge: '预览',
hotCacheDisclaimer: '预加载队列中即将播放的曲目并保留近期已播放的。开启后占用磁盘与网络。',
hotCacheDirDefault: '默认(应用数据)',
hotCacheDirChange: '更改目录',
hotCacheDirClear: '重置为默认',
hotCacheDirHint: '更换目录会重置应用内索引;已下载的文件仍留在原路径,需自行删除。',
hotCacheEnabled: '启用热播放缓存',
hotCacheMaxMb: '最大缓存大小',
hotCacheDebounce: '队列变更去抖',
hotCacheDebounceImmediate: '立即',
hotCacheDebounceSeconds: '{{n}} 秒',
hotCacheClearBtn: '清空热缓存',
showArtistImages: '显示艺术家图片', showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。', showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
showTrayIcon: '显示托盘图标', showTrayIcon: '显示托盘图标',
+184 -1
View File
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell'; import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
@@ -181,6 +182,12 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; 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() { export default function Settings() {
const auth = useAuthStore(); const auth = useAuthStore();
const theme = useThemeStore(); const theme = useThemeStore();
@@ -189,6 +196,13 @@ export default function Settings() {
const gs = useGlobalShortcutsStore(); const gs = useGlobalShortcutsStore();
const serverId = auth.activeServerId ?? ''; const serverId = auth.activeServerId ?? '';
const clearAllOffline = useOfflineStore(s => s.clearAll); 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 [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null); const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -205,6 +219,7 @@ export default function Settings() {
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null); const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null); const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null); const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false);
@@ -218,7 +233,8 @@ export default function Settings() {
if (activeTab !== 'storage') return; if (activeTab !== 'storage') return;
getImageCacheSize().then(setImageCacheBytes); getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); 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 () => { const handleClearCache = useCallback(async () => {
setClearing(true); 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 pickDownloadFolder = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') }); const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
if (selected && typeof selected === 'string') { if (selected && typeof selected === 'string') {
@@ -788,6 +813,164 @@ export default function Settings() {
</div> </div>
</section> </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 */} {/* ZIP Export & Archiving */}
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
+20
View File
@@ -47,6 +47,13 @@ interface AuthState {
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
hotCacheMaxMb: number;
hotCacheDebounceSec: number;
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
// Status // Status
isLoggedIn: boolean; isLoggedIn: boolean;
isConnecting: boolean; isConnecting: boolean;
@@ -88,6 +95,10 @@ interface AuthState {
setShowFullscreenLyrics: (v: boolean) => void; setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
logout: () => void; logout: () => void;
// Derived // Derived
@@ -131,6 +142,10 @@ export const useAuthStore = create<AuthState>()(
showFullscreenLyrics: true, showFullscreenLyrics: true,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
isLoggedIn: false, isLoggedIn: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
@@ -207,6 +222,11 @@ export const useAuthStore = create<AuthState>()(
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
logout: () => set({ isLoggedIn: false }), logout: () => set({ isLoggedIn: false }),
getBaseUrl: () => { getBaseUrl: () => {
+183
View File
@@ -0,0 +1,183 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { Track } from './playerStore';
const PREFETCH_AHEAD = 5;
export interface HotCacheEntry {
localPath: string;
sizeBytes: number;
cachedAt: number;
/** Last time this file was started as the current track (eviction tie-break: newer = keep longer). */
lastPlayedAt?: number;
}
interface HotCacheState {
/** Persisted map `${serverId}:${trackId}` → file meta */
entries: Record<string, HotCacheEntry>;
getLocalUrl: (trackId: string, serverId: string) => string | null;
setEntry: (trackId: string, serverId: string, localPath: string, sizeBytes: number) => void;
/** Bump LRU when the user actually plays this track (if it is in the hot cache). */
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Respects queue tail first, protects current + next N. */
evictToFit: (
queue: Track[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
hotCacheCustomDir: string | null,
) => Promise<void>;
clearAllDisk: (customDir: string | null) => Promise<void>;
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function parseKey(key: string): { serverId: string; trackId: string } | null {
const i = key.indexOf(':');
if (i <= 0) return null;
return { serverId: key.slice(0, i), trackId: key.slice(i + 1) };
}
function lruStamp(meta: HotCacheEntry | undefined): number {
if (!meta) return 0;
return meta.lastPlayedAt ?? meta.cachedAt ?? 0;
}
export const useHotCacheStore = create<HotCacheState>()(
persist(
(set, get) => ({
entries: {},
getLocalUrl: (trackId, serverId) => {
const e = get().entries[entryKey(serverId, trackId)];
if (!e?.localPath) return null;
return `psysonic-local://${e.localPath}`;
},
setEntry: (trackId, serverId, localPath, sizeBytes) => {
const now = Date.now();
set(s => ({
entries: {
...s.entries,
[entryKey(serverId, trackId)]: {
localPath,
sizeBytes,
cachedAt: now,
lastPlayedAt: now,
},
},
}));
},
touchPlayed: (trackId, serverId) => {
const k = entryKey(serverId, trackId);
set(s => {
const e = s.entries[k];
if (!e) return s;
return {
entries: {
...s.entries,
[k]: { ...e, lastPlayedAt: Date.now() },
},
};
});
},
removeEntry: (trackId, serverId) => {
set(s => {
const next = { ...s.entries };
delete next[entryKey(serverId, trackId)];
return { entries: next };
});
},
totalBytes: () =>
Object.values(get().entries).reduce((acc, e) => acc + (e.sizeBytes || 0), 0),
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, hotCacheCustomDir) => {
if (maxBytes <= 0) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + PREFETCH_AHEAD);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(t => t.id === trackId);
return idx >= 0 ? idx : null;
};
let entries = { ...get().entries };
let sum = Object.values(entries).reduce((a, e) => a + (e.sizeBytes || 0), 0);
if (sum <= maxBytes) return;
const keys = Object.keys(entries);
type Cand = { key: string; tier: number; primary: number; lru: number };
const cands: Cand[] = [];
for (const key of keys) {
const parsed = parseKey(key);
if (!parsed) continue;
const { serverId, trackId } = parsed;
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
const meta = entries[key];
const lru = lruStamp(meta);
if (serverId !== activeServerId) {
cands.push({ key, tier: 0, primary: 0, lru });
continue;
}
const qIdx = indexOfInQueue(trackId);
if (qIdx === null) {
cands.push({ key, tier: 1, primary: 0, lru });
} else if (qIdx > protectHi) {
cands.push({ key, tier: 2, primary: -qIdx, lru });
} else if (qIdx < protectLo) {
cands.push({ key, tier: 3, primary: qIdx, lru });
}
}
cands.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.primary !== b.primary) return a.primary - b.primary;
return a.lru - b.lru;
});
for (const { key } of cands) {
if (sum <= maxBytes) break;
const meta = entries[key];
if (!meta) continue;
const parsed = parseKey(key);
if (!parsed) continue;
await invoke('delete_hot_cache_track', {
localPath: meta.localPath,
customDir: hotCacheCustomDir || null,
}).catch(() => {});
sum -= meta.sizeBytes || 0;
delete entries[key];
}
set({ entries });
},
clearAllDisk: async (customDir: string | null) => {
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
},
}),
{
name: 'psysonic-hot-cache',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
},
),
);
+25 -5
View File
@@ -3,10 +3,13 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic'; import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore'; import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
export interface Track { export interface Track {
id: string; id: string;
@@ -209,6 +212,11 @@ radioAudio.addEventListener('suspend', () => {
// Used to suppress ghost-commands from stale IPC arriving after the switch. // Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0; let lastGaplessSwitchTime = 0;
function touchHotCacheOnPlayback(trackId: string, serverId: string) {
if (!trackId || !serverId) return;
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
// Track ID that has already been sent to audio_chain_preload / audio_preload. // Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over // Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download. // the last 30 seconds of a track, each spawning its own HTTP download.
@@ -230,6 +238,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
// ─── Audio event handlers (called from initAudioListeners) ─────────────────── // ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) { function handleAudioPlaying(_duration: number) {
setDeferHotCachePrefetch(false);
usePlayerStore.setState({ isPlaying: true }); usePlayerStore.setState({ isPlaying: true });
} }
@@ -281,7 +290,7 @@ function handleAudioProgress(current_time: number, duration: number) {
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) { if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id; gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? ''; const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id); const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
if (gaplessEnabled) { if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in // Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is // advance. By the time the track boundary arrives, the next source is
@@ -390,6 +399,7 @@ function handleAudioTrackSwitched(duration: number) {
}); });
} }
syncQueueToServer(queue, nextTrack, 0); syncQueueToServer(queue, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
} }
function handleAudioError(message: string) { function handleAudioError(message: string) {
@@ -721,7 +731,8 @@ export const usePlayerStore = create<PlayerState>()(
}); });
const authState = useAuthStore.getState(); const authState = useAuthStore.getState();
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id); setDeferHotCachePrefetch(true);
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
const replayGainDb = authState.replayGainEnabled const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null ? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null; : null;
@@ -735,6 +746,7 @@ export const usePlayerStore = create<PlayerState>()(
manual, manual,
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err); console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
setTimeout(() => { setTimeout(() => {
@@ -757,6 +769,7 @@ export const usePlayerStore = create<PlayerState>()(
}); });
} }
syncQueueToServer(newQueue, track, 0); syncQueueToServer(newQueue, track, 0);
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
}, },
// ── pause / resume / togglePlay ────────────────────────────────────────── // ── pause / resume / togglePlay ──────────────────────────────────────────
@@ -784,6 +797,7 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_resume').catch(console.error); invoke('audio_resume').catch(console.error);
isAudioPaused = false; isAudioPaused = false;
set({ isPlaying: true }); set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else { } else {
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play. // Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
const gen = ++playGeneration; const gen = ++playGeneration;
@@ -801,7 +815,9 @@ export const usePlayerStore = create<PlayerState>()(
: null; : null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null; const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? ''; const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id); setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', { invoke('audio_play', {
url: coldUrl, url: coldUrl,
volume: vol, volume: vol,
@@ -815,6 +831,7 @@ export const usePlayerStore = create<PlayerState>()(
} }
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
}); });
@@ -828,7 +845,9 @@ export const usePlayerStore = create<PlayerState>()(
: null; : null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? ''; const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id); setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', { invoke('audio_play', {
url: coldUrl, url: coldUrl,
volume: vol, volume: vol,
@@ -838,6 +857,7 @@ export const usePlayerStore = create<PlayerState>()(
manual: false, manual: false,
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
}); });
+10
View File
@@ -0,0 +1,10 @@
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
let deferHotCachePrefetch = false;
export function setDeferHotCachePrefetch(v: boolean): void {
deferHotCachePrefetch = v;
}
export function getDeferHotCachePrefetch(): boolean {
return deferHotCachePrefetch;
}
+12
View File
@@ -0,0 +1,12 @@
import { buildStreamUrl } from '../api/subsonic';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
/** Offline library → hot playback cache → HTTP stream. */
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
if (offline) return offline;
const hot = useHotCacheStore.getState().getLocalUrl(trackId, serverId);
if (hot) return hot;
return buildStreamUrl(trackId);
}