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
+60
View File
@@ -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",
+2
View File
@@ -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:
+135
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ─── */