mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
Generated
+60
@@ -907,6 +907,12 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -2058,6 +2064,17 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
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]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2420,6 +2437,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"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]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -2881,6 +2924,15 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ogg_pager"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -2979,6 +3031,12 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3344,6 +3402,8 @@ dependencies = [
|
|||||||
"biquad",
|
"biquad",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"id3",
|
||||||
|
"lofty",
|
||||||
"md5",
|
"md5",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"]
|
|||||||
discord-rich-presence = "0.2"
|
discord-rich-presence = "0.2"
|
||||||
url = "2"
|
url = "2"
|
||||||
thread-priority = "1"
|
thread-priority = "1"
|
||||||
|
lofty = "0.22"
|
||||||
|
id3 = "1.16.4"
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Local patch for Symphonia's isomp4 demuxer:
|
# Local patch for Symphonia's isomp4 demuxer:
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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.
|
/// Progress payload emitted to the frontend during a ZIP download.
|
||||||
/// `total` is `None` when the server doesn't send a `Content-Length` header
|
/// `total` is `None` when the server doesn't send a `Content-Length` header
|
||||||
/// (Navidrome on-the-fly ZIPs).
|
/// (Navidrome on-the-fly ZIPs).
|
||||||
@@ -1310,6 +1443,8 @@ pub fn run() {
|
|||||||
download_zip,
|
download_zip,
|
||||||
check_arch_linux,
|
check_arch_linux,
|
||||||
download_update,
|
download_update,
|
||||||
|
open_folder,
|
||||||
|
get_embedded_lyrics,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Psysonic");
|
.expect("error while running Psysonic");
|
||||||
|
|||||||
+3
-1
@@ -36,7 +36,9 @@ export async function fetchLyrics(
|
|||||||
export function parseLrc(lrc: string): LrcLine[] {
|
export function parseLrc(lrc: string): LrcLine[] {
|
||||||
const lines: LrcLine[] = [];
|
const lines: LrcLine[] = [];
|
||||||
for (const line of lrc.split('\n')) {
|
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;
|
if (!match) continue;
|
||||||
const mins = parseInt(match[1], 10);
|
const mins = parseInt(match[1], 10);
|
||||||
const secs = parseFloat(match[2]);
|
const secs = parseFloat(match[2]);
|
||||||
|
|||||||
+5
-2
@@ -856,7 +856,10 @@ export interface SubsonicLyricLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsonicStructuredLyrics {
|
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;
|
lang?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
displayArtist?: string;
|
displayArtist?: string;
|
||||||
@@ -878,7 +881,7 @@ export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredL
|
|||||||
);
|
);
|
||||||
const list = data.lyricsList?.structuredLyrics;
|
const list = data.lyricsList?.structuredLyrics;
|
||||||
if (!list || list.length === 0) return null;
|
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 {
|
} catch {
|
||||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -200,13 +200,10 @@ export default function AppUpdater() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleShowFolder = async () => {
|
const handleShowFolder = async () => {
|
||||||
try {
|
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||||
const dir = await dirname(dlPath);
|
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||||
await open(dir);
|
const dir = await dirname(dlPath);
|
||||||
} catch {
|
await invoke('open_folder', { path: dir });
|
||||||
// fallback: try opening the file path directly
|
|
||||||
await open(dlPath).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pct = dlProgress.total > 0
|
const pct = dlProgress.total > 0
|
||||||
|
|||||||
+42
-2
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
|
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||||
import type { Track } from '../store/playerStore';
|
import type { Track } from '../store/playerStore';
|
||||||
|
|
||||||
export type LyricsSource = 'server' | 'lrclib';
|
export type LyricsSource = 'server' | 'lrclib' | 'embedded';
|
||||||
|
|
||||||
export interface CachedLyrics {
|
export interface CachedLyrics {
|
||||||
syncedLines: LrcLine[] | null;
|
syncedLines: LrcLine[] | null;
|
||||||
@@ -20,7 +23,9 @@ export const lyricsCache = new Map<string, CachedLyrics>();
|
|||||||
export function parseStructuredLyrics(
|
export function parseStructuredLyrics(
|
||||||
lyrics: SubsonicStructuredLyrics,
|
lyrics: SubsonicStructuredLyrics,
|
||||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
): 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
|
const lines: LrcLine[] = lyrics.line
|
||||||
.filter(l => l.start !== undefined)
|
.filter(l => l.start !== undefined)
|
||||||
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
||||||
@@ -79,6 +84,37 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
|||||||
setLoading(false);
|
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 fetchServer = async (): Promise<boolean> => {
|
||||||
const structured = await getLyricsBySongId(currentTrack.id);
|
const structured = await getLyricsBySongId(currentTrack.id);
|
||||||
if (!structured) return false;
|
if (!structured) return false;
|
||||||
@@ -107,6 +143,10 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Embedded lyrics from local file always win (most accurate SYLT data).
|
||||||
|
if (cancelled) return;
|
||||||
|
if (await fetchEmbedded()) return;
|
||||||
|
|
||||||
const [first, second] = lyricsServerFirst
|
const [first, second] = lyricsServerFirst
|
||||||
? [fetchServer, fetchLrclibFn]
|
? [fetchServer, fetchLrclibFn]
|
||||||
: [fetchLrclibFn, fetchServer];
|
: [fetchLrclibFn, fetchServer];
|
||||||
|
|||||||
+17
-11
@@ -13,16 +13,22 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-width) 1fr var(--queue-width);
|
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:
|
grid-template-areas:
|
||||||
"sidebar main queue"
|
"sidebar main queue"
|
||||||
"player player player";
|
"player player player";
|
||||||
height: 100vh;
|
/* height: 100% inherits from html/body/#root (all 100%) so the grid
|
||||||
/* overflow: hidden keeps the player bar pinned at the bottom even when the
|
tracks actual window resize events on every platform.
|
||||||
window is dragged below the OS minHeight constraint (ignored on some
|
100vh was used previously but WebView2 (Windows) does not update the
|
||||||
Linux WMs/compositors). Without this, the 1fr row's implicit auto
|
CSS viewport synchronously on resize, causing the player-bar grid row
|
||||||
min-height can push the grid taller than 100vh, scrolling the player
|
to drift off-screen. overflow: hidden still prevents the 1fr row from
|
||||||
bar out of view. */
|
pushing the grid taller than the window on Linux WMs that ignore
|
||||||
|
the OS minHeight constraint. */
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
}
|
}
|
||||||
@@ -33,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-titlebar] {
|
.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:
|
grid-template-areas:
|
||||||
"titlebar titlebar titlebar"
|
"titlebar titlebar titlebar"
|
||||||
"sidebar main queue"
|
"sidebar main queue"
|
||||||
@@ -1748,7 +1754,7 @@
|
|||||||
/* ─── Grid Overrides ─── */
|
/* ─── Grid Overrides ─── */
|
||||||
.app-shell[data-mobile] {
|
.app-shell[data-mobile] {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 1fr auto auto;
|
grid-template-rows: minmax(0, 1fr) auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"main"
|
"main"
|
||||||
"bottomnav"
|
"bottomnav"
|
||||||
@@ -1758,7 +1764,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell[data-mobile][data-titlebar] {
|
.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:
|
grid-template-areas:
|
||||||
"titlebar"
|
"titlebar"
|
||||||
"main"
|
"main"
|
||||||
@@ -1833,7 +1839,7 @@
|
|||||||
/* When the mobile player is active, the app-shell becomes a single full-screen
|
/* When the mobile player is active, the app-shell becomes a single full-screen
|
||||||
area with no BottomNav or PlayerBar — just the main content. */
|
area with no BottomNav or PlayerBar — just the main content. */
|
||||||
.app-shell[data-mobile-player] {
|
.app-shell[data-mobile-player] {
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: minmax(0, 1fr);
|
||||||
grid-template-areas: "main";
|
grid-template-areas: "main";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-22
@@ -3144,8 +3144,8 @@
|
|||||||
/* ─── Global Base Settings ─── */
|
/* ─── Global Base Settings ─── */
|
||||||
:root {
|
:root {
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
|
||||||
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
--font-display: 'Space Grotesk Variable', 'Inter Variable', sans-serif;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--space-1: 4px;
|
--space-1: 4px;
|
||||||
@@ -3514,8 +3514,9 @@ html,
|
|||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 1280px;
|
/* min-height removed: Tauri enforces the real window minimum (minHeight: 480).
|
||||||
min-height: 720px;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6318,49 +6319,54 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Font overrides ─── */
|
/* ─── Font overrides ─── */
|
||||||
|
[data-font='inter'] {
|
||||||
|
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-display: 'Inter Variable', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
[data-font='outfit'] {
|
[data-font='outfit'] {
|
||||||
--font-sans: 'Outfit', system-ui, sans-serif;
|
--font-sans: 'Outfit Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Outfit', sans-serif;
|
--font-display: 'Outfit Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='dm-sans'] {
|
[data-font='dm-sans'] {
|
||||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
--font-sans: 'DM Sans Variable', system-ui, sans-serif;
|
||||||
--font-display: 'DM Sans', sans-serif;
|
--font-display: 'DM Sans Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='nunito'] {
|
[data-font='nunito'] {
|
||||||
--font-sans: 'Nunito', system-ui, sans-serif;
|
--font-sans: 'Nunito Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Nunito', sans-serif;
|
--font-display: 'Nunito Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='rubik'] {
|
[data-font='rubik'] {
|
||||||
--font-sans: 'Rubik', system-ui, sans-serif;
|
--font-sans: 'Rubik Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Rubik', sans-serif;
|
--font-display: 'Rubik Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='space-grotesk'] {
|
[data-font='space-grotesk'] {
|
||||||
--font-sans: 'Space Grotesk', system-ui, sans-serif;
|
--font-sans: 'Space Grotesk Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Space Grotesk', sans-serif;
|
--font-display: 'Space Grotesk Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='figtree'] {
|
[data-font='figtree'] {
|
||||||
--font-sans: 'Figtree', system-ui, sans-serif;
|
--font-sans: 'Figtree Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Figtree', sans-serif;
|
--font-display: 'Figtree Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='manrope'] {
|
[data-font='manrope'] {
|
||||||
--font-sans: 'Manrope', system-ui, sans-serif;
|
--font-sans: 'Manrope Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Manrope', sans-serif;
|
--font-display: 'Manrope Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='plus-jakarta-sans'] {
|
[data-font='plus-jakarta-sans'] {
|
||||||
--font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
|
--font-sans: 'Plus Jakarta Sans Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Plus Jakarta Sans', sans-serif;
|
--font-display: 'Plus Jakarta Sans Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-font='lexend'] {
|
[data-font='lexend'] {
|
||||||
--font-sans: 'Lexend', system-ui, sans-serif;
|
--font-sans: 'Lexend Variable', system-ui, sans-serif;
|
||||||
--font-display: 'Lexend', sans-serif;
|
--font-display: 'Lexend Variable', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Equalizer bars animation ─── */
|
/* ─── Equalizer bars animation ─── */
|
||||||
|
|||||||
Reference in New Issue
Block a user