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
This commit is contained in:
vicrodh committed 2026-01-10 06:31:12 -06:00
1 parent a3a766ba34
commit 80ff8afa3a
6 files changed
+533 -6

No files matched your search

-1
View File
@@ -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;
+1 -1
View File
@@ -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)]
+19 -2
View File
@@ -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 @@
<button class="action-btn" onclick={onCast} title="Cast to device">
<Cast size={20} />
</button>
<button
class="action-btn"
class:active={lyricsActive}
onclick={onToggleLyrics}
title="Lyrics"
>
<Mic2 size={20} />
</button>
<!-- Volume Control -->
<div class="volume-control">
@@ -500,6 +513,10 @@
color: var(--text-primary);
}
.action-btn.active {
color: var(--accent-primary);
}
/* Volume */
.volume-control {
display: flex;
+433
View File
@@ -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<void> {
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<LyricsPayload | null>('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<void> {
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();
}
+79 -1
View File
@@ -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<ToastData | null>(null);
// Lyrics State (from lyricsStore subscription)
let lyricsStatus = $state<'idle' | 'loading' | 'loaded' | 'error' | 'not_found'>('idle');
let lyricsError = $state<string | null>(null);
let lyricsLines = $state<LyricsLine[]>([]);
let lyricsIsSynced = $state(false);
let lyricsActiveIndex = $state(-1);
let lyricsActiveProgress = $state(0);
let lyricsSidebarVisible = $state(false);
let lyricsProvider = $state<string | undefined>(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<QueueTrack | null>(currentTrack ? {
id: String(currentTrack.id),
@@ -928,6 +979,8 @@
subscription={userInfo?.subscription || 'Qobuz'}
/>
<!-- Content Area (main + lyrics sidebar) -->
<div class="content-area">
<!-- Main Content -->
<main class="main-content">
{#if activeView === 'home'}
@@ -1042,6 +1095,21 @@
{/if}
</main>
<!-- Lyrics Sidebar -->
{#if lyricsSidebarVisible}
<LyricsSidebar
title={currentTrack?.title}
artist={currentTrack?.artist}
provider={lyricsProvider}
lines={lyricsLines.map(l => ({ text: l.text }))}
activeIndex={lyricsActiveIndex}
activeProgress={lyricsActiveProgress}
isLoading={lyricsStatus === 'loading'}
error={lyricsStatus === 'error' ? lyricsError : (lyricsStatus === 'not_found' ? 'No lyrics found' : null)}
/>
{/if}
</div>
<!-- Now Playing Bar -->
{#if currentTrack}
<NowPlayingBar
@@ -1069,6 +1137,8 @@
onOpenQueue={openQueue}
onOpenFullScreen={openFullScreen}
onCast={openCastPicker}
onToggleLyrics={toggleLyricsSidebar}
lyricsActive={lyricsSidebarVisible}
/>
{:else}
<NowPlayingBar
@@ -1183,10 +1253,18 @@
background-color: var(--bg-primary);
}
.main-content {
.content-area {
display: flex;
flex: 1;
min-width: 0;
height: calc(100vh - 80px);
overflow: hidden;
}
.main-content {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
padding: 24px 32px;
}
+1 -1
View File
@@ -26,4 +26,4 @@ const localStorageMock = {
length: 0,
key: vi.fn()
};
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock });