From 80ff8afa3aad2863695bcf4e72138573bb3804a6 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Sat, 10 Jan 2026 06:31:12 -0600
Subject: [PATCH] feat: integrate lyrics module with UI
- Add lyricsStore for frontend state management with LRC parsing
- Wire LyricsSidebar to main layout with toggle button in NowPlayingBar
- Fix Rust compile errors (add Clone to LrclibItem, remove unused import)
- Add lyrics active line tracking with binary search
- Auto-start/stop lyrics updates based on playback state
---
src-tauri/src/lyrics/mod.rs | 1 -
src-tauri/src/lyrics/providers.rs | 2 +-
src/lib/components/NowPlayingBar.svelte | 21 +-
src/lib/stores/lyricsStore.ts | 433 ++++++++++++++++++++++++
src/routes/+page.svelte | 80 ++++-
src/test/setup.ts | 2 +-
6 files changed, 533 insertions(+), 6 deletions(-)
create mode 100644 src/lib/stores/lyricsStore.ts
diff --git a/src-tauri/src/lyrics/mod.rs b/src-tauri/src/lyrics/mod.rs
index 8a31b83fa..c24f0fd14 100644
--- a/src-tauri/src/lyrics/mod.rs
+++ b/src-tauri/src/lyrics/mod.rs
@@ -7,7 +7,6 @@ pub mod commands;
pub mod providers;
use serde::{Deserialize, Serialize};
-use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
diff --git a/src-tauri/src/lyrics/providers.rs b/src-tauri/src/lyrics/providers.rs
index c6a7ff130..9d5c85adc 100644
--- a/src-tauri/src/lyrics/providers.rs
+++ b/src-tauri/src/lyrics/providers.rs
@@ -13,7 +13,7 @@ pub struct LyricsData {
pub provider: LyricsProvider,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LrclibItem {
#[serde(default)]
diff --git a/src/lib/components/NowPlayingBar.svelte b/src/lib/components/NowPlayingBar.svelte
index 55c135266..6671407fc 100644
--- a/src/lib/components/NowPlayingBar.svelte
+++ b/src/lib/components/NowPlayingBar.svelte
@@ -12,7 +12,8 @@
Volume2,
VolumeX,
Volume1,
- Cast
+ Cast,
+ Mic2
} from 'lucide-svelte';
import QualityBadge from './QualityBadge.svelte';
@@ -42,6 +43,8 @@
onOpenQueue?: () => void;
onOpenFullScreen?: () => void;
onCast?: () => void;
+ onToggleLyrics?: () => void;
+ lyricsActive?: boolean;
}
let {
@@ -69,7 +72,9 @@
onToggleFavorite,
onOpenQueue,
onOpenFullScreen,
- onCast
+ onCast,
+ onToggleLyrics,
+ lyricsActive = false
}: Props = $props();
let progressRef: HTMLDivElement;
@@ -242,6 +247,14 @@
+
@@ -500,6 +513,10 @@
color: var(--text-primary);
}
+ .action-btn.active {
+ color: var(--accent-primary);
+ }
+
/* Volume */
.volume-control {
display: flex;
diff --git a/src/lib/stores/lyricsStore.ts b/src/lib/stores/lyricsStore.ts
new file mode 100644
index 000000000..b5515c21a
--- /dev/null
+++ b/src/lib/stores/lyricsStore.ts
@@ -0,0 +1,433 @@
+/**
+ * Lyrics State Store
+ *
+ * Manages lyrics fetching, LRC parsing, and synced line tracking.
+ */
+
+import { invoke } from '@tauri-apps/api/core';
+import { getCurrentTrack, getCurrentTime, subscribe as subscribePlayer } from './playerStore';
+
+// ============ Types ============
+
+export interface LyricsPayload {
+ track_id: number | null;
+ title: string;
+ artist: string;
+ album: string | null;
+ duration_secs: number | null;
+ plain: string | null;
+ synced_lrc: string | null;
+ provider: 'lrclib' | 'ovh';
+ cached: boolean;
+}
+
+export interface LyricsLine {
+ timeMs: number;
+ text: string;
+}
+
+export interface ParsedLyrics {
+ lines: LyricsLine[];
+ isSynced: boolean;
+}
+
+type LyricsStatus = 'idle' | 'loading' | 'loaded' | 'error' | 'not_found';
+
+// ============ State ============
+
+let status: LyricsStatus = 'idle';
+let error: string | null = null;
+let payload: LyricsPayload | null = null;
+let parsedLyrics: ParsedLyrics = { lines: [], isSynced: false };
+let activeIndex = -1;
+let activeProgress = 0;
+let sidebarVisible = false;
+
+// Track the last fetched track to avoid duplicate fetches
+let lastFetchedTrackId: number | null = null;
+
+// Listeners
+const listeners = new Set<() => void>();
+
+function notifyListeners(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+// ============ Subscribe ============
+
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ listener();
+ return () => listeners.delete(listener);
+}
+
+// ============ Getters ============
+
+export function getStatus(): LyricsStatus {
+ return status;
+}
+
+export function getError(): string | null {
+ return error;
+}
+
+export function getPayload(): LyricsPayload | null {
+ return payload;
+}
+
+export function getParsedLyrics(): ParsedLyrics {
+ return parsedLyrics;
+}
+
+export function getActiveIndex(): number {
+ return activeIndex;
+}
+
+export function getActiveProgress(): number {
+ return activeProgress;
+}
+
+export function isSidebarVisible(): boolean {
+ return sidebarVisible;
+}
+
+export interface LyricsState {
+ status: LyricsStatus;
+ error: string | null;
+ payload: LyricsPayload | null;
+ lines: LyricsLine[];
+ isSynced: boolean;
+ activeIndex: number;
+ activeProgress: number;
+ sidebarVisible: boolean;
+}
+
+export function getLyricsState(): LyricsState {
+ return {
+ status,
+ error,
+ payload,
+ lines: parsedLyrics.lines,
+ isSynced: parsedLyrics.isSynced,
+ activeIndex,
+ activeProgress,
+ sidebarVisible
+ };
+}
+
+// ============ LRC Parser ============
+
+/**
+ * Parse LRC format into array of lines with timestamps
+ * Supports: [mm:ss.xx], [mm:ss.xxx], [mm:ss]
+ */
+function parseLRC(lrc: string): LyricsLine[] {
+ const lines: LyricsLine[] = [];
+ const regex = /\[(\d{1,2}):(\d{2})(?:[.:](\d{2,3}))?\](.*)/g;
+
+ let match;
+ while ((match = regex.exec(lrc)) !== null) {
+ const minutes = parseInt(match[1], 10);
+ const seconds = parseInt(match[2], 10);
+ const ms = match[3] ? parseInt(match[3].padEnd(3, '0'), 10) : 0;
+ const text = match[4].trim();
+
+ if (text) {
+ const timeMs = (minutes * 60 + seconds) * 1000 + ms;
+ lines.push({ timeMs, text });
+ }
+ }
+
+ // Sort by time
+ lines.sort((a, b) => a.timeMs - b.timeMs);
+
+ return lines;
+}
+
+/**
+ * Parse plain lyrics (no timestamps)
+ */
+function parsePlain(plain: string): LyricsLine[] {
+ return plain
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0)
+ .map(text => ({ timeMs: 0, text }));
+}
+
+/**
+ * Parse lyrics payload into lines
+ */
+function parsePayload(p: LyricsPayload): ParsedLyrics {
+ if (p.synced_lrc && p.synced_lrc.trim()) {
+ const lines = parseLRC(p.synced_lrc);
+ if (lines.length > 0) {
+ return { lines, isSynced: true };
+ }
+ }
+
+ if (p.plain && p.plain.trim()) {
+ return { lines: parsePlain(p.plain), isSynced: false };
+ }
+
+ return { lines: [], isSynced: false };
+}
+
+// ============ Active Line Tracking ============
+
+/**
+ * Find active line index using binary search
+ */
+function findActiveLineIndex(lines: LyricsLine[], currentTimeMs: number): number {
+ if (lines.length === 0) return -1;
+
+ let left = 0;
+ let right = lines.length - 1;
+ let result = -1;
+
+ while (left <= right) {
+ const mid = Math.floor((left + right) / 2);
+ if (lines[mid].timeMs <= currentTimeMs) {
+ result = mid;
+ left = mid + 1;
+ } else {
+ right = mid - 1;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Calculate progress within current line (0-1)
+ */
+function calculateLineProgress(lines: LyricsLine[], index: number, currentTimeMs: number): number {
+ if (index < 0 || index >= lines.length) return 0;
+
+ const currentLine = lines[index];
+ const nextLine = lines[index + 1];
+
+ if (!nextLine) {
+ // Last line - assume 5 seconds duration
+ const duration = 5000;
+ return Math.min(1, (currentTimeMs - currentLine.timeMs) / duration);
+ }
+
+ const lineDuration = nextLine.timeMs - currentLine.timeMs;
+ if (lineDuration <= 0) return 0;
+
+ return Math.min(1, (currentTimeMs - currentLine.timeMs) / lineDuration);
+}
+
+/**
+ * Update active line based on current playback time
+ */
+export function updateActiveLine(): void {
+ if (!parsedLyrics.isSynced || parsedLyrics.lines.length === 0) {
+ if (activeIndex !== -1 || activeProgress !== 0) {
+ activeIndex = -1;
+ activeProgress = 0;
+ notifyListeners();
+ }
+ return;
+ }
+
+ const currentTimeMs = getCurrentTime() * 1000;
+ const newIndex = findActiveLineIndex(parsedLyrics.lines, currentTimeMs);
+ const newProgress = calculateLineProgress(parsedLyrics.lines, newIndex, currentTimeMs);
+
+ if (newIndex !== activeIndex || Math.abs(newProgress - activeProgress) > 0.01) {
+ activeIndex = newIndex;
+ activeProgress = newProgress;
+ notifyListeners();
+ }
+}
+
+// ============ Actions ============
+
+/**
+ * Fetch lyrics for current track
+ */
+export async function fetchLyrics(): Promise
{
+ const track = getCurrentTrack();
+
+ if (!track) {
+ reset();
+ return;
+ }
+
+ // Skip if already fetched for this track
+ if (track.id === lastFetchedTrackId && status === 'loaded') {
+ return;
+ }
+
+ lastFetchedTrackId = track.id;
+ status = 'loading';
+ error = null;
+ notifyListeners();
+
+ try {
+ const result = await invoke('lyrics_get', {
+ trackId: track.id,
+ title: track.title,
+ artist: track.artist,
+ album: track.album || null,
+ durationSecs: track.duration || null
+ });
+
+ if (result) {
+ payload = result;
+ parsedLyrics = parsePayload(result);
+ status = 'loaded';
+ activeIndex = -1;
+ activeProgress = 0;
+
+ // Immediately update active line
+ if (parsedLyrics.isSynced) {
+ updateActiveLine();
+ }
+ } else {
+ payload = null;
+ parsedLyrics = { lines: [], isSynced: false };
+ status = 'not_found';
+ }
+ } catch (err) {
+ console.error('Failed to fetch lyrics:', err);
+ status = 'error';
+ error = err instanceof Error ? err.message : String(err);
+ payload = null;
+ parsedLyrics = { lines: [], isSynced: false };
+ }
+
+ notifyListeners();
+}
+
+/**
+ * Toggle sidebar visibility
+ */
+export function toggleSidebar(): void {
+ sidebarVisible = !sidebarVisible;
+ notifyListeners();
+
+ // Fetch lyrics if opening and not yet fetched
+ if (sidebarVisible && status === 'idle') {
+ fetchLyrics();
+ }
+}
+
+/**
+ * Show sidebar
+ */
+export function showSidebar(): void {
+ if (!sidebarVisible) {
+ sidebarVisible = true;
+ notifyListeners();
+
+ if (status === 'idle') {
+ fetchLyrics();
+ }
+ }
+}
+
+/**
+ * Hide sidebar
+ */
+export function hideSidebar(): void {
+ if (sidebarVisible) {
+ sidebarVisible = false;
+ notifyListeners();
+ }
+}
+
+/**
+ * Clear lyrics cache (via backend)
+ */
+export async function clearCache(): Promise {
+ try {
+ await invoke('lyrics_clear_cache');
+ console.log('Lyrics cache cleared');
+ } catch (err) {
+ console.error('Failed to clear lyrics cache:', err);
+ }
+}
+
+/**
+ * Reset store state
+ */
+export function reset(): void {
+ status = 'idle';
+ error = null;
+ payload = null;
+ parsedLyrics = { lines: [], isSynced: false };
+ activeIndex = -1;
+ activeProgress = 0;
+ lastFetchedTrackId = null;
+ notifyListeners();
+}
+
+// ============ Auto-update ============
+
+let updateInterval: number | null = null;
+
+/**
+ * Start auto-updating active line (call when lyrics are synced and playing)
+ */
+export function startActiveLineUpdates(): void {
+ if (updateInterval !== null) return;
+
+ updateInterval = window.setInterval(() => {
+ if (parsedLyrics.isSynced) {
+ updateActiveLine();
+ }
+ }, 50); // 50ms for smooth progress
+}
+
+/**
+ * Stop auto-updating active line
+ */
+export function stopActiveLineUpdates(): void {
+ if (updateInterval !== null) {
+ clearInterval(updateInterval);
+ updateInterval = null;
+ }
+}
+
+// ============ Player Integration ============
+
+let playerUnsubscribe: (() => void) | null = null;
+let lastTrackId: number | null = null;
+
+/**
+ * Start watching player state for track changes
+ */
+export function startWatching(): void {
+ if (playerUnsubscribe) return;
+
+ playerUnsubscribe = subscribePlayer(() => {
+ const track = getCurrentTrack();
+ const trackId = track?.id ?? null;
+
+ // Track changed - fetch new lyrics
+ if (trackId !== lastTrackId) {
+ lastTrackId = trackId;
+ if (trackId !== null && sidebarVisible) {
+ fetchLyrics();
+ } else if (trackId === null) {
+ reset();
+ }
+ }
+ });
+}
+
+/**
+ * Stop watching player state
+ */
+export function stopWatching(): void {
+ if (playerUnsubscribe) {
+ playerUnsubscribe();
+ playerUnsubscribe = null;
+ }
+ stopActiveLineUpdates();
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 74699e04e..3a76c0a6c 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -164,6 +164,18 @@
// App bootstrap
import { bootstrapApp } from '$lib/app/bootstrap';
+ // Lyrics state management
+ import {
+ subscribe as subscribeLyrics,
+ toggleSidebar as toggleLyricsSidebar,
+ startWatching as startLyricsWatching,
+ stopWatching as stopLyricsWatching,
+ startActiveLineUpdates,
+ stopActiveLineUpdates,
+ getLyricsState,
+ type LyricsLine
+ } from '$lib/stores/lyricsStore';
+
// Components
import Sidebar from '$lib/components/Sidebar.svelte';
import NowPlayingBar from '$lib/components/NowPlayingBar.svelte';
@@ -186,6 +198,7 @@
import FocusMode from '$lib/components/FocusMode.svelte';
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
import CastPicker from '$lib/components/CastPicker.svelte';
+ import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
// Auth State (from authStore subscription)
let isLoggedIn = $state(false);
@@ -230,6 +243,16 @@
// Toast State (from store subscription)
let toast = $state(null);
+ // Lyrics State (from lyricsStore subscription)
+ let lyricsStatus = $state<'idle' | 'loading' | 'loaded' | 'error' | 'not_found'>('idle');
+ let lyricsError = $state(null);
+ let lyricsLines = $state([]);
+ let lyricsIsSynced = $state(false);
+ let lyricsActiveIndex = $state(-1);
+ let lyricsActiveProgress = $state(0);
+ let lyricsSidebarVisible = $state(false);
+ let lyricsProvider = $state(undefined);
+
// Navigation wrapper (keeps debug logging)
function navigateTo(view: string) {
console.log('navigateTo called with:', view, 'current activeView:', activeView);
@@ -867,6 +890,22 @@
repeatMode = queueState.repeatMode;
});
+ // Subscribe to lyrics state changes
+ const unsubscribeLyrics = subscribeLyrics(() => {
+ const state = getLyricsState();
+ lyricsStatus = state.status;
+ lyricsError = state.error;
+ lyricsLines = state.lines;
+ lyricsIsSynced = state.isSynced;
+ lyricsActiveIndex = state.activeIndex;
+ lyricsActiveProgress = state.activeProgress;
+ lyricsSidebarVisible = state.sidebarVisible;
+ lyricsProvider = state.payload?.provider;
+ });
+
+ // Start lyrics watcher for track changes
+ startLyricsWatching();
+
// Set up track ended callback for auto-advance
setOnTrackEnded(async () => {
const nextTrackResult = await nextTrack();
@@ -888,6 +927,9 @@
unsubscribeNav();
unsubscribePlayer();
unsubscribeQueue();
+ unsubscribeLyrics();
+ stopLyricsWatching();
+ stopActiveLineUpdates();
stopPolling();
cleanupPlayback();
};
@@ -900,6 +942,15 @@
}
});
+ // Start/stop lyrics active line updates based on playback state
+ $effect(() => {
+ if (isPlaying && lyricsIsSynced && lyricsSidebarVisible) {
+ startActiveLineUpdates();
+ } else {
+ stopActiveLineUpdates();
+ }
+ });
+
// Derived values for NowPlayingBar
const currentQueueTrack = $derived(currentTrack ? {
id: String(currentTrack.id),
@@ -928,6 +979,8 @@
subscription={userInfo?.subscription || 'Qobuz'}
/>
+
+
{#if activeView === 'home'}
@@ -1042,6 +1095,21 @@
{/if}
+
+ {#if lyricsSidebarVisible}
+ ({ text: l.text }))}
+ activeIndex={lyricsActiveIndex}
+ activeProgress={lyricsActiveProgress}
+ isLoading={lyricsStatus === 'loading'}
+ error={lyricsStatus === 'error' ? lyricsError : (lyricsStatus === 'not_found' ? 'No lyrics found' : null)}
+ />
+ {/if}
+
+
{#if currentTrack}
{:else}