chore(playback): remove Preload Next Track setting (#1007)

* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
This commit is contained in:
cucadmuh
2026-06-06 01:01:08 +03:00
committed by GitHub
parent a66d932afe
commit 40db6b08d2
22 changed files with 39 additions and 235 deletions
+9
View File
@@ -60,6 +60,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
## Fixed
### Servers — complete border on the active server card
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
-60
View File
@@ -203,65 +203,6 @@ export function StorageTab() {
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>
@@ -280,7 +221,6 @@ export function StorageTab() {
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));
+1 -1
View File
@@ -57,7 +57,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
{ tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
-2
View File
@@ -20,7 +20,6 @@ type AnalysisPrunePendingResult = {
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
const { queueItems, currentTrack, queueIndex } = usePlayerStore.getState();
const { preloadMode } = useAuthStore.getState();
const rawServerId = getPlaybackServerId() ?? '';
const server = useAuthStore.getState().servers.find(s => s.id === rawServerId);
const serverId = server ? serverIndexKeyFromUrl(server.url) : rawServerId;
@@ -42,7 +41,6 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
queueItems,
queueIndex,
currentTrack,
preloadMode,
);
const sig = JSON.stringify({ keepTrackIds, middleTrackIds, serverId });
if (sig === lastAnalysisPruneSig) return;
+1 -9
View File
@@ -490,15 +490,7 @@ export const settings = {
trackPreviewLocation_favorites: 'In Favoriten',
trackPreviewLocation_artist: 'In Künstler-Top-Tracks',
trackPreviewLocation_randomMix: 'In Random Mix',
preloadMode: 'Nächsten Track vorpuffern',
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
nextTrackBufferingTitle: 'Pufferung',
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
preloadOff: 'Aus',
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
preloadCustom: 'Benutzerdefiniert',
preloadCustomSeconds: 'Sekunden vor Ende: {{n}}',
infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
experimental: 'Experimentell',
@@ -551,7 +543,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Aggressiver Hintergrundscan. Nach Abschluss erhältst du BPM für alle Tracks sowie sofort verfügbare Loudness- und Waveform-Daten.',
analyticsStrategyPriorityTitle: 'Analyse-Priorität (immer aktiv)',
analyticsStrategyPriorityHigh: '1 — Aktuell laufender Track: zuerst herunterladen und analysieren (Playback-Bytes, Loudness-Backfill oder HTTP falls nötig).',
analyticsStrategyPriorityMiddle: '2 — Als Nächstes: die nächsten ~5 Queue-Tracks, Preload-next und Hot-Cache-Nachbarn — Analyse bevorzugt aus Cache/Downloads; HTTP-Backfill nur bei Miss.',
analyticsStrategyPriorityMiddle: '2 — Als Nächstes: die nächsten ~5 Queue-Tracks und Hot-Cache-Nachbarn — Analyse bevorzugt aus Cache/Downloads; HTTP-Backfill nur bei Miss.',
analyticsStrategyPriorityLow: '3 — Bibliotheks-Batch (nur Aggressiv): automatische Hintergrund-Queue am Ende; wird per Watermark (~3× Worker) nachgefüllt, ohne auf eine leere Queue zu warten.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysefortschritt:',
+1 -9
View File
@@ -334,7 +334,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'More aggressive background scan. Once finished, you get BPM for every track plus instant loudness + waveform loads.',
analyticsStrategyPriorityTitle: 'Analysis priority (always enforced)',
analyticsStrategyPriorityHigh: '1 — Currently playing: download + analyze first (playback bytes, loudness backfill, or HTTP when needed).',
analyticsStrategyPriorityMiddle: '2 — Up next: the next ~5 queue tracks, preload next, and hot-cache neighbours — analyze from cached/downloaded bytes when possible; HTTP backfill only on miss.',
analyticsStrategyPriorityMiddle: '2 — Up next: the next ~5 queue tracks and hot-cache neighbours — analyze from cached/downloaded bytes when possible; HTTP backfill only on miss.',
analyticsStrategyPriorityLow: '3 — Library batch (Aggressive only): automatic background queue at the tail; refilled by watermark (~3× workers) without waiting for an empty queue.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysis progress:',
@@ -577,15 +577,7 @@ export const settings = {
trackPreviewLocation_favorites: 'In favorites',
trackPreviewLocation_artist: 'In artist top tracks',
trackPreviewLocation_randomMix: 'In Random Mix',
preloadMode: 'Preload Next Track',
preloadModeDesc: 'When to start buffering the next track in the queue',
nextTrackBufferingTitle: 'Buffering',
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
preloadOff: 'Off',
preloadBalanced: 'Balanced (30 s before end)',
preloadEarly: 'Early (after 5 s of playback)',
preloadCustom: 'Custom',
preloadCustomSeconds: 'Seconds before end: {{n}}',
infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental',
+1 -9
View File
@@ -489,15 +489,7 @@ export const settings = {
trackPreviewLocation_favorites: 'En favoritos',
trackPreviewLocation_artist: 'En las mejores pistas del artista',
trackPreviewLocation_randomMix: 'En Random Mix',
preloadMode: 'Precargar Siguiente Pista',
preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola',
nextTrackBufferingTitle: 'Buffer',
preloadHotCacheMutualExclusive: 'Precarga y Caché Activa sirven para lo mismo — solo uno puede estar activo a la vez. Habilitar uno desactivará automáticamente el otro.',
preloadOff: 'Apagado',
preloadBalanced: 'Balanceado (30 s antes del final)',
preloadEarly: 'Temprano (después de 5 s de reproducción)',
preloadCustom: 'Personalizado',
preloadCustomSeconds: 'Segundos antes del final: {{n}}',
infiniteQueue: 'Cola Infinita',
infiniteQueueDesc: 'Agregar automáticamente pistas aleatorias cuando la cola termina',
experimental: 'Experimental',
@@ -551,7 +543,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Escaneo en segundo plano más agresivo. Al terminar, obtienes BPM para todas las pistas y carga inmediata de loudness y forma de onda.',
analyticsStrategyPriorityTitle: 'Prioridad de análisis (siempre activa)',
analyticsStrategyPriorityHigh: '1 — Reproducción actual: descargar y analizar primero (bytes de reproducción, backfill de loudness o HTTP cuando haga falta).',
analyticsStrategyPriorityMiddle: '2 — Siguientes: ~5 pistas de cola, precarga de la siguiente y vecinas de caché caliente; analiza desde caché/descarga cuando sea posible, con backfill HTTP solo en fallo.',
analyticsStrategyPriorityMiddle: '2 — Siguientes: ~5 pistas de cola y vecinas de caché caliente; analiza desde caché/descarga cuando sea posible, con backfill HTTP solo en fallo.',
analyticsStrategyPriorityLow: '3 — Lote de biblioteca (solo Agresiva): cola automática en segundo plano al final; se rellena por watermark (~3× workers) sin esperar a que quede vacía.',
analyticsStrategyServerLabel: 'Servidor',
analyticsStrategyProgressLabel: 'Progreso del análisis:',
+1 -9
View File
@@ -487,15 +487,7 @@ export const settings = {
trackPreviewLocation_favorites: 'Dans les favoris',
trackPreviewLocation_artist: 'Dans les meilleurs morceaux de l\'artiste',
trackPreviewLocation_randomMix: 'Dans Random Mix',
preloadMode: 'Précharger la piste suivante',
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
nextTrackBufferingTitle: 'Mise en mémoire tampon',
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
preloadOff: 'Désactivé',
preloadBalanced: 'Équilibré (30 s avant la fin)',
preloadEarly: 'Tôt (après 5 s de lecture)',
preloadCustom: 'Personnalisé',
preloadCustomSeconds: 'Secondes avant la fin : {{n}}',
infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
experimental: 'Expérimental',
@@ -551,7 +543,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Analyse de fond plus agressive. Une fois terminée, vous obtenez le BPM pour toutes les pistes et un chargement immédiat de loudness et de formes donde.',
analyticsStrategyPriorityTitle: 'Priorité danalyse (toujours appliquée)',
analyticsStrategyPriorityHigh: '1 — En cours de lecture : télécharger et analyser en premier (octets de lecture, backfill loudness ou HTTP si nécessaire).',
analyticsStrategyPriorityMiddle: '2 — À suivre : les ~5 pistes suivantes de la file, préchargement de la suivante et voisines du hot-cache — analyse depuis cache/téléchargements si possible, backfill HTTP seulement en cas dabsence.',
analyticsStrategyPriorityMiddle: '2 — À suivre : les ~5 pistes suivantes de la file et voisines du hot-cache — analyse depuis cache/téléchargements si possible, backfill HTTP seulement en cas dabsence.',
analyticsStrategyPriorityLow: '3 — Lot bibliothèque (Agressive uniquement) : file darrière-plan automatique en fin de file ; réalimentée par watermark (~3× workers) sans attendre une file vide.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Progression de lanalyse :',
+1 -9
View File
@@ -489,15 +489,7 @@ export const settings = {
infiniteQueue: 'Uendelig kø',
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
experimental: 'Eksperimentell',
preloadMode: 'Forhåndslast neste spor',
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
nextTrackBufferingTitle: 'Bufring',
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
preloadOff: 'Av',
preloadBalanced: 'Balansert (30 s før slutt)',
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
sidebarLyricsStyle: 'Rullestil for tekst',
sidebarLyricsStyleClassic: 'Klassisk',
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
@@ -552,7 +544,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Mer aggressiv bakgrunnsskanning. Når den er ferdig får du BPM for alle spor samt umiddelbar lasting av loudness og waveform.',
analyticsStrategyPriorityTitle: 'Analyseprioritet (alltid aktiv)',
analyticsStrategyPriorityHigh: '1 — Spiller nå: last ned og analyser først (avspillingsbytes, loudness-backfill eller HTTP ved behov).',
analyticsStrategyPriorityMiddle: '2 — Neste opp: de neste ~5 køsporene, preload av neste og hot-cache-naboer — analyser fra cache/nedlastede bytes når mulig; HTTP-backfill kun ved bom.',
analyticsStrategyPriorityMiddle: '2 — Neste opp: de neste ~5 køsporene og hot-cache-naboer — analyser fra cache/nedlastede bytes når mulig; HTTP-backfill kun ved bom.',
analyticsStrategyPriorityLow: '3 — Bibliotekbatch (kun Aggressiv): automatisk bakgrunnskø bakerst; fylles på ved watermark (~3× workers) uten å vente på tom kø.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysefremdrift:',
+1 -9
View File
@@ -487,15 +487,7 @@ export const settings = {
trackPreviewLocation_favorites: 'In favorieten',
trackPreviewLocation_artist: 'In top-tracks van artiest',
trackPreviewLocation_randomMix: 'In Random Mix',
preloadMode: 'Volgend nummer vooraf laden',
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
nextTrackBufferingTitle: 'Buffering',
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
preloadOff: 'Uit',
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
preloadEarly: 'Vroeg (na 5 s afspelen)',
preloadCustom: 'Aangepast',
preloadCustomSeconds: 'Seconden voor einde: {{n}}',
infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel',
@@ -551,7 +543,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Agressievere achtergrondscan. Na voltooiing krijg je BPM voor alle tracks plus directe loudness- en waveform-lading.',
analyticsStrategyPriorityTitle: 'Analyseprioriteit (altijd actief)',
analyticsStrategyPriorityHigh: '1 — Nu afspelen: eerst downloaden en analyseren (playback-bytes, loudness-backfill of HTTP indien nodig).',
analyticsStrategyPriorityMiddle: '2 — Hierna: de volgende ~5 wachtrijtracks, preload-next en hot-cache-buren — analyse vanuit cache/downloads waar mogelijk; HTTP-backfill alleen bij miss.',
analyticsStrategyPriorityMiddle: '2 — Hierna: de volgende ~5 wachtrijtracks en hot-cache-buren — analyse vanuit cache/downloads waar mogelijk; HTTP-backfill alleen bij miss.',
analyticsStrategyPriorityLow: '3 — Bibliotheekbatch (alleen Agressief): automatische achtergrondwachtrij achteraan; wordt bijgevuld via watermark (~3× workers) zonder op een lege wachtrij te wachten.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysevoortgang:',
+1 -9
View File
@@ -492,15 +492,7 @@ export const settings = {
trackPreviewLocation_favorites: 'În favorite',
trackPreviewLocation_artist: 'În top piese ale artistului',
trackPreviewLocation_randomMix: 'În Mixul Aleatoriu',
preloadMode: 'Preîncarcă Următoarea Piesă',
preloadModeDesc: 'Când să înceapă preîncărcarea următoarei piese din coadă',
nextTrackBufferingTitle: 'Preîncarcă',
preloadHotCacheMutualExclusive: 'Preîncarcarea și Hot Cache au același scop — doar una poate fi activată deodată. Pornirea unei opțiuni o dezactivează pe cealaltă.',
preloadOff: 'Oprit',
preloadBalanced: 'Balansat (30 s înainte de sfârșit)',
preloadEarly: 'Devreme (după 5 s de redare)',
preloadCustom: 'Personalizat',
preloadCustomSeconds: 'Secunde înainte de sfârșit: {{n}}',
infiniteQueue: 'Coadă Infinită',
infiniteQueueDesc: 'Adaugă automat piese aleatorii când coada se golește',
experimental: 'Experimental',
@@ -551,7 +543,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: 'Scanare de fundal mai agresivă. După finalizare, primești BPM pentru toate piesele și încărcare instantă pentru loudness și waveform.',
analyticsStrategyPriorityTitle: 'Prioritate analiză (mereu activă)',
analyticsStrategyPriorityHigh: '1 — Redare curentă: descarcă și analizează mai întâi (bytes de playback, loudness backfill sau HTTP când e nevoie).',
analyticsStrategyPriorityMiddle: '2 — Urmează: următoarele ~5 piese din coadă, preload pentru următoarea și vecinii din hot-cache — analiză din cache/descărcări când se poate; backfill HTTP doar la miss.',
analyticsStrategyPriorityMiddle: '2 — Urmează: următoarele ~5 piese din coadă și vecinii din hot-cache — analiză din cache/descărcări când se poate; backfill HTTP doar la miss.',
analyticsStrategyPriorityLow: '3 — Lot bibliotecă (doar Agresivă): coadă automată în fundal la final; reumplere pe watermark (~3× workers) fără a aștepta golirea cozii.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Progres analiză:',
+1 -9
View File
@@ -342,7 +342,7 @@ export const settings = {
'Запускает фоновый анализ всей библиотеки и скачивает треки для анализа.',
analyticsStrategyPriorityTitle: 'Приоритет анализа (всегда)',
analyticsStrategyPriorityHigh: '1 — Сейчас играет: скачивание и анализ в первую очередь (байты воспроизведения, loudness backfill или HTTP при необходимости).',
analyticsStrategyPriorityMiddle: '2 — Следующие: ~5 треков в очереди, preload next и соседи hot cache — анализ из уже скачанных байтов, HTTP backfill только при промахе.',
analyticsStrategyPriorityMiddle: '2 — Следующие: ~5 треков в очереди и соседи hot cache — анализ из уже скачанных байтов, HTTP backfill только при промахе.',
analyticsStrategyPriorityLow: '3 — Библиотечный batch (только Агрессивный): автоматическая фоновая очередь в хвосте; пополняется по watermark (~3× потоков), не дожидаясь полного опустошения.',
analyticsStrategyServerLabel: 'Сервер',
analyticsStrategyProgressLabel: 'Прогресс анализа:',
@@ -598,15 +598,7 @@ export const settings = {
trackPreviewLocation_favorites: 'В избранном',
trackPreviewLocation_artist: 'В топ-треках исполнителя',
trackPreviewLocation_randomMix: 'В Random Mix',
preloadMode: 'Предзагрузка следующего трека',
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
nextTrackBufferingTitle: 'Буферизация',
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
preloadOff: 'Выкл.',
preloadBalanced: 'Умеренно (за 30 с до конца)',
preloadEarly: 'Рано (через 5 с после старта)',
preloadCustom: 'Свой интервал',
preloadCustomSeconds: 'Секунд до конца: {{n}}',
infiniteQueue: 'Бесконечная очередь',
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
experimental: 'Экспериментально',
+1 -9
View File
@@ -486,15 +486,7 @@ export const settings = {
trackPreviewLocation_favorites: '收藏中',
trackPreviewLocation_artist: '艺人热门曲目中',
trackPreviewLocation_randomMix: 'Random Mix 中',
preloadMode: '预加载下一曲目',
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
nextTrackBufferingTitle: '缓冲',
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
preloadOff: '关闭',
preloadBalanced: '均衡(结束前30秒)',
preloadEarly: '提前(播放5秒后)',
preloadCustom: '自定义',
preloadCustomSeconds: '结束前秒数:{{n}}',
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
@@ -550,7 +542,7 @@ export const settings = {
analyticsStrategyAdvancedDesc: '更激进的后台扫描。完成后可获得所有歌曲 BPM,并可即时加载响度与波形。',
analyticsStrategyPriorityTitle: '分析优先级(始终生效)',
analyticsStrategyPriorityHigh: '1 — 当前播放:优先下载并分析(播放字节、响度回填,或在需要时走 HTTP)。',
analyticsStrategyPriorityMiddle: '2 — 即将播放:队列中接下来的约 5 首、预加载下一首以及热缓存邻近曲目——优先从缓存/已下载字节分析,未命中时才做 HTTP 回填。',
analyticsStrategyPriorityMiddle: '2 — 即将播放:队列中接下来的约 5 首及热缓存邻近曲目——优先从缓存/已下载字节分析,未命中时才做 HTTP 回填。',
analyticsStrategyPriorityLow: '3 — 库批处理(仅“激进”):自动后台队列排在末尾;按水位线(约 3× workers)补充,无需等待队列清空。',
analyticsStrategyServerLabel: '服务器',
analyticsStrategyProgressLabel: '分析进度:',
+7 -34
View File
@@ -207,46 +207,22 @@ export function handleAudioProgress(
markStoreProgressCommit(nowCommit);
}
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
// Pre-buffer / pre-chain next track for gapless and crossfade.
const {
gaplessEnabled,
preloadMode,
preloadCustomSeconds,
hotCacheEnabled,
crossfadeEnabled,
crossfadeSecs,
} = useAuthStore.getState();
const remaining = dur - current_time;
// Gapless chain: always triggers at 30s regardless of preloadMode.
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
// Even with preload mode OFF, crossfade needs the next track bytes ready before
// we enter the fade window to avoid a hard gap after track boundary.
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0 // balanced (default)
);
// Crossfade needs the next track bytes ready before the fade window.
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
const shouldBytePreloadForCrossfade =
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
const shouldBytePreload = !hotCacheEnabled && (
shouldBytePreloadFromMode ||
shouldBytePreloadForCrossfade
);
// Hot/offline cache: seed enrichment from disk (playback also uses psysonic-local://).
const shouldPreloadLocalFileAnalysis = preloadMode !== 'off' && (
preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0
);
!hotCacheEnabled && !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
if (shouldChainGapless || shouldBytePreloadForCrossfade || gaplessEnabled) {
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
// Next track for preload/chain. The resolver bridge keeps the window around
@@ -281,11 +257,10 @@ export function handleAudioProgress(
const serverId = getPlaybackCacheServerKey();
const analysisServerId = getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
const nextIsLocalFile = nextUrl.startsWith('psysonic-local://');
// Byte pre-download — runs early so bytes are cached by chain time.
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
if (
(shouldBytePreload || shouldBytePreloadForGaplessBackup || (shouldPreloadLocalFileAnalysis && nextIsLocalFile))
(shouldBytePreloadForCrossfade || shouldBytePreloadForGaplessBackup)
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
@@ -296,10 +271,8 @@ export function handleAudioProgress(
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreload,
shouldBytePreloadForCrossfade,
shouldBytePreloadForGaplessBackup,
shouldPreloadLocalFileAnalysis,
nextIsLocalFile,
remaining,
gaplessEnabled,
});
+2 -6
View File
@@ -6,23 +6,19 @@ type SetState = (
/**
* Persistent plumbing settings that don't fit a more specific domain:
* runtime logging level, Navidrome `getNowPlaying` toggle, preload
* mode + custom seconds, audiobook exclusion, genre blacklist.
* runtime logging level, Navidrome `getNowPlaying` toggle, audiobook
* exclusion, genre blacklist.
*/
export function createPlumbingSettingsActions(set: SetState): Pick<
AuthState,
| 'setLoggingMode'
| 'setNowPlayingEnabled'
| 'setPreloadMode'
| 'setPreloadCustomSeconds'
| 'setExcludeAudiobooks'
| 'setCustomGenreBlacklist'
> {
return {
setLoggingMode: (v) => set({ loggingMode: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setPreloadMode: (v) => set({ preloadMode: v }),
setPreloadCustomSeconds: (v) => set({ preloadCustomSeconds: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
};
+5 -20
View File
@@ -4,8 +4,7 @@
* Covers Zustand persist's hydration path (via `useAuthStore.persist.rehydrate()`):
* existing localStorage shapes load, missing fields default to the store's
* initial values, corrupt JSON does not crash bootstrap, the
* hotCacheEnabled / preloadMode'off' conflicting-legacy migration clears
* both, and a few smaller field-level migrations.
* legacy field stripping, and a few smaller field-level migrations.
*
* Also pins the **synchronous storage** invariant called out in `CLAUDE.md`
* ("never switch to async storage") regression §2 of the pre-refactor
@@ -53,7 +52,6 @@ describe('hydration — loads existing localStorage shape', () => {
expect(s.gaplessEnabled).toBe(false);
expect(s.replayGainEnabled).toBe(false);
expect(s.normalizationEngine).toBe('off');
expect(s.preloadMode).toBe('balanced');
});
it('preserves saved fields verbatim when present', async () => {
@@ -94,34 +92,21 @@ describe('hydration — corrupt / unexpected input', () => {
});
describe('onRehydrate migrations', () => {
it('clears the conflicting hotCacheEnabled + preloadMode≠"off" legacy combo', async () => {
it('keeps hotCacheEnabled when legacy preloadMode fields are present', async () => {
writePersistedState({
servers: [],
activeServerId: null,
hotCacheEnabled: true,
preloadMode: 'balanced',
});
await useAuthStore.persist.rehydrate();
const s = useAuthStore.getState();
expect(s.hotCacheEnabled).toBe(false);
expect(s.preloadMode).toBe('off');
});
it('keeps hotCacheEnabled when preloadMode was already "off"', async () => {
writePersistedState({
servers: [],
activeServerId: null,
hotCacheEnabled: true,
preloadMode: 'off',
preloadCustomSeconds: 45,
});
await useAuthStore.persist.rehydrate();
const s = useAuthStore.getState();
expect(s.hotCacheEnabled).toBe(true);
expect(s.preloadMode).toBe('off');
expect((s as { preloadMode?: unknown }).preloadMode).toBeUndefined();
expect((s as { preloadCustomSeconds?: unknown }).preloadCustomSeconds).toBeUndefined();
});
it('migrates a legacy `waveform` seekbarStyle to `truewave`', async () => {
+1 -9
View File
@@ -82,7 +82,6 @@ describe('trivial pass-through setters', () => {
['setMaxCacheMb', 'maxCacheMb', 2048],
['setHotCacheMaxMb', 'hotCacheMaxMb', 1024],
['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60],
['setPreloadCustomSeconds', 'preloadCustomSeconds', 45],
])('%s stores a numeric value', (setter, key, value) => {
(useAuthStore.getState() as unknown as Record<string, (v: unknown) => void>)[setter](value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value);
@@ -207,14 +206,7 @@ describe('replay-gain related setters (write through to player store)', () => {
});
});
describe('preload mode + discord cover source setters', () => {
it('setPreloadMode accepts off / balanced / early / custom', () => {
for (const mode of ['off', 'balanced', 'early', 'custom'] as const) {
useAuthStore.getState().setPreloadMode(mode);
expect(useAuthStore.getState().preloadMode).toBe(mode);
}
});
describe('discord cover source setters', () => {
it('setDiscordCoverSource accepts none / apple / server', () => {
for (const src of ['none', 'apple', 'server'] as const) {
useAuthStore.getState().setDiscordCoverSource(src);
-2
View File
@@ -59,8 +59,6 @@ export const useAuthStore = create<AuthState>()(
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
trackPreviewStartRatio: 0.33,
trackPreviewDurationSec: 30,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
preservePlayNextOrder: false,
showArtistImages: false,
+3 -6
View File
@@ -34,11 +34,9 @@ import type {
* and writes the one-shot Linux smooth-scroll migration sentinel.
*/
export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState> {
// If both hot cache and preload were enabled before mutual exclusion was enforced, reset both.
const conflictingLegacyState =
state.hotCacheEnabled && state.preloadMode !== 'off'
? { hotCacheEnabled: false, preloadMode: 'off' as const }
: {};
// Drop removed preload-next-track settings from legacy persist blobs.
delete (state as { preloadMode?: unknown }).preloadMode;
delete (state as { preloadCustomSeconds?: unknown }).preloadCustomSeconds;
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
// Only for an *existing* persisted state (upgrade from a build without
@@ -166,7 +164,6 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
loudnessPreIsRefV1: true,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...youLyPlusMigrated,
...wheelSmoothOneTime,
-4
View File
@@ -115,8 +115,6 @@ export interface AuthState {
trackPreviewStartRatio: number;
/** Preview window length in seconds. Default 30 s. */
trackPreviewDurationSec: number;
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean;
showArtistImages: boolean;
@@ -307,8 +305,6 @@ export interface AuthState {
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
setTrackPreviewStartRatio: (v: number) => void;
setTrackPreviewDurationSec: (v: number) => void;
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
+1 -9
View File
@@ -46,12 +46,11 @@ export function collectLoudnessBackfillWindowTrackIds(
return Array.from(ids);
}
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
/** Next ~5 queue neighbours for middle-tier analysis priority hints. */
export function collectPlaybackMiddlePriorityTrackIds(
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
): string[] {
const ids = new Set<string>();
const start = Math.max(0, queueIndex + 1);
@@ -60,13 +59,6 @@ export function collectPlaybackMiddlePriorityTrackIds(
const tid = queue[i]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
if (preloadMode !== 'off') {
const nextIdx = queueIndex + 1;
if (nextIdx >= 0 && nextIdx < queue.length) {
const tid = queue[nextIdx]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
}
return Array.from(ids);
}