fix: font switching, layout scaling, embedded lyrics, folder opening

- 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 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-09 16:52:27 +02:00
parent 9c57d4f887
commit a78c0fe9ac
9 changed files with 296 additions and 45 deletions
+3 -1
View File
@@ -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]);
+5 -2
View File
@@ -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<SubsonicStructuredL
);
const list = data.lyricsList?.structuredLyrics;
if (!list || list.length === 0) return null;
return list.find(l => 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;
+4 -7
View File
@@ -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
+42 -2
View File
@@ -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<string, CachedLyrics>();
export function parseStructuredLyrics(
lyrics: SubsonicStructuredLyrics,
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
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<boolean> => {
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<string | null>('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<boolean> => {
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];
+17 -11
View File
@@ -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";
}
+28 -22
View File
@@ -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 ─── */