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:
1 parent
a3a766ba34
commit
80ff8afa3a
6 files changed
+533
-6
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -26,4 +26,4 @@ const localStorageMock = {
|
||||
length: 0,
|
||||
key: vi.fn()
|
||||
};
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock });
|
||||
Reference in new issue
Block a user