From 20dabbfd034e5b0c57c0ba5e042a717e02bce16d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 23:57:36 +0200 Subject: [PATCH] feat(lyrics): configurable sources with drag-to-reorder + feat(audio): ReplayGain pre-gain & fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lyrics Sources: - Replace lyricsServerFirst + enableNeteaselyrics toggles with a new Settings section — three sources (Server, LRCLIB, Netease) each individually toggled and drag-reorderable via psy-drop DnD - useLyrics.ts iterates sources in user-defined order, skipping disabled ones; embedded SYLT from local files still wins unconditionally - onRehydrateStorage migration from legacy fields on first load ReplayGain Pre-Gain: - New authStore fields: replayGainPreGainDb (0…+6 dB) and replayGainFallbackDb (-6…0 dB, untagged / radio) - audio.rs compute_gain applies pre_gain_db and fallback_db - Settings sliders under ReplayGain mode selector - Radio HTML5 volume scaled by fallbackDb factor on playRadio i18n(ru): incorporate PR #148 translation improvements (kilyabin) All 7 locales updated for new keys. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/audio.rs | 18 ++-- src/hooks/useLyrics.ts | 29 +++--- src/locales/de.ts | 7 ++ src/locales/en.ts | 7 ++ src/locales/fr.ts | 7 ++ src/locales/nb.ts | 7 ++ src/locales/nl.ts | 7 ++ src/locales/ru.ts | 7 ++ src/locales/zh.ts | 7 ++ src/pages/Settings.tsx | 188 ++++++++++++++++++++++++++++++++++----- src/store/authStore.ts | 43 +++++++++ src/store/playerStore.ts | 24 +++-- 12 files changed, 303 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 140d24c1..896465ef 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1822,11 +1822,13 @@ const MASTER_HEADROOM: f32 = 0.891_254; fn compute_gain( replay_gain_db: Option, replay_gain_peak: Option, + pre_gain_db: f32, + fallback_db: f32, volume: f32, ) -> (f32, f32) { let gain_linear = replay_gain_db - .map(|db| 10f32.powf(db / 20.0)) - .unwrap_or(1.0); + .map(|db| 10f32.powf((db + pre_gain_db) / 20.0)) + .unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)); let peak = replay_gain_peak.unwrap_or(1.0).max(0.001); let gain_linear = gain_linear.min(1.0 / peak); let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); @@ -1842,6 +1844,8 @@ pub async fn audio_play( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + pre_gain_db: f32, + fallback_db: f32, manual: bool, // true = user-initiated skip → bypass crossfade, start immediately hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) app: AppHandle, @@ -1931,7 +1935,7 @@ pub async fn audio_play( return Ok(()); } - let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume); + let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); // Manual skips (user-initiated) bypass crossfade — the track should start immediately. let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual; @@ -2176,6 +2180,8 @@ pub async fn audio_chain_preload( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + pre_gain_db: f32, + fallback_db: f32, hi_res_enabled: bool, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -2236,7 +2242,7 @@ pub async fn audio_chain_preload( let raw_bytes = Arc::new(data); - let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume); + let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); let done_next = Arc::new(AtomicBool::new(false)); // Use a dedicated counter for the chained source — it will be swapped into @@ -2616,9 +2622,11 @@ pub fn audio_update_replay_gain( volume: f32, replay_gain_db: Option, replay_gain_peak: Option, + pre_gain_db: f32, + fallback_db: f32, state: State<'_, AudioEngine>, ) { - let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, volume); + let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); let mut cur = state.current.lock().unwrap(); cur.replay_gain_linear = gain_linear; cur.base_volume = volume.clamp(0.0, 1.0); diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts index 7b3e8984..af7b63ea 100644 --- a/src/hooks/useLyrics.ts +++ b/src/hooks/useLyrics.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { invoke } from '@tauri-apps/api/core'; import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib'; import { fetchNeteaselyrics } from '../api/netease'; @@ -47,8 +48,7 @@ export interface UseLyricsResult { export function useLyrics(currentTrack: Track | null): UseLyricsResult { const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined; - const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst); - const enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics); + const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); const [loading, setLoading] = useState(!cached && !!currentTrack); const [syncedLines, setSyncedLines] = useState(cached?.syncedLines ?? null); @@ -159,29 +159,30 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { } }; + const fetchFns: Record Promise> = { + server: fetchServer, + lrclib: fetchLrclibFn, + netease: fetchNetease, + }; + (async () => { // Embedded lyrics from local file always win (most accurate SYLT data). if (cancelled) return; if (await fetchEmbedded()) return; - const [first, second] = lyricsServerFirst - ? [fetchServer, fetchLrclibFn] - : [fetchLrclibFn, fetchServer]; - - if (cancelled) return; - if (await first()) return; - if (cancelled) return; - if (await second()) return; - // Netease as last fallback — only when explicitly enabled - if (enableNeteaselyrics) { + // Try enabled sources in user-defined order. + for (const src of lyricsSources) { + if (!src.enabled) continue; + const fn = fetchFns[src.id]; + if (!fn) continue; if (cancelled) return; - if (await fetchNetease()) return; + if (await fn()) return; } if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true }); })(); return () => { cancelled = true; }; - }, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps + }, [currentTrack?.id, lyricsSources]); // eslint-disable-line react-hooks/exhaustive-deps return { syncedLines, plainLyrics, source, loading, notFound }; } diff --git a/src/locales/de.ts b/src/locales/de.ts index f402b52e..9ee08fdf 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -491,6 +491,11 @@ export const deTranslation = { lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.', enableNeteaselyrics: 'Netease Cloud Music Lyrics', enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.', + lyricsSourcesTitle: 'Lyrics-Quellen', + lyricsSourcesDesc: 'Wähle, welche Quellen für Lyrics abgefragt werden und in welcher Reihenfolge. Ziehen zum Sortieren. Deaktivierte Quellen werden übersprungen.', + lyricsSourceServer: 'Server', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'ZIP-Export & Archivierung', downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.', downloadsDefault: 'Standard-Downloads-Ordner', @@ -574,6 +579,8 @@ export const deTranslation = { replayGainMode: 'Modus', replayGainTrack: 'Track', replayGainAlbum: 'Album', + replayGainPreGain: 'Pre-Gain (getaggte Dateien)', + replayGainFallback: 'Fallback (ohne Tags / Radio)', crossfade: 'Crossfade', crossfadeDesc: 'Überblendung zwischen Tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/en.ts b/src/locales/en.ts index c414254e..9351a9a1 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -492,6 +492,11 @@ export const enTranslation = { lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.', enableNeteaselyrics: 'Netease Cloud Music lyrics', enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.', + lyricsSourcesTitle: 'Lyrics Sources', + lyricsSourcesDesc: 'Choose which sources to query for lyrics and in what order. Drag to reorder. Disabled sources are skipped entirely.', + lyricsSourceServer: 'Server', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'ZIP Export & Archiving', downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.', downloadsDefault: 'Default Downloads Folder', @@ -575,6 +580,8 @@ export const enTranslation = { replayGainMode: 'Mode', replayGainTrack: 'Track', replayGainAlbum: 'Album', + replayGainPreGain: 'Pre-Gain (tagged files)', + replayGainFallback: 'Fallback (untagged / radio)', crossfade: 'Crossfade', crossfadeDesc: 'Fade between tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 277a6b46..e4ca4039 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -489,6 +489,11 @@ export const frTranslation = { lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.', enableNeteaselyrics: 'Paroles Netease Cloud Music', enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.', + lyricsSourcesTitle: 'Sources de paroles', + lyricsSourcesDesc: 'Choisissez quelles sources interroger et dans quel ordre. Glissez pour réordonner. Les sources désactivées sont ignorées.', + lyricsSourceServer: 'Serveur', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'Export ZIP & Archivage', downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.', downloadsDefault: 'Dossier de téléchargement par défaut', @@ -572,6 +577,8 @@ export const frTranslation = { replayGainMode: 'Mode', replayGainTrack: 'Piste', replayGainAlbum: 'Album', + replayGainPreGain: 'Pré-Gain (fichiers taggés)', + replayGainFallback: 'Repli (sans tags / radio)', crossfade: 'Fondu enchaîné', crossfadeDesc: 'Fondu entre les pistes', crossfadeSecs: '{{n}} s', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index e869f066..3f749378 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -488,6 +488,11 @@ export const nbTranslation = { lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.', enableNeteaselyrics: 'Netease Cloud Music sangtekster', enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.', + lyricsSourcesTitle: 'Sangtekstkilder', + lyricsSourcesDesc: 'Velg hvilke kilder som skal brukes og i hvilken rekkefølge. Dra for å sortere. Deaktiverte kilder hoppes over.', + lyricsSourceServer: 'Tjener', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'ZIP Eksport & Arkivering', downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.', downloadsDefault: 'Standard nedlastingsmappe', @@ -571,6 +576,8 @@ export const nbTranslation = { replayGainMode: 'Modus', replayGainTrack: 'Spor', replayGainAlbum: 'Album', + replayGainPreGain: 'Pre-Gain (taggede filer)', + replayGainFallback: 'Reserveverdi (uten tagger / radio)', crossfade: 'Crossfade', crossfadeDesc: 'Tone mellom spor', crossfadeSecs: '{{n}}s', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 2f7a9b0d..dca90491 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -489,6 +489,11 @@ export const nlTranslation = { lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.', enableNeteaselyrics: 'Netease Cloud Music songteksten', enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.', + lyricsSourcesTitle: 'Songtekstbronnen', + lyricsSourcesDesc: 'Kies welke bronnen worden geraadpleegd en in welke volgorde. Sleep om te sorteren. Uitgeschakelde bronnen worden overgeslagen.', + lyricsSourceServer: 'Server', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'ZIP-export & Archivering', downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.', downloadsDefault: 'Standaard downloadmap', @@ -572,6 +577,8 @@ export const nlTranslation = { replayGainMode: 'Modus', replayGainTrack: 'Nummer', replayGainAlbum: 'Album', + replayGainPreGain: 'Pre-Gain (getagde bestanden)', + replayGainFallback: 'Terugval (zonder tags / radio)', crossfade: 'Overgang', crossfadeDesc: 'Fade tussen nummers', crossfadeSecs: '{{n}} s', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 7d5ba274..82104200 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -512,6 +512,11 @@ export const ruTranslation = { lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы сначала использовать LRCLIB.', enableNeteaselyrics: 'Тексты из Netease Cloud Music', enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда остальные способы не находят ничего. Лучшее покрытие для азиатской и международной музыки.', + lyricsSourcesTitle: 'Источники текстов', + lyricsSourcesDesc: 'Выберите источники для поиска текстов и их порядок. Перетаскивайте для сортировки. Отключённые источники пропускаются.', + lyricsSourceServer: 'Сервер', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: 'Netease Cloud Music', downloadsTitle: 'Экспорт ZIP и архивов', downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.', downloadsDefault: 'Папка «Загрузки» по умолчанию', @@ -600,6 +605,8 @@ export const ruTranslation = { replayGainMode: 'Режим', replayGainTrack: 'По треку', replayGainAlbum: 'По альбому', + replayGainPreGain: 'Предусиление (файлы с тегами)', + replayGainFallback: 'Резерв (без тегов / радио)', crossfade: 'Кроссфейд', crossfadeDesc: 'Плавный переход между треками', crossfadeSecs: '{{n}} с', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 0861159e..3426b55c 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -485,6 +485,11 @@ export const zhTranslation = { lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。', enableNeteaselyrics: '网易云音乐歌词', enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。', + lyricsSourcesTitle: '歌词来源', + lyricsSourcesDesc: '选择要查询的歌词来源及其顺序。拖动以排序。已禁用的来源将被跳过。', + lyricsSourceServer: '服务器', + lyricsSourceLrclib: 'LRCLIB', + lyricsSourceNetease: '网易云音乐', downloadsTitle: 'ZIP 导出与归档', downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。', downloadsDefault: '默认下载文件夹', @@ -568,6 +573,8 @@ export const zhTranslation = { replayGainMode: '模式', replayGainTrack: '曲目', replayGainAlbum: '专辑', + replayGainPreGain: '预增益(有标签文件)', + replayGainFallback: '回退增益(无标签 / 收音机)', crossfade: '交叉淡入淡出', crossfadeDesc: '曲目间淡入淡出', crossfadeSecs: '{{n}} 秒', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f06b8c2c..22db22cb 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -18,7 +18,8 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; -import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore'; +import { useShallow } from 'zustand/react/shallow'; +import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; @@ -500,6 +501,38 @@ export default function Settings() { )} + {auth.replayGainEnabled && ( +
+
+ + {t('settings.replayGainPreGain')} + + auth.setReplayGainPreGainDb(Number(e.target.value))} + style={{ flex: 1, maxWidth: 160 }} + /> + + {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB + +
+
+ + {t('settings.replayGainFallback')} + + auth.setReplayGainFallbackDb(Number(e.target.value))} + style={{ flex: 1, maxWidth: 160 }} + /> + + {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB + +
+
+ )}
@@ -862,31 +895,12 @@ export default function Settings() {
-
-
-
-
{t('settings.lyricsServerFirst')}
-
{t('settings.lyricsServerFirstDesc')}
-
- -
-
-
-
-
{t('settings.enableNeteaselyrics')}
-
{t('settings.enableNeteaselyricsDesc')}
-
- -
+ {/* Lyrics Sources */} + + {/* Random Mix */}
@@ -2049,6 +2063,134 @@ function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'lib ); } +// ── Lyrics Sources Customizer ────────────────────────────────────────────── + +const LYRICS_SOURCE_LABEL_KEYS: Record = { + server: 'settings.lyricsSourceServer', + lrclib: 'settings.lyricsSourceLrclib', + netease: 'settings.lyricsSourceNetease', +}; + +type LyricsDropTarget = { idx: number; before: boolean } | null; + +function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) { + const { t } = useTranslation(); + const { onMouseDown } = useDragSource(() => ({ + data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }), + label, + })); + return ( + + + + ); +} + +function LyricsSourcesCustomizer() { + const { t } = useTranslation(); + const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); + const setLyricsSources = useAuthStore(s => s.setLyricsSources); + const { isDragging: isPsyDragging } = useDragDrop(); + const containerRef = useRef(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(null); + const sourcesRef = useRef(lyricsSources); + sourcesRef.current = lyricsSources; + + useEffect(() => { + if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } + }, [isPsyDragging]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onPsyDrop = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: { type?: string; index?: number }; + try { parsed = JSON.parse(detail.data as string); } catch { return; } + if (parsed.type !== 'lyrics_source_reorder' || parsed.index == null) return; + + const fromIdx = parsed.index; + const target = dropTargetRef.current; + dropTargetRef.current = null; setDropTarget(null); + if (!target) return; + + const insertBefore = target.before ? target.idx : target.idx + 1; + if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; + + const next = [...sourcesRef.current]; + const [moved] = next.splice(fromIdx, 1); + next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + setLyricsSources(next); + }; + el.addEventListener('psy-drop', onPsyDrop); + return () => el.removeEventListener('psy-drop', onPsyDrop); + }, [setLyricsSources]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isPsyDragging || !containerRef.current) return; + const rows = containerRef.current.querySelectorAll('[data-lyrics-idx]'); + let target: LyricsDropTarget = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.lyricsIdx); + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } + target = { idx, before: false }; + } + dropTargetRef.current = target; + setDropTarget(target); + }; + + const toggleSource = (id: LyricsSourceId) => { + setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s)); + }; + + return ( +
+
+ +

{t('settings.lyricsSourcesTitle')}

+
+

+ {t('settings.lyricsSourcesDesc')} +

+
+ {lyricsSources.map((src, i) => { + const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]); + const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; + const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before; + return ( +
+ + {label} + +
+ ); + })} +
+
+ ); +} + +// ── Sidebar Customizer ────────────────────────────────────────────────────── + type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; function SidebarCustomizer() { diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 17743de9..8853095c 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -19,6 +19,15 @@ export interface ServerProfile { export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; +export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; +export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } + +const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [ + { id: 'server', enabled: true }, + { id: 'lrclib', enabled: true }, + { id: 'netease', enabled: false }, +]; + interface AuthState { // Multi-server servers: ServerProfile[]; @@ -39,6 +48,8 @@ interface AuthState { customGenreBlacklist: string[]; replayGainEnabled: boolean; replayGainMode: 'track' | 'album'; + replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) + replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) crossfadeEnabled: boolean; crossfadeSecs: number; gaplessEnabled: boolean; @@ -54,6 +65,7 @@ interface AuthState { nowPlayingEnabled: boolean; lyricsServerFirst: boolean; enableNeteaselyrics: boolean; + lyricsSources: LyricsSourceConfig[]; showFullscreenLyrics: boolean; showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; @@ -157,6 +169,8 @@ interface AuthState { setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; setReplayGainMode: (v: 'track' | 'album') => void; + setReplayGainPreGainDb: (v: number) => void; + setReplayGainFallbackDb: (v: number) => void; setCrossfadeEnabled: (v: boolean) => void; setCrossfadeSecs: (v: number) => void; setGaplessEnabled: (v: boolean) => void; @@ -172,6 +186,7 @@ interface AuthState { setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; setEnableNeteaselyrics: (v: boolean) => void; + setLyricsSources: (sources: LyricsSourceConfig[]) => void; setShowFullscreenLyrics: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; @@ -244,6 +259,8 @@ export const useAuthStore = create()( customGenreBlacklist: [], replayGainEnabled: false, replayGainMode: 'track', + replayGainPreGainDb: 0, + replayGainFallbackDb: 0, crossfadeEnabled: false, crossfadeSecs: 3, gaplessEnabled: false, @@ -259,6 +276,7 @@ export const useAuthStore = create()( nowPlayingEnabled: false, lyricsServerFirst: true, enableNeteaselyrics: false, + lyricsSources: DEFAULT_LYRICS_SOURCES, showFullscreenLyrics: true, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', @@ -353,6 +371,14 @@ export const useAuthStore = create()( set({ replayGainMode: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); }, + setReplayGainPreGainDb: (v) => { + set({ replayGainPreGainDb: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, + setReplayGainFallbackDb: (v) => { + set({ replayGainFallbackDb: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }), @@ -368,6 +394,7 @@ export const useAuthStore = create()( setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }), + setLyricsSources: (sources) => set({ lyricsSources: sources }), setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), @@ -527,6 +554,21 @@ export const useAuthStore = create()( state.hotCacheEnabled && state.preloadMode !== 'off' ? { hotCacheEnabled: false, preloadMode: 'off' as const } : {}; + + // Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time). + let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {}; + try { + const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record }; + if (!raw?.state?.lyricsSources) { + const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true; + const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false; + const migrated: LyricsSourceConfig[] = serverFirst + ? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }] + : [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }]; + lyricsSourcesMigrated = { lyricsSources: migrated }; + } + } catch { /* ignore */ } + useAuthStore.setState({ mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), @@ -535,6 +577,7 @@ export const useAuthStore = create()( (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, ), ...conflictingLegacyState, + ...lyricsSourcesMigrated, }); }, } diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 9d5387dc..c6669441 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -378,6 +378,8 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, + preGainDb: authState.replayGainPreGainDb, + fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, }).catch(() => {}); } @@ -780,7 +782,9 @@ export const usePlayerStore = create()( .catch(() => station.streamUrl); // Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering. radioAudio.src = streamUrl; - radioAudio.volume = volume; + const { replayGainFallbackDb } = useAuthStore.getState(); + const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1; + radioAudio.volume = Math.min(1, volume * fallbackFactor); radioAudio.play().catch((err: unknown) => { console.error('[psysonic] radio HTML5 play failed:', err); showToast('Radio stream error', 3000, 'error'); @@ -853,6 +857,8 @@ export const usePlayerStore = create()( durationHint: track.duration, replayGainDb, replayGainPeak, + preGainDb: authState.replayGainPreGainDb, + fallbackDb: authState.replayGainFallbackDb, manual, hiResEnabled: authState.enableHiRes, }).catch((err: unknown) => { @@ -938,8 +944,10 @@ export const usePlayerStore = create()( volume: vol, durationHint: trackToPlay.duration, replayGainDb: replayGainDbCold, - manual: false, replayGainPeak: replayGainPeakCold, + preGainDb: authStateCold.replayGainPreGainDb, + fallbackDb: authStateCold.replayGainFallbackDb, + manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, }).then(() => { if (playGeneration === gen && currentTime > 1) { @@ -970,6 +978,8 @@ export const usePlayerStore = create()( durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, + preGainDb: authStateCold.replayGainPreGainDb, + fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, }).catch((err: unknown) => { @@ -1309,10 +1319,12 @@ export const usePlayerStore = create()( ? (currentTrack.replayGainPeak ?? null) : null; - invoke('audio_update_replay_gain', { - volume, - replayGainDb, - replayGainPeak + invoke('audio_update_replay_gain', { + volume, + replayGainDb, + replayGainPeak, + preGainDb: authState.replayGainPreGainDb, + fallbackDb: authState.replayGainFallbackDb, }).catch(console.error); }, }),