From a78c0fe9acca7511da7b049e44533d3f5bcfa066 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 9 Apr 2026 16:52:27 +0200 Subject: [PATCH] fix: font switching, layout scaling, embedded lyrics, folder opening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(fonts): update CSS var declarations to @fontsource-variable naming ('Inter Variable', 'Outfit Variable', etc.) so dynamic font switching works - fix(layout): 100vh → 100% on .app-shell to fix Windows WebView2 playerbar drift; 1fr → minmax(0,1fr) in all grid-template-rows + remove min-height: 720px to fix Linux playerbar disappearing at high zoom or small window sizes - fix(updater): replace shell open() with Rust open_folder command to bypass shell:allow-open capability scope blocking local paths on Windows - fix(lyrics): add get_embedded_lyrics Tauri command (id3 crate for MP3 SYLT/USLT, lofty for FLAC SYNCEDLYRICS/LYRICS); fix parseLrc regex for LRC without fractional seconds; fix SubsonicStructuredLyrics to accept both synced and issynced fields Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/Cargo.lock | 60 +++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 135 ++++++++++++++++++++++++++++++++++ src/api/lrclib.ts | 4 +- src/api/subsonic.ts | 7 +- src/components/AppUpdater.tsx | 11 +-- src/hooks/useLyrics.ts | 44 ++++++++++- src/styles/layout.css | 28 ++++--- src/styles/theme.css | 50 +++++++------ 9 files changed, 296 insertions(+), 45 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 86e2f099..ae5f9500 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -907,6 +907,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -2058,6 +2064,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "id3" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "flate2", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -2420,6 +2437,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" version = "0.4.29" @@ -2881,6 +2924,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ogg_pager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2979,6 +3031,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3344,6 +3402,8 @@ dependencies = [ "biquad", "discord-rich-presence", "futures-util", + "id3", + "lofty", "md5", "reqwest 0.12.28", "ringbuf", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 656817d5..f88c2f6e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,8 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] discord-rich-presence = "0.2" url = "2" thread-priority = "1" +lofty = "0.22" +id3 = "1.16.4" [patch.crates-io] # Local patch for Symphonia's isomp4 demuxer: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32e6573b..d090bf20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -713,6 +713,139 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) - } } +/// Reads embedded synced / unsynced lyrics from a local audio file. +/// +/// Priority order: +/// MP3 → ID3v2 SYLT (synchronized, ms timestamps) → ID3v2 USLT (plain) +/// FLAC → Vorbis SYNCEDLYRICS (LRC string) → Vorbis LYRICS (plain) +/// +/// Returns a standard LRC string (`[mm:ss.cc]line\n…`) for synced lyrics, +/// or plain text for unsynced lyrics. Returns `None` when no lyrics are found. +/// Errors are silenced and mapped to `None` so the frontend falls through to the +/// next lyrics source without crashing. +#[tauri::command] +fn get_embedded_lyrics(path: String) -> Option { + use lofty::file::FileType; + use lofty::prelude::*; + use lofty::probe::Probe; + + let fpath = std::path::Path::new(&path); + if !fpath.exists() { + return None; + } + + // Detect file type from magic bytes only — no full tag read yet. + // guess_file_type() consumes self and returns Self, so reassign. + let probe = Probe::open(fpath).ok()?; + let probe = probe.guess_file_type().ok()?; + let file_type = probe.file_type(); + + // ── MP3 / MPEG: use the `id3` crate for SYLT / USLT ───────────────────── + // lofty's MpegFile::id3v2_tag field is pub(crate) — not accessible here. + // The `id3` crate exposes a clean public API for typed ID3v2 frames. + if matches!(file_type, Some(FileType::Mpeg)) { + use id3::{Content, Tag as Id3Tag}; + + if let Ok(tag) = Id3Tag::read_from_path(fpath) { + // 1. SYLT — millisecond-timestamped synced lyrics. + for frame in tag.frames() { + if frame.id() != "SYLT" { + continue; + } + if let Content::SynchronisedLyrics(sylt) = frame.content() { + // Only accept millisecond timestamps — MPEG-frame-based + // timestamps can't be converted to wall-clock seconds. + if sylt.timestamp_format != id3::frame::TimestampFormat::Ms { + continue; + } + let lrc: String = sylt + .content + .iter() + .filter_map(|(ms, text)| { + let t = text.trim(); + if t.is_empty() { + return None; + } + let mins = ms / 60_000; + let secs = (ms % 60_000) / 1_000; + let cs = (ms % 1_000) / 10; + // [mm:ss.cc] matches parseLrc's /\d+(?:\.\d*)?/ regex + Some(format!("[{:02}:{:02}.{:02}]{}\n", mins, secs, cs, t)) + }) + .collect(); + if !lrc.is_empty() { + return Some(lrc.trim_end().to_owned()); + } + } + } + + // 2. USLT — unsynchronized lyrics, plain-text fallback. + for frame in tag.frames() { + if frame.id() != "USLT" { + continue; + } + if let Content::Lyrics(uslt) = frame.content() { + let text = uslt.text.trim(); + if !text.is_empty() { + return Some(text.to_owned()); + } + } + } + } + return None; // MPEG file but no usable lyrics found + } + + // ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ──────────────────── + // Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field. + // It is not a known lofty ItemKey, so access it via ItemKey::Unknown. + let tagged = probe.read().ok()?; + for tag in tagged.tags() { + if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) { + let lrc = lrc.trim(); + if !lrc.is_empty() { + return Some(lrc.to_owned()); + } + } + if let Some(plain) = tag.get_string(&ItemKey::Lyrics) { + let plain = plain.trim(); + if !plain.is_empty() { + return Some(plain.to_owned()); + } + } + } + + None +} + +/// Opens a directory in the OS file manager (Explorer / Finder / Nautilus). +/// Uses platform-specific process spawning — tauri-plugin-shell's open() only +/// allows https:// URLs per the capability scope and fails silently for paths. +#[tauri::command] +fn open_folder(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer.exe") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + Ok(()) +} + /// Progress payload emitted to the frontend during a ZIP download. /// `total` is `None` when the server doesn't send a `Content-Length` header /// (Navidrome on-the-fly ZIPs). @@ -1310,6 +1443,8 @@ pub fn run() { download_zip, check_arch_linux, download_update, + open_folder, + get_embedded_lyrics, ]) .run(tauri::generate_context!()) .expect("error while running Psysonic"); diff --git a/src/api/lrclib.ts b/src/api/lrclib.ts index 10301c25..bf3f4fec 100644 --- a/src/api/lrclib.ts +++ b/src/api/lrclib.ts @@ -36,7 +36,9 @@ export async function fetchLyrics( export function parseLrc(lrc: string): LrcLine[] { const lines: LrcLine[] = []; for (const line of lrc.split('\n')) { - const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/); + // \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches. + // parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly. + const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/); if (!match) continue; const mins = parseInt(match[1], 10); const secs = parseFloat(match[2]); diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 84465c69..7974e35a 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -856,7 +856,10 @@ export interface SubsonicLyricLine { } export interface SubsonicStructuredLyrics { - issynced: boolean; + /** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */ + synced?: boolean; + /** Legacy / alternate casing used by some older Subsonic-compatible servers. */ + issynced?: boolean; lang?: string; offset?: number; displayArtist?: string; @@ -878,7 +881,7 @@ export async function getLyricsBySongId(id: string): Promise l.issynced) ?? list[0]; + return list.find(l => l.synced || l.issynced) ?? list[0]; } catch { // Server doesn't support the endpoint or track has no embedded lyrics return null; diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index 9aef9001..a4f9008d 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -200,13 +200,10 @@ export default function AppUpdater() { }; const handleShowFolder = async () => { - try { - const dir = await dirname(dlPath); - await open(dir); - } catch { - // fallback: try opening the file path directly - await open(dlPath).catch(() => {}); - } + // tauri-plugin-shell's open() only allows https:// per capability scope — + // local paths are blocked and fail silently. Delegate to Rust instead. + const dir = await dirname(dlPath); + await invoke('open_folder', { path: dir }); }; const pct = dlProgress.total > 0 diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts index 8faa27ee..e93b6b23 100644 --- a/src/hooks/useLyrics.ts +++ b/src/hooks/useLyrics.ts @@ -1,10 +1,13 @@ import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib'; import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; +import { useOfflineStore } from '../store/offlineStore'; +import { useHotCacheStore } from '../store/hotCacheStore'; import type { Track } from '../store/playerStore'; -export type LyricsSource = 'server' | 'lrclib'; +export type LyricsSource = 'server' | 'lrclib' | 'embedded'; export interface CachedLyrics { syncedLines: LrcLine[] | null; @@ -20,7 +23,9 @@ export const lyricsCache = new Map(); export function parseStructuredLyrics( lyrics: SubsonicStructuredLyrics, ): Pick { - if (lyrics.issynced && lyrics.line.length > 0) { + // Accept both `synced` (OpenSubsonic spec) and `issynced` (legacy servers). + const isSynced = !!(lyrics.synced ?? lyrics.issynced); + if (isSynced && lyrics.line.length > 0) { const lines: LrcLine[] = lyrics.line .filter(l => l.start !== undefined) .map(l => ({ time: l.start! / 1000, text: l.value.trim() })) @@ -79,6 +84,37 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { setLoading(false); }; + // For offline / hot-cached tracks we have the file locally — read SYLT / + // SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing. + // Fast path: both store lookups are synchronous; returns false immediately + // for streaming tracks so it has zero impact on the normal fetch sequence. + const fetchEmbedded = async (): Promise => { + const serverId = useAuthStore.getState().activeServerId ?? ''; + const localUrl = + useOfflineStore.getState().getLocalUrl(currentTrack.id, serverId) ?? + useHotCacheStore.getState().getLocalUrl(currentTrack.id, serverId); + if (!localUrl) return false; + + const prefix = 'psysonic-local://'; + const filePath = localUrl.startsWith(prefix) ? localUrl.slice(prefix.length) : null; + if (!filePath) return false; + + try { + const lrcString = await invoke('get_embedded_lyrics', { path: filePath }); + if (!lrcString) return false; + + const lines = parseLrc(lrcString); + const synced = lines.length > 0 ? lines : null; + const plain = synced ? null : (lrcString.trim() || null); + if (!synced && !plain) return false; + + store({ syncedLines: synced, plainLyrics: plain, source: 'embedded', notFound: false }); + return true; + } catch { + return false; + } + }; + const fetchServer = async (): Promise => { const structured = await getLyricsBySongId(currentTrack.id); if (!structured) return false; @@ -107,6 +143,10 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { }; (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]; diff --git a/src/styles/layout.css b/src/styles/layout.css index d1241b58..321579e7 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -13,16 +13,22 @@ position: relative; display: grid; grid-template-columns: var(--sidebar-width) 1fr var(--queue-width); - grid-template-rows: 1fr var(--player-height); + /* minmax(0, 1fr) instead of plain 1fr: CSS Grid spec treats 1fr as + minmax(auto, 1fr), meaning the row won't shrink below its min-content + size. With minmax(0, 1fr) the row can collapse to zero, keeping the + player bar row always visible regardless of content size or zoom level. */ + grid-template-rows: minmax(0, 1fr) var(--player-height); grid-template-areas: "sidebar main queue" "player player player"; - height: 100vh; - /* overflow: hidden keeps the player bar pinned at the bottom even when the - window is dragged below the OS minHeight constraint (ignored on some - Linux WMs/compositors). Without this, the 1fr row's implicit auto - min-height can push the grid taller than 100vh, scrolling the player - bar out of view. */ + /* height: 100% inherits from html/body/#root (all 100%) so the grid + tracks actual window resize events on every platform. + 100vh was used previously but WebView2 (Windows) does not update the + CSS viewport synchronously on resize, causing the player-bar grid row + to drift off-screen. overflow: hidden still prevents the 1fr row from + pushing the grid taller than the window on Linux WMs that ignore + the OS minHeight constraint. */ + height: 100%; overflow: hidden; background: var(--bg-app); } @@ -33,7 +39,7 @@ } .app-shell[data-titlebar] { - grid-template-rows: var(--titlebar-height) 1fr var(--player-height); + grid-template-rows: var(--titlebar-height) minmax(0, 1fr) var(--player-height); grid-template-areas: "titlebar titlebar titlebar" "sidebar main queue" @@ -1748,7 +1754,7 @@ /* ─── Grid Overrides ─── */ .app-shell[data-mobile] { grid-template-columns: 1fr; - grid-template-rows: 1fr auto auto; + grid-template-rows: minmax(0, 1fr) auto auto; grid-template-areas: "main" "bottomnav" @@ -1758,7 +1764,7 @@ } .app-shell[data-mobile][data-titlebar] { - grid-template-rows: var(--titlebar-height) 1fr auto auto; + grid-template-rows: var(--titlebar-height) minmax(0, 1fr) auto auto; grid-template-areas: "titlebar" "main" @@ -1833,7 +1839,7 @@ /* When the mobile player is active, the app-shell becomes a single full-screen area with no BottomNav or PlayerBar — just the main content. */ .app-shell[data-mobile-player] { - grid-template-rows: 1fr; + grid-template-rows: minmax(0, 1fr); grid-template-areas: "main"; } diff --git a/src/styles/theme.css b/src/styles/theme.css index 1d3be17c..39765b7f 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3144,8 +3144,8 @@ /* ─── Global Base Settings ─── */ :root { /* Typography */ - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-display: 'Space Grotesk', 'Inter', sans-serif; + --font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif; + --font-display: 'Space Grotesk Variable', 'Inter Variable', sans-serif; /* Spacing */ --space-1: 4px; @@ -3514,8 +3514,9 @@ html, body, #root { height: 100%; - min-width: 1280px; - min-height: 720px; + /* min-height removed: Tauri enforces the real window minimum (minHeight: 480). + A CSS min-height larger than the effective CSS viewport (which shrinks at + zoom > 1) pushes html taller than the window, clipping the player bar. */ overflow: hidden; } @@ -6318,49 +6319,54 @@ input[type="range"]:hover::-webkit-slider-thumb { } /* ─── Font overrides ─── */ +[data-font='inter'] { + --font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif; + --font-display: 'Inter Variable', sans-serif; +} + [data-font='outfit'] { - --font-sans: 'Outfit', system-ui, sans-serif; - --font-display: 'Outfit', sans-serif; + --font-sans: 'Outfit Variable', system-ui, sans-serif; + --font-display: 'Outfit Variable', sans-serif; } [data-font='dm-sans'] { - --font-sans: 'DM Sans', system-ui, sans-serif; - --font-display: 'DM Sans', sans-serif; + --font-sans: 'DM Sans Variable', system-ui, sans-serif; + --font-display: 'DM Sans Variable', sans-serif; } [data-font='nunito'] { - --font-sans: 'Nunito', system-ui, sans-serif; - --font-display: 'Nunito', sans-serif; + --font-sans: 'Nunito Variable', system-ui, sans-serif; + --font-display: 'Nunito Variable', sans-serif; } [data-font='rubik'] { - --font-sans: 'Rubik', system-ui, sans-serif; - --font-display: 'Rubik', sans-serif; + --font-sans: 'Rubik Variable', system-ui, sans-serif; + --font-display: 'Rubik Variable', sans-serif; } [data-font='space-grotesk'] { - --font-sans: 'Space Grotesk', system-ui, sans-serif; - --font-display: 'Space Grotesk', sans-serif; + --font-sans: 'Space Grotesk Variable', system-ui, sans-serif; + --font-display: 'Space Grotesk Variable', sans-serif; } [data-font='figtree'] { - --font-sans: 'Figtree', system-ui, sans-serif; - --font-display: 'Figtree', sans-serif; + --font-sans: 'Figtree Variable', system-ui, sans-serif; + --font-display: 'Figtree Variable', sans-serif; } [data-font='manrope'] { - --font-sans: 'Manrope', system-ui, sans-serif; - --font-display: 'Manrope', sans-serif; + --font-sans: 'Manrope Variable', system-ui, sans-serif; + --font-display: 'Manrope Variable', sans-serif; } [data-font='plus-jakarta-sans'] { - --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; - --font-display: 'Plus Jakarta Sans', sans-serif; + --font-sans: 'Plus Jakarta Sans Variable', system-ui, sans-serif; + --font-display: 'Plus Jakarta Sans Variable', sans-serif; } [data-font='lexend'] { - --font-sans: 'Lexend', system-ui, sans-serif; - --font-display: 'Lexend', sans-serif; + --font-sans: 'Lexend Variable', system-ui, sans-serif; + --font-display: 'Lexend Variable', sans-serif; } /* ─── Equalizer bars animation ─── */