From 6ccc73401c630226d5b09f459229482d71b09236 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:16:10 -0400 Subject: [PATCH] lyrics support + lookup --- package-lock.json | 7 + package.json | 2 + src/app/now-playing.tsx | 91 ++++-- src/components/lyrics/LyricsBand.tsx | 253 ++++++++++++++++ src/components/lyrics/LyricsLine.tsx | 169 +++++++++++ src/components/lyrics/LyricsView.tsx | 193 ++++++++++++ src/db/lyricsQueries.ts | 107 +++++++ src/db/schema.ts | 29 +- src/lyrics/lyrics.ts | Bin 0 -> 7801 bytes src/lyrics/parsing.test.mts | 76 +++++ src/lyrics/parsing.ts | 288 ++++++++++++++++++ src/lyrics/presentation.test.mts | 108 +++++++ src/lyrics/presentation.ts | 437 +++++++++++++++++++++++++++ src/lyrics/types.ts | 62 ++++ src/services/lyrics/lrclib.ts | 348 +++++++++++++++++++++ src/services/lyrics/unicode.ts | 48 +++ src/services/lyrics/xlrcdb.ts | 179 +++++++++++ src/stores/lyricsStore.ts | 94 ++++++ src/stores/settingsStore.ts | 16 +- 19 files changed, 2478 insertions(+), 29 deletions(-) create mode 100644 src/components/lyrics/LyricsBand.tsx create mode 100644 src/components/lyrics/LyricsLine.tsx create mode 100644 src/components/lyrics/LyricsView.tsx create mode 100644 src/db/lyricsQueries.ts create mode 100644 src/lyrics/lyrics.ts create mode 100644 src/lyrics/parsing.test.mts create mode 100644 src/lyrics/parsing.ts create mode 100644 src/lyrics/presentation.test.mts create mode 100644 src/lyrics/presentation.ts create mode 100644 src/lyrics/types.ts create mode 100644 src/services/lyrics/lrclib.ts create mode 100644 src/services/lyrics/unicode.ts create mode 100644 src/services/lyrics/xlrcdb.ts create mode 100644 src/stores/lyricsStore.ts diff --git a/package-lock.json b/package-lock.json index 9efbab8..70e2ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/ui": "~56.0.13", @@ -1163,6 +1164,12 @@ "node": ">=6.9.0" } }, + "node_modules/@boof2015/xlrc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@boof2015/xlrc/-/xlrc-0.2.1.tgz", + "integrity": "sha512-XncWjaw+BjzG1pQXwG92RCIrBOoU1DvLRT+wEnbU18j4qKCBujep4dT55gH+vb6TTRxI5Yv3E8V7p6FipYhWqw==", + "license": "MIT" + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", diff --git a/package.json b/package.json index c44bee5..a6e4a9c 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "main": "index.js", "version": "0.1.0", "dependencies": { + "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/ui": "~56.0.13", @@ -71,6 +72,7 @@ "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts", "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", + "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index fb7bff3..2bd2a78 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -27,6 +27,7 @@ import { NowPlayingWash } from '@/components/NowPlayingWash'; import { SeekBar } from '@/components/SeekBar'; import { WaveformSeekBar } from '@/components/WaveformSeekBar'; import { Visualizer } from '@/components/Visualizer'; +import { LyricsView } from '@/components/lyrics/LyricsView'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker'; import { QueueTray } from '@/components/queue/QueueTray'; @@ -279,6 +280,8 @@ export default function NowPlayingScreen() { const scopeMode = useSettingsStore((s) => s.scopeMode); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); + const lyricsVisible = useSettingsStore((s) => s.lyricsVisible); + const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); @@ -316,6 +319,9 @@ export default function NowPlayingScreen() { const activeTrack = desktopSnapshot?.currentTrack ?? null; const isPlaying = activePresentation.playbackState === 'playing'; const isLoading = activePresentation.playbackState === 'loading'; + // Lyrics mode takes over the whole phone-playback body (its own header + minimal + // controls); only for local playback, never the desktop-remote target. + const lyricsMode = !isDesktopTarget && !!track && lyricsVisible; // Wash off a low-res thumbnail (like the album/artist detail headers do) so the // blur reads as pure colors — full-res art keeps its detail at any blur radius. // currentTrack only carries the full-size artworkData, so derive the thumb from it. @@ -513,33 +519,66 @@ export default function NowPlayingScreen() { }} /> - - - dismissSheet()} hitSlop={12}> - - + {!lyricsMode && ( + + + dismissSheet()} hitSlop={12}> + + + + + + PLAYING FROM + + + {source} + + + + {!isDesktopTarget && track ? ( + void setLyricsVisible(!lyricsVisible)} + hitSlop={12} + accessibilityLabel={lyricsVisible ? 'Hide lyrics' : 'Show lyrics'} + accessibilityState={{ selected: lyricsVisible }} + > + + + ) : null} + + + + - - - PLAYING FROM - - - {source} - - - - - - - - + )} - {isDesktopTarget ? ( + {lyricsMode && track ? ( + void seekTo(seconds)} + onPlayPause={togglePlay} + onNext={skipToNext} + onPrev={skipToPrevious} + onToggleFavorite={() => void toggleFavorite(track)} + onExitLyrics={() => void setLyricsVisible(false)} + onDismiss={() => dismissSheet()} + /> + ) : isDesktopTarget ? ( activeTrack ? ( ({ justifyContent: 'center', }, headerSide: { - width: 48, + width: 68, flexDirection: 'row', alignItems: 'center', }, diff --git a/src/components/lyrics/LyricsBand.tsx b/src/components/lyrics/LyricsBand.tsx new file mode 100644 index 0000000..874d352 --- /dev/null +++ b/src/components/lyrics/LyricsBand.tsx @@ -0,0 +1,253 @@ +// The scrolling synced-lyrics list for lyrics mode. Fills its parent (flex:1), +// left-aligned, self-measuring. The active line advances off the same smooth +// playback clock the waveform uses (RNTP stays authoritative) and auto-scrolls to +// a comfortable reading anchor; manual scroll pauses the follow and surfaces a +// Recenter pill. Top/bottom gradient overlays soften the edges into the chrome +// (react-native-svg — no native rebuild). Plain (unsynced) hits render as a +// static scroll; loading/not-found/error states get a centered message. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native'; +import { Text } from '@/components/Text'; +import { useColors } from '@/theme/themed'; +import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; +import { useLyricsStore } from '@/stores/lyricsStore'; +import { + getLyricsLineSeekTimeSeconds, + getSyncedLyricsDisplayLines, + getSyncedLyricsGapProgress, + hasRenderableSyncedLines, + resolveSyncedLyricsTiming, +} from '@/lyrics/presentation'; +import { LyricsLine, type LyricsLineTier } from './LyricsLine'; +import type { Track } from '@/types/audio'; + +const TRANSLATION_PRIORITY = ['en', 'ja-Latn']; +const ANCHOR_RATIO = 0.4; +const H_PADDING = 22; +// The displayed active line lags the audio by a fixed pipeline delay (RNTP +// position reporting + poll/smoothing) that the desktop doesn't have, so advance +// the lyrics clock by this much. Tune to taste — bigger = earlier highlight. +const LYRICS_LEAD_MS = 350; + +interface LyricsBandProps { + track: Track; + currentTime: number; + duration: number; + isPlaying: boolean; + onSeek: (seconds: number) => void; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) { + const colors = useColors(); + const entry = useLyricsStore((s) => s.byPath[track.path]); + const loadForTrack = useLyricsStore((s) => s.loadForTrack); + + useEffect(() => { + void loadForTrack(track); + }, [track, loadForTrack]); + + const [size, setSize] = useState({ w: 0, h: 0 }); + const onContainerLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setSize((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height })); + }; + + const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); + // Lead the audio to counter display-pipeline lag (see LYRICS_LEAD_MS). + const lyricsTime = smoothTime + LYRICS_LEAD_MS / 1000; + const result = entry?.result ?? null; + const isLoading = entry?.loading ?? !entry; + + const syncedLines = useMemo( + () => (result?.status === 'hit' ? result.lyrics.syncedLines : []), + [result] + ); + const displayLines = useMemo( + () => getSyncedLyricsDisplayLines(syncedLines, { durationSeconds: duration }), + [syncedLines, duration] + ); + const hasSynced = hasRenderableSyncedLines(syncedLines); + const timing = resolveSyncedLyricsTiming(syncedLines, lyricsTime, { durationSeconds: duration }); + const focusIndex = timing.focusLineIndex; + + // Uniform size for every line (LyricsLine no longer scales font per tier), so pick + // a comfortable reading size rather than the old oversized active value. + const baseSize = size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, 24)) : 22; + + // --- auto-scroll centering --- + const scrollRef = useRef(null); + const offsets = useRef([]); + const heights = useRef([]); + const [followPaused, setFollowPaused] = useState(false); + + const centerOn = useCallback( + (displayIndex: number, animated: boolean) => { + if (displayIndex < 0 || size.h <= 0) return; + const y = offsets.current[displayIndex]; + const h = heights.current[displayIndex]; + if (y == null || h == null) return; + const target = Math.max(0, y + h / 2 - size.h * ANCHOR_RATIO); + scrollRef.current?.scrollTo({ y: target, animated }); + }, + [size.h] + ); + + // Reset follow + jump to the focus line when the track (or its lyrics) changes. + useEffect(() => { + offsets.current = []; + heights.current = []; + const timer = setTimeout(() => { + setFollowPaused(false); + centerOn(timing.focusLineIndex, false); + }, 90); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [track.path, displayLines.length]); + + // Follow the active/focus line as playback advances. + useEffect(() => { + if (followPaused) return; + centerOn(focusIndex, true); + }, [focusIndex, followPaused, centerOn]); + + const recenter = useCallback(() => { + setFollowPaused(false); + centerOn(focusIndex, true); + }, [centerOn, focusIndex]); + + const message = !hasSynced + ? result?.status === 'transient_error' + ? 'Lyrics lookup ran into a problem. A retry may work.' + : result?.status === 'not_found' + ? result.reason === 'online-disabled' + ? 'Online lyrics lookup is off.' + : result.reason === 'provider-unavailable' + ? "Lyrics providers didn't respond in time." + : 'No lyrics found for this track.' + : isLoading + ? 'Finding lyrics…' + : 'Lyrics are ready when a track is playing.' + : null; + + // --- plain (unsynced) hit --- + if (result?.status === 'hit' && !hasSynced) { + return ( + + + + {result.lyrics.plainLyrics ?? ''} + + + + ); + } + + // --- loading / not-found / error --- + if (!hasSynced) { + return ( + + + {message} + + + ); + } + + // --- synced view --- + return ( + + setFollowPaused(true)} + contentContainerStyle={{ + paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120, + paddingHorizontal: H_PADDING, + alignItems: 'stretch', + }} + > + {displayLines.map((displayLine) => { + const onLayout = (event: LayoutChangeEvent) => { + offsets.current[displayLine.displayIndex] = event.nativeEvent.layout.y; + heights.current[displayLine.displayIndex] = event.nativeEvent.layout.height; + }; + + if (displayLine.kind === 'gap') { + const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0; + const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral; + return ( + + + + + + ); + } + + const distance = displayLine.displayIndex - focusIndex; + const isActive = !timing.isNeutral && displayLine.displayIndex === timing.activeLineIndex; + const absDistance = Math.abs(distance); + const tier: LyricsLineTier = isActive ? 'active' : absDistance <= 1 ? 'near' : absDistance === 2 ? 'far' : 'distant'; + const seekSeconds = getLyricsLineSeekTimeSeconds(displayLine.timestampMs, duration, 0); + + return ( + { + if (seekSeconds != null) onSeek(seekSeconds); + }} + onLayout={onLayout} + /> + ); + })} + + + {followPaused ? ( + + + Recenter + + + ) : null} + + ); +} diff --git a/src/components/lyrics/LyricsLine.tsx b/src/components/lyrics/LyricsLine.tsx new file mode 100644 index 0000000..b92c459 --- /dev/null +++ b/src/components/lyrics/LyricsLine.tsx @@ -0,0 +1,169 @@ +// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile / +// Apple-Music reading style), with furigana ruby columns when present and an +// optional translation line beneath. Tapping seeks to the line. +// +// Every line uses the SAME font size, weight, and metrics, so text wrapping and +// line heights are identical across tiers — the layout never reflows as the active +// line moves, which is what caused the surrounding lines to shift/flicker. The +// active line is emphasized only by opacity, a small scale, and an accent glow — +// all paint/transform, never layout — and both animate so tier changes ease +// instead of snapping. +// +// RN has no , so ruby is a stacked column: a small reading Text over the +// base Text. Every segment is the identical box (a View reserving `readingHeight` +// of top space with the base below) so all bases land on one line; the reading is +// absolutely positioned in that top zone, base-width and shrunk to fit, so a wide +// reading never widens the column and spreads the sentence apart. + +import { memo, useEffect, useMemo } from 'react'; +import { Pressable, View, type LayoutChangeEvent } from 'react-native'; +import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { Text } from '@/components/Text'; +import { useColors } from '@/theme/themed'; +import { getPreferredLyricsTranslation } from '@/lyrics/presentation'; +import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types'; + +export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant'; + +interface LyricsLineProps { + line: LyricsLineData; + tier: LyricsLineTier; + baseSize: number; + translationPriority: string[]; + onSeek: () => void; + onLayout?: (event: LayoutChangeEvent) => void; +} + +// scale/opacity only — never anything that reflows layout. Active scale is kept +// small enough that the grow overflows into the horizontal padding, not off-screen. +const TIER: Record = { + active: { scale: 1.06, opacity: 1 }, + near: { scale: 1.0, opacity: 0.52 }, + far: { scale: 0.965, opacity: 0.27 }, + distant: { scale: 0.93, opacity: 0.13 }, +}; + +const EASE = Easing.bezier(0.22, 1, 0.36, 1); +const DURATION = 220; + +interface Segment { + text: string; + reading?: string; +} + +function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Segment[] { + if (!furigana || furigana.length === 0) return [{ text }]; + const sorted = [...furigana].sort((a, b) => a.start - b.start); + const segments: Segment[] = []; + let cursor = 0; + for (const entry of sorted) { + if (entry.start < cursor || entry.end > text.length) continue; + if (entry.start > cursor) segments.push({ text: text.slice(cursor, entry.start) }); + segments.push({ text: text.slice(entry.start, entry.end), reading: entry.reading }); + cursor = entry.end; + } + if (cursor < text.length) segments.push({ text: text.slice(cursor) }); + return segments; +} + +function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) { + const colors = useColors(); + const target = TIER[tier]; + // Uniform metrics for every line (the whole point — no wrap/reflow between tiers). + const size = baseSize; + const lineHeight = Math.round(size * 1.2); + const readingSize = Math.max(9, Math.round(size * 0.5)); + const readingHeight = Math.round(readingSize * 1.25); + const translation = useMemo( + () => getPreferredLyricsTranslation(line, translationPriority), + [line, translationPriority] + ); + const hasFurigana = Boolean(line.furigana && line.furigana.length > 0); + const segments = useMemo(() => buildSegments(line.text, line.furigana), [line.text, line.furigana]); + + const opacity = useSharedValue(target.opacity); + const scale = useSharedValue(target.scale); + useEffect(() => { + opacity.value = withTiming(target.opacity, { duration: DURATION, easing: EASE }); + scale.value = withTiming(target.scale, { duration: DURATION, easing: EASE }); + }, [target.opacity, target.scale, opacity, scale]); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); + + const textColor = colors.textPrimary; + const mainShadow = + tier === 'active' + ? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 } + : undefined; + + return ( + + + + {hasFurigana ? ( + + {segments.map((segment, index) => ( + + + {segment.text} + + {segment.reading ? ( + + {segment.reading} + + ) : null} + + ))} + + ) : ( + + {line.text} + + )} + + {translation ? ( + + {translation.text} + + ) : null} + + + + ); +} + +export const LyricsLine = memo(LyricsLineComponent); diff --git a/src/components/lyrics/LyricsView.tsx b/src/components/lyrics/LyricsView.tsx new file mode 100644 index 0000000..9f33508 --- /dev/null +++ b/src/components/lyrics/LyricsView.tsx @@ -0,0 +1,193 @@ +// Lyrics mode for now-playing — a lyrics-first takeover that replaces the whole +// art/controls body (and the standard header). Lyrics fill the screen over the +// blurred-art wash; a slim strip on top (dismiss · track · favorite · exit) and a +// minimal control bar below (progress + prev/play/next) stay permanently visible +// so you never lose control to go fully immersive. The ♫ toggle exits back to the +// art view; swipe-down still closes the player. + +import { Image } from 'expo-image'; +import { Pressable, View } from 'react-native'; +import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { MarqueeText } from '@/components/MarqueeText'; +import { AstraLogo } from '@/components/AstraLogo'; +import { SeekBar } from '@/components/SeekBar'; +import { LyricsBand } from './LyricsBand'; +import { spacing, radius } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import type { Track } from '@/types/audio'; + +interface LyricsViewProps { + track: Track; + currentTime: number; + duration: number; + isPlaying: boolean; + isLoading: boolean; + isFavorite: boolean; + onSeek: (seconds: number) => void; + onPlayPause: () => void; + onNext: () => void; + onPrev: () => void; + onToggleFavorite: () => void; + onExitLyrics: () => void; + onDismiss: () => void; +} + +export function LyricsView({ + track, + currentTime, + duration, + isPlaying, + isLoading, + isFavorite, + onSeek, + onPlayPause, + onNext, + onPrev, + onToggleFavorite, + onExitLyrics, + onDismiss, +}: LyricsViewProps) { + const styles = useStyles(); + const colors = useColors(); + + return ( + + + + + + + + {track.artworkData ? ( + + ) : ( + + )} + + + + + {track.title} + + + {track.artist} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + root: { + flex: 1, + }, + strip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingBottom: spacing.sm, + }, + stripBtn: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + }, + thumb: { + width: 40, + height: 40, + borderRadius: radius.sm, + backgroundColor: colors.bgTertiary, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + thumbImage: { + width: 40, + height: 40, + }, + stripText: { + flex: 1, + minWidth: 0, + }, + stripTitle: { + color: colors.textPrimary, + }, + controls: { + paddingTop: spacing.sm, + }, + transport: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xl, + marginTop: spacing.xs, + }, + transportBtn: { + width: 48, + height: 48, + alignItems: 'center', + justifyContent: 'center', + }, + playButton: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: colors.accent, + alignItems: 'center', + justifyContent: 'center', + }, +})); diff --git a/src/db/lyricsQueries.ts b/src/db/lyricsQueries.ts new file mode 100644 index 0000000..9f95c12 --- /dev/null +++ b/src/db/lyricsQueries.ts @@ -0,0 +1,107 @@ +// Lyrics cache — LRC/XLRC results from online lookup, mirroring the waveform +// cache (src/db/waveformQueries.ts): keyed by track_path with no FK, upserted +// with ON CONFLICT. A row is returned only when its metadata_signature still +// matches the current track tags, so a retag re-fetches. Parsed lines are stored +// as JSON and re-sanitized on read. + +import type { LibraryDatabase } from './database'; +import { sanitizeLyricsLines } from '@/lyrics/parsing'; +import type { LyricsFormat, LyricsLine, LyricsProvider, LyricsSource } from '@/lyrics/types'; + +export interface LyricsCacheEntry { + status: 'hit' | 'not_found'; + source: LyricsSource; + provider: LyricsProvider | null; + format: LyricsFormat; + plainLyrics: string | null; + syncedLyrics: string | null; + syncedLines: LyricsLine[]; +} + +export interface LyricsCacheWrite { + trackPath: string; + metadataSignature: string; + status: 'hit' | 'not_found'; + source: LyricsSource | null; + provider: LyricsProvider | null; + format: LyricsFormat | null; + plainLyrics: string | null; + syncedLyrics: string | null; + syncedLines: LyricsLine[]; +} + +interface LyricsCacheRow { + metadata_signature: string | null; + status: string; + source: string | null; + provider: string | null; + format: string | null; + plain_lyrics: string | null; + synced_lyrics: string | null; + synced_lines_json: string; +} + +function parseSyncedLines(json: string): LyricsLine[] { + try { + return sanitizeLyricsLines(JSON.parse(json)); + } catch { + return []; + } +} + +export async function getLyricsCache( + db: LibraryDatabase, + trackPath: string, + metadataSignature: string +): Promise { + const row = await db.get( + `SELECT metadata_signature, status, source, provider, format, plain_lyrics, synced_lyrics, synced_lines_json + FROM lyrics_cache WHERE track_path = ?`, + [trackPath] + ); + if (!row) return null; + // A metadata change (retag) invalidates the cached result. + if (row.metadata_signature !== metadataSignature) return null; + + return { + status: row.status === 'hit' ? 'hit' : 'not_found', + source: (row.source as LyricsSource | null) ?? 'lrclib', + provider: (row.provider as LyricsProvider | null) ?? null, + format: (row.format as LyricsFormat | null) ?? 'plain', + plainLyrics: row.plain_lyrics, + syncedLyrics: row.synced_lyrics, + syncedLines: parseSyncedLines(row.synced_lines_json), + }; +} + +export async function putLyricsCache(db: LibraryDatabase, entry: LyricsCacheWrite): Promise { + await db.run( + `INSERT INTO lyrics_cache ( + track_path, metadata_signature, status, source, provider, format, + plain_lyrics, synced_lyrics, synced_lines_json, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(track_path) DO UPDATE SET + metadata_signature = excluded.metadata_signature, + status = excluded.status, + source = excluded.source, + provider = excluded.provider, + format = excluded.format, + plain_lyrics = excluded.plain_lyrics, + synced_lyrics = excluded.synced_lyrics, + synced_lines_json = excluded.synced_lines_json, + updated_at = excluded.updated_at`, + [ + entry.trackPath, + entry.metadataSignature, + entry.status, + entry.source, + entry.provider, + entry.format, + entry.plainLyrics, + entry.syncedLyrics, + JSON.stringify(entry.syncedLines), + Date.now(), + ] + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index fd4c8c2..72224e4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -28,11 +28,13 @@ // v17 adds `playlist_sync_state` — the per-playlist (local, remote) updated_at baseline // from the last successful sync, which turns blind last-writer-wins into 3-way change // detection: only-one-side-changed syncs silently, both-changed surfaces a conflict -// prompt (Steam-Cloud style) instead of silently dropping an edit. +// prompt (Steam-Cloud style) instead of silently dropping an edit; v18 adds `lyrics_cache` +// — LRC/XLRC results from online lookup (xlrcdb + lrclib), keyed by track_path with no FK +// (survives folder re-grant like waveform_peaks) and invalidated by a metadata_signature. import type { LibraryDatabase } from './database'; -export const SCHEMA_VERSION = 17; +export const SCHEMA_VERSION = 18; // One statement per entry — op-sqlite executes single statements. const MIGRATIONS: readonly (readonly string[])[] = [ @@ -325,6 +327,29 @@ const MIGRATIONS: readonly (readonly string[])[] = [ remote_updated_at INTEGER NOT NULL )`, ], + // v17 -> v18 — cached lyrics (LRC/XLRC) from online lookup (xlrcdb + lrclib). + // Keyed by track_path (SAF URI / remote identity), no FK — survives folder + // removal/re-grant like waveform_peaks. metadata_signature (sha1 of + // title/artist/album/duration) invalidates the row when the track's tags change; + // `status` distinguishes a real hit from a cached "no lyrics found" (so we don't + // re-hit the network every open); synced_lines_json is the parsed LyricsLine[] the + // renderer consumes. Non-ASCII lyric text stores via the op-sqlite UTF-8/Latin1 + // param workaround (see database.ts). Local/embedded sources land in the v2 phase. + [ + `CREATE TABLE IF NOT EXISTS lyrics_cache ( + track_path TEXT PRIMARY KEY NOT NULL, + metadata_signature TEXT, + status TEXT NOT NULL, + source TEXT, + provider TEXT, + format TEXT, + plain_lyrics TEXT, + synced_lyrics TEXT, + synced_lines_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_lyrics_cache_updated ON lyrics_cache(updated_at)', + ], ]; export async function migrate(db: LibraryDatabase): Promise { diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b59fb343231933c816f541b21b54096ee74396 GIT binary patch literal 7801 zcmd5>+in}l5zVu{q6{pSq(aWvd8AhM#@=?Wrv4K$o#x@ROavVc70 z1A=_veo0PMbaiZq$!Yp zvsLbVO;uUsrKO^(uA0r1*12<)%gYO@FD>1eYrCr48m~=e-BFs-*t=!wm(|9m@oiH3 z$(&3{c{y7Z`Nd^TFU^KBn>8EjX<3!a#yMNoXyP$BV`0wvrohjQbv}nObtT&t zqW3#dlWMalY(m$Ma1Q7|W7T+u|F)AkOCdcF{rvZT(1*e;vjvSyYco$-gG1+4IgxjG zRpbkK45D#FRe{ErIc(vmtm=2GswuMxOTNkLOVKE^{;IAvR5qquS`3+*3>}M@jDlc@ zIHs(!z8uxGD5~WZ=B%*6Wl)Pnn!2{@O`XtNY>qjmNbP!IGgvG2d1jZ!k*^xJgyqIC z6TN8`zAE!&`a4?`+f`nYx9(b+Qw{oXU*v%i3*+V)c+eHrD0V_htJ_^XP^B(3=hS zra0JTH%sK-(pzWXjZRx0_Ni&Iyz1uO($ZN|TUGd#*)rQ?`6Q^pNo;#0L~s<7>E{Ra$Xi+i`$*Zr+OAzdS}FWsA!J&&4x zVr2ugR~f_Th;ayb=4EZ&$^hcOiFo>9!-4JT_J{~6YH43v3AHD4T2xhGO?iSh8Uf#z zoI|qytGlBkfj9m7)vI?uegFE+Z+|$a(=HAY-dWB2Btf)!Y(`I>(CA_ED0#@L6}Apx z{VK%QobIt7eMI6Y3draGPGqSXzgw!F+cW*{(xX*VF8Sny$kOe`NWS-N%+|?QG=8Ao zh}%teG-u81&3#hF8Wy@o-}~dqgpKTM-MDf`f3>`w`4Y(pVXD}f#2^2=NRrjJVzU7--XKZ#sWr}A77iIv2$w5tFpQ&amcbk zR(H{T@n{_ENX{~wPESv1r1M7f_1APROOo6_&&#~FDs#ebbpGR0dN_&Jerf8<#Brqx z^Ln08l)?V#@iD!T0y?D^)~&6daS)QvFOX9jXS>WH#DHrmkieyrqXQ+`mdh=frK`Lr z*OWe?YYmPTjAg7#Y3{VcFOV~mmBvb=d2B|>jCz!n+Po^|FfByStqe)U%XIvib zS#@LGGt`ywByo0AAPtPuKlxv#NPHvWiL!9$D1~fHgspmG@DH~I1chOA`FNXZ4pAi)Q`VRWgZ_(5SaGa-9C^U399hac02m zg!9?#Q4cqvppM^vbt~t(`^8tcs;WHk_WTg#+`Wey0cB5{yZ~xUAd)0IDmh@r5eQ(h zoxy)wp_g2d1x+Exx4B}$qSQe@vbV-yuFI==sNtGQIg9MLa5vN*$g(zGG$ue5nbgn* z(F23Yl!SjgX;BoX?73f4N`#bPK-YgB1NScN0YTEV==Jd-133ZlN{CJm;OWWQ1)CZr&fk}HH;hyz;Lkxj zg#eC)Q%Co9oUrwHpJ(m*T-GKat##uyy@HdECX#ugUpIl*okl(-I0{T{EJh3$ibQ$1 zoG-Lgkc4ey@3TKOW32p>fz!so_ z{pPyLv&RAejeJ}VPs!XEzV7v}GGno|z8GFo#@S*T5G2rMozy1lz_Z)J0KH-%)ETWg z#UtWV1y$>n0m!1Ey0>ve#VO3Xw|&_4;MPs(4F-jJ*z2b4BQlII68EO@AWOTOBui5- zFUR8vovMwA(&KO&zj5F&v@&_YJR|o=K4=f%R#Gs9;MI}`4!Qrls-Fw9Y3}Ay_>cnr#Y{mryoD&@M1c8M8||Xa#dWTzo7|=uWuj@S~f^BzV@c3+^mTAitt0p z`H8eKv~sXSpewYHY=-U$qlJ6Nh<7a@+wyNR~Bdt~6JW)xcWb_Y>p$Ndl2+ zyn42YsqJJuuiRT^B=>R2DRz{*>3sjTdP=uc& z1ar2xVXS(px8ZSI-G41PPgk{Zg(aUP&%HHQc8l3yi^?yVyf8m(Me&dRb|?^!P!AsR z_|751i}yfDJcs=NCe2jw6kb!%C2+zRyNV7>nBttaD#n4HoZ9#+$N*Xxb!u|;U9X}b z!w=dBaH2FuL+gNZ9mtSD2X2tqF-QJ-8uQ8uE%3}Jr*#|tN2wY z%KZCKnj9Vm^Th+P3JN@hJ9F^2?uZ$a2U~~^kKHv-hUag`!5F?6zeCY`7KKlO79Dm! zU7fk*q6t8<1elC^Q+{dZaHrFPlM1iyqi{$EHT0p2mi{)%MW&Sg4%|Jwh?7*fN{CWX z0aJQW6&aFHkgX~cC;%C5@oI+ZH-=+Z7z{2)pXdfz>Vz!XPFoy+{~)tMGIUWS@*0|m zfZnn|4-+xc)|?m#NOkMA&2scJi!Cym{IM-msXda+ZHD+t+RsYvRCj4l3<9D%E)84) zL#h<^DgWJ(ExZl@=p0rC3?9JR3x6^=t zgW5;x+cD?Xj^NLc@{V-vEX%$05l4|bL$;nq3%FLiK9sa&hQ381&Ed~x9U(=ka9`w} zv!eACvtmo8%mJ-VW5Pr>~0Y&!;THth+yV^bVen=$l-&9 z>c=m3xj0#OQ8D|DIFqLNf8>b1$p6w215y8$BMz+m5=R7|>n%hBI?~Q}O=+${&Kc3| z4S63A2jd~inDu2GM8W|D!1K^qL;`zf`BUR3_THU|uank$bAdkVz#d@~2EkW@op(Ic z(+qj^DH18g6QHSS`3nPRqrnGDl=!^PuemK6J_B~&O9|;4)bhsS6CZ!zb9RPc0q!A_ zTU@2BZz^|%x~Z8P#SOfZzgBMPrsJ`MsHx}zA6Bs=GE9MQp&kuR{)mJ65adN0rAx54 y3>{^BnDw5Ssbs=IT=u=&>fM^Pmm<#Nwd!D=kd)Bx2YeNO9%> literal 0 HcmV?d00001 diff --git a/src/lyrics/parsing.test.mts b/src/lyrics/parsing.test.mts new file mode 100644 index 0000000..fe2ff8e --- /dev/null +++ b/src/lyrics/parsing.test.mts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseLyricsText, sanitizeLyricsLines } from './parsing.ts'; + +test('plain LRC parses timestamps and preserves order', () => { + const payload = parseLyricsText('[00:15.50]World\n[00:12.00]Hello', 'lrclib', 'lrc'); + assert.ok(payload); + assert.equal(payload.format, 'lrc'); + assert.equal(payload.syncedLines.length, 2); + // Lines are re-sorted by timestamp. + assert.deepEqual( + payload.syncedLines.map((line) => [line.timestampMs, line.text]), + [ + [12000, 'Hello'], + [15500, 'World'], + ] + ); +}); + +test('plain LRC without timestamps degrades to plain format', () => { + const payload = parseLyricsText('just some words\nno timing here', 'lrclib', 'lrc'); + assert.ok(payload); + assert.equal(payload.format, 'plain'); + assert.equal(payload.syncedLines.length, 0); + assert.ok(payload.plainLyrics?.includes('just some words')); +}); + +test('XLRC furigana attaches a ruby over the kanji only', () => { + const payload = parseLyricsText('[00:01.00]私[わたし]は', 'xlrcdb', 'xlrc'); + assert.ok(payload); + assert.equal(payload.format, 'xlrc'); + const [line] = payload.syncedLines; + assert.equal(line.text, '私は'); + assert.ok(line.furigana && line.furigana.length === 1); + assert.deepEqual(line.furigana[0], { start: 0, end: 1, base: '私', reading: 'わたし' }); +}); + +test('XLRC inline translation attaches to the preceding lyric line', () => { + const payload = parseLyricsText("[00:01.00]君がいいの\n[>en]It's you I want", 'xlrcdb', 'xlrc'); + assert.ok(payload); + const [line] = payload.syncedLines; + assert.ok(line.translations && line.translations.length === 1); + assert.equal(line.translations[0].lang, 'en'); + assert.equal(line.translations[0].text, "It's you I want"); +}); + +test('XLRC word timing yields per-word cues', () => { + const payload = parseLyricsText('[00:01.00]<00:01.00>Hello <00:01.50>World', 'xlrcdb', 'xlrc'); + assert.ok(payload); + const [line] = payload.syncedLines; + assert.ok(line.words && line.words.length === 2); + assert.equal(line.words[0].text.trim(), 'Hello'); + assert.equal(line.words[1].timestampMs, 1500); +}); + +test('empty timestamp line becomes a silence cue', () => { + const payload = parseLyricsText('[00:00.00]Intro\n[00:20.00]', 'xlrcdb', 'xlrc'); + assert.ok(payload); + const silence = payload.syncedLines.find((line) => line.kind === 'silence'); + assert.ok(silence); + assert.equal(silence.timestampMs, 20000); +}); + +test('offset header shifts every timestamp', () => { + const payload = parseLyricsText('[offset:500]\n[00:10.00]Line', 'lrclib', 'lrc'); + assert.ok(payload); + assert.equal(payload.syncedLines[0].timestampMs, 10500); +}); + +test('sanitizeLyricsLines drops out-of-range furigana', () => { + const lines = sanitizeLyricsLines([ + { timestampMs: 0, text: 'ab', furigana: [{ start: 0, end: 5, base: 'ab', reading: 'x' }] }, + ]); + assert.equal(lines.length, 1); + assert.equal(lines[0].furigana, undefined); +}); diff --git a/src/lyrics/parsing.ts b/src/lyrics/parsing.ts new file mode 100644 index 0000000..763dec9 --- /dev/null +++ b/src/lyrics/parsing.ts @@ -0,0 +1,288 @@ +// Parsing bridge — ported near-verbatim from desktop +// (astra/src/main/services/lyricsParsing.ts). Converts the `@boof2015/xlrc` +// parser output (XLRCLine) into the app-internal LyricsLine contract, keeping +// rich fields (words/furigana/translations/voice) for XLRC and dropping them +// for plain LRC. Pure functions — no RN/Node dependencies. + +import { parseXLRC, type XLRCFile, type XLRCLine } from '@boof2015/xlrc'; +import type { + LyricsFormat, + LyricsFurigana, + LyricsLine, + LyricsPayload, + LyricsTranslation, + LyricsWord, +} from './types'; + +export function normalizeLyricsText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.replace(/\r\n/g, '\n').trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeTimestampMs(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : null; +} + +function sanitizeFurigana(raw: unknown, text: string): LyricsFurigana[] { + if (!Array.isArray(raw)) return []; + + const furigana: LyricsFurigana[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as { start?: unknown; end?: unknown; base?: unknown; reading?: unknown }; + if (typeof record.base !== 'string' || typeof record.reading !== 'string') continue; + if (typeof record.start !== 'number' || typeof record.end !== 'number') continue; + if (!Number.isInteger(record.start) || !Number.isInteger(record.end)) continue; + const start = record.start; + const end = record.end; + if (start < 0 || end <= start || end > text.length) continue; + const base = record.base.trim(); + const reading = record.reading.trim(); + if (!base || !reading) continue; + + furigana.push({ start, end, base, reading }); + } + + return furigana; +} + +function sanitizeWords(raw: unknown): LyricsWord[] { + if (!Array.isArray(raw)) return []; + + const words: LyricsWord[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as { timestampMs?: unknown; timestamp?: unknown; text?: unknown; furigana?: unknown }; + if (typeof record.text !== 'string') continue; + const timestampMs = normalizeTimestampMs(record.timestampMs ?? record.timestamp); + if (timestampMs === null) continue; + const text = record.text; + if (!text.trim()) continue; + const furigana = sanitizeFurigana(record.furigana, text); + + words.push({ + timestampMs, + text, + ...(furigana.length > 0 ? { furigana } : {}), + }); + } + + return words; +} + +function sanitizeTranslations(raw: unknown): LyricsTranslation[] { + if (!Array.isArray(raw)) return []; + + const translations: LyricsTranslation[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as { lang?: unknown; text?: unknown }; + if (typeof record.lang !== 'string' || typeof record.text !== 'string') continue; + const lang = record.lang.trim(); + const text = record.text.trim(); + if (!lang || !text) continue; + translations.push({ lang, text }); + } + + return translations; +} + +export function sanitizeLyricsLines(rawValue: unknown): LyricsLine[] { + if (!Array.isArray(rawValue)) return []; + + const lines: LyricsLine[] = []; + for (const entry of rawValue) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const record = entry as { + timestampMs?: unknown; + text?: unknown; + kind?: unknown; + words?: unknown; + furigana?: unknown; + translations?: unknown; + voice?: unknown; + }; + if (typeof record.text !== 'string') continue; + + const timestampMs = normalizeTimestampMs(record.timestampMs); + if (timestampMs === null) continue; + + if (record.kind === 'silence') { + lines.push({ timestampMs, text: '', kind: 'silence' }); + continue; + } + + const text = record.text.trim(); + if (!text) continue; + + const words = sanitizeWords(record.words); + const furigana = sanitizeFurigana(record.furigana, text); + const translations = sanitizeTranslations(record.translations); + const voice = typeof record.voice === 'string' && record.voice.trim() ? record.voice.trim() : null; + + lines.push({ + timestampMs, + text, + ...(words.length > 0 ? { words } : {}), + ...(furigana.length > 0 ? { furigana } : {}), + ...(translations.length > 0 ? { translations } : {}), + ...(voice ? { voice } : {}), + }); + } + + lines.sort((left, right) => left.timestampMs - right.timestampMs); + return lines; +} + +function normalizeParsedOffsetMs(file: XLRCFile): number { + const offset = file.meta.offset; + return typeof offset === 'number' && Number.isFinite(offset) ? Math.trunc(offset) : 0; +} + +function applyParsedOffsetMs(timestampMs: number, offsetMs: number): number { + return Math.max(0, Math.floor(timestampMs + offsetMs)); +} + +function mapParsedLine(line: XLRCLine, offsetMs: number, preserveRichFields: boolean): LyricsLine { + const timestampMs = applyParsedOffsetMs(line.timestamp, offsetMs); + const text = line.text.trim(); + if (line.isEmpty || !text) { + return { + timestampMs, + text: '', + kind: 'silence', + }; + } + + if (!preserveRichFields) { + return { timestampMs, text }; + } + + const words = line.words + .map((word): LyricsWord => { + const wordText = word.text; + const wordFurigana = sanitizeFurigana(word.furigana, wordText); + return { + timestampMs: applyParsedOffsetMs(word.timestamp, offsetMs), + text: wordText, + ...(wordFurigana.length > 0 ? { furigana: wordFurigana } : {}), + }; + }) + .filter((word) => word.text.trim().length > 0); + const furigana = sanitizeFurigana(line.furigana, text); + const translations = sanitizeTranslations(line.translations); + const voice = line.voice?.trim() || null; + + return { + timestampMs, + text, + ...(words.length > 0 ? { words } : {}), + ...(furigana.length > 0 ? { furigana } : {}), + ...(translations.length > 0 ? { translations } : {}), + ...(voice ? { voice } : {}), + }; +} + +function parsePackageSyncedLines(lyricsText: string, preserveRichFields: boolean): LyricsLine[] { + const normalizedText = normalizeLyricsText(lyricsText); + if (!normalizedText) return []; + + const parsed = parseXLRC(normalizedText); + const offsetMs = normalizeParsedOffsetMs(parsed); + return sanitizeLyricsLines( + parsed.lines.map((line) => mapParsedLine(line, offsetMs, preserveRichFields)) + ); +} + +export function parseLrcSyncedLines(lyricsText: string): LyricsLine[] { + return parsePackageSyncedLines(lyricsText, false); +} + +function parseXlrcSyncedLines(lyricsText: string): LyricsLine[] { + return parsePackageSyncedLines(lyricsText, true); +} + +export function toPlainLyricsFromLines(lines: LyricsLine[]): string | null { + const textLines = lines + .filter((line) => line.kind !== 'silence' && line.text.trim().length > 0) + .map((line) => line.text); + if (textLines.length === 0) return null; + return textLines.join('\n'); +} + +export function createLyricsPayload( + source: LyricsPayload['source'], + provider: LyricsPayload['provider'], + format: LyricsFormat, + plainLyrics: string | null, + syncedLyrics: string | null, + syncedLines: LyricsLine[] +): LyricsPayload | null { + const normalizedPlain = normalizeLyricsText(plainLyrics); + const normalizedSynced = normalizeLyricsText(syncedLyrics); + const parsedSyncedLines = + normalizedSynced && (format === 'lrc' || format === 'xlrc') + ? parsePackageSyncedLines(normalizedSynced, format === 'xlrc') + : []; + const sourceLines = parsedSyncedLines.length > 0 ? parsedSyncedLines : syncedLines; + const normalizedLines = sanitizeLyricsLines(sourceLines); + + if (!normalizedPlain && !normalizedSynced && normalizedLines.length === 0) { + return null; + } + + return { + source, + provider, + format, + plainLyrics: normalizedPlain ?? toPlainLyricsFromLines(normalizedLines), + syncedLyrics: normalizedSynced ?? toPlainLyricsFromLines(normalizedLines), + syncedLines: normalizedLines, + }; +} + +function parseXlrcLyricsText(lyricsText: string, source: LyricsPayload['source']): LyricsPayload | null { + const normalizedText = normalizeLyricsText(lyricsText); + if (!normalizedText) return null; + + const syncedLines = parseXlrcSyncedLines(normalizedText); + if (syncedLines.length === 0) return null; + + return createLyricsPayload( + source, + null, + 'xlrc', + toPlainLyricsFromLines(syncedLines), + normalizedText, + syncedLines + ); +} + +export function parseLyricsText( + lyricsText: string, + source: LyricsPayload['source'], + format: LyricsFormat = 'lrc' +): LyricsPayload | null { + const normalizedText = normalizeLyricsText(lyricsText); + if (!normalizedText) return null; + + if (format === 'xlrc') { + return parseXlrcLyricsText(normalizedText, source); + } + + if (format === 'plain') { + return createLyricsPayload(source, null, 'plain', normalizedText, null, []); + } + + const syncedLines = parseLrcSyncedLines(normalizedText); + const hasSyncedLyrics = syncedLines.length > 0; + const syncedLyrics = hasSyncedLyrics ? normalizedText : null; + const plainLyrics = hasSyncedLyrics + ? toPlainLyricsFromLines(syncedLines) ?? normalizedText + : normalizedText; + + return createLyricsPayload(source, null, hasSyncedLyrics ? 'lrc' : 'plain', plainLyrics, syncedLyrics, syncedLines); +} diff --git a/src/lyrics/presentation.test.mts b/src/lyrics/presentation.test.mts new file mode 100644 index 0000000..29aa976 --- /dev/null +++ b/src/lyrics/presentation.test.mts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { LyricsLine, LyricsPayload } from './types.ts'; +import { + findActiveSyncedLineIndex, + getCompensatedLyricsTime, + getLyricsLineSeekTimeSeconds, + getLyricsMetaChipText, + getPreferredLyricsTranslation, + getSyncedLyricsDisplayLines, + resolveSyncedLyricsTiming, +} from './presentation.ts'; + +function lines(): LyricsLine[] { + return [ + { timestampMs: 0, text: 'A' }, + { timestampMs: 1000, text: 'B' }, + { timestampMs: 2000, text: 'C' }, + ]; +} + +test('active line resolves to the cue at or before the playback time', () => { + assert.equal(findActiveSyncedLineIndex(lines(), 0.5), 0); + assert.equal(findActiveSyncedLineIndex(lines(), 1.2), 1); + assert.equal(findActiveSyncedLineIndex(lines(), 2.9), 2); +}); + +test('before the first cue there is no active line', () => { + const timing = resolveSyncedLyricsTiming( + [ + { timestampMs: 5000, text: 'later' }, + ], + 1 + ); + assert.equal(timing.activeLineIndex, -1); + assert.equal(timing.isNeutral, true); +}); + +test('a long instrumental gap inserts a synthetic gap row and neutralizes', () => { + const withGap: LyricsLine[] = [ + { timestampMs: 0, text: 'A' }, + { timestampMs: 20000, text: 'B' }, + ]; + const display = getSyncedLyricsDisplayLines(withGap); + assert.ok(display.some((row) => row.kind === 'gap')); + + // 6s in (past the 4s post-line hold, within the 10s+ gap) → neutral. + const timing = resolveSyncedLyricsTiming(withGap, 6); + assert.equal(timing.activeLineIndex, -1); + assert.equal(timing.isNeutral, true); +}); + +test('translation selection honors the language priority list', () => { + const line: LyricsLine = { + timestampMs: 0, + text: 'x', + translations: [ + { lang: 'ja-Latn', text: 'romaji' }, + { lang: 'en', text: 'english' }, + ], + }; + assert.equal(getPreferredLyricsTranslation(line, ['en', 'ja-Latn'])?.text, 'english'); + assert.equal(getPreferredLyricsTranslation(line, ['ja-Latn'])?.text, 'romaji'); + assert.equal(getPreferredLyricsTranslation(line, ['fr'])?.text, 'romaji'); // falls back to first +}); + +test('seek + compensation math clamp to duration', () => { + assert.equal(getLyricsLineSeekTimeSeconds(1500, 200, 0), 1.5); + assert.equal(getLyricsLineSeekTimeSeconds(1500, 1, 0), 1); // clamped to duration + assert.equal(getCompensatedLyricsTime(10, 200, 500), 9.5); // 500ms delay subtracted + assert.equal(getCompensatedLyricsTime(0.2, 200, 500), 0); // never negative +}); + +function hit(source: LyricsPayload['source'], format: LyricsPayload['format'], cached: boolean) { + const lyrics: LyricsPayload = { + source, + provider: source === 'xlrcdb' ? 'xlrcdb' : 'lrclib', + format, + plainLyrics: 'x', + syncedLyrics: 'x', + syncedLines: [{ timestampMs: 0, text: 'x' }], + }; + return { status: 'hit' as const, lyrics, cached }; +} + +test('meta chip reflects source, sync, and cache state', () => { + assert.equal( + getLyricsMetaChipText({ hasTrack: true, result: hit('lrclib', 'lrc', false), hasSyncedLyrics: true, isLoading: false }), + 'LRCLIB • Synced' + ); + assert.equal( + getLyricsMetaChipText({ hasTrack: true, result: hit('xlrcdb', 'xlrc', true), hasSyncedLyrics: true, isLoading: false }), + 'XLRCDB • Synced • Cached' + ); + assert.equal( + getLyricsMetaChipText({ hasTrack: true, result: null, hasSyncedLyrics: false, isLoading: true }), + 'Loading' + ); + assert.equal( + getLyricsMetaChipText({ + hasTrack: true, + result: { status: 'not_found', reason: 'provider-not-found' }, + hasSyncedLyrics: false, + isLoading: false, + }), + 'Not Found' + ); +}); diff --git a/src/lyrics/presentation.ts b/src/lyrics/presentation.ts new file mode 100644 index 0000000..d97c844 --- /dev/null +++ b/src/lyrics/presentation.ts @@ -0,0 +1,437 @@ +// Lyrics timing + presentation — ported from desktop +// (astra/src/renderer/utils/lyricsPresentation.ts). Pure functions that map a +// playback position onto an active line/word and expand a line list into a +// display list with synthetic instrumental-gap rows. No RN dependencies, so it +// runs under `node --test`. The desktop-only body-state/display-settings copy is +// intentionally omitted; v1 renders furigana + translations unconditionally. + +import type { + LyricsFormat, + LyricsLine, + LyricsPayload, + LyricsSource, + LyricsTranslation, + LyricsWord, +} from './types'; + +export function getLyricsSourceLabel(source: LyricsSource, format?: LyricsFormat): string { + if (source === 'embedded') return 'Embedded'; + if (source === 'manual') return format === 'xlrc' ? 'Manual XLRC' : 'Manual'; + if (source === 'xlrc') return 'XLRC File'; + if (source === 'lrc') return 'LRC File'; + if (source === 'xlrcdb') return 'XLRCDB'; + return 'LRCLIB'; +} + +export function getLyricsPayloadSourceLabel(payload: LyricsPayload): string { + return getLyricsSourceLabel(payload.source, payload.format); +} + +export const LYRICS_INFERRED_GAP_THRESHOLD_MS = 10_000; +export const LYRICS_POST_LINE_HOLD_MS = 4_000; + +export interface RenderableSyncedLine { + line: LyricsLine; + cueIndex: number; + displayIndex: number; +} + +export type SyncedLyricsDisplayLine = + | { + kind: 'lyric'; + line: LyricsLine; + cueIndex: number; + afterCueIndex: null; + displayIndex: number; + key: string; + timestampMs: number; + text: string; + } + | { + kind: 'gap'; + cueIndex: number | null; + afterCueIndex: number | null; + displayIndex: number; + key: string; + timestampMs: number; + text: ''; + progressStartMs: number; + progressEndMs: number | null; + }; + +export interface SyncedLyricsTimingOptions { + durationSeconds?: number | null; + neutralGapThresholdMs?: number; + postLineHoldMs?: number; +} + +export interface SyncedLyricsTimingState { + activeCueIndex: number; + activeLineIndex: number; + focusLineIndex: number; + isNeutral: boolean; +} + +function toPlaybackTimeMs(currentTimeSeconds: number): number { + return Number.isFinite(currentTimeSeconds) ? Math.max(0, Math.floor(currentTimeSeconds * 1000)) : 0; +} + +function toDurationMs(durationSeconds: number | null | undefined): number | null { + if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return null; + } + return Math.floor(durationSeconds * 1000); +} + +export function getCompensatedLyricsTime( + currentTimeSeconds: number, + durationSeconds: number | null | undefined, + effectiveDelayMs: number +): number { + const normalizedTime = Number.isFinite(currentTimeSeconds) ? Math.max(0, currentTimeSeconds) : 0; + const normalizedDelaySeconds = Number.isFinite(effectiveDelayMs) ? Math.max(0, effectiveDelayMs) / 1000 : 0; + const compensatedTime = Math.max(0, normalizedTime - normalizedDelaySeconds); + if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return compensatedTime; + } + return Math.min(durationSeconds, compensatedTime); +} + +export function getLyricsLineSeekTimeSeconds( + timestampMs: number, + durationSeconds: number | null | undefined, + effectiveDelayMs: number +): number | null { + if (!Number.isFinite(timestampMs) || timestampMs < 0) return null; + + const normalizedDelaySeconds = Number.isFinite(effectiveDelayMs) ? Math.max(0, effectiveDelayMs) / 1000 : 0; + const seekTimeSeconds = Math.max(0, timestampMs / 1000 + normalizedDelaySeconds); + if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return seekTimeSeconds; + } + return Math.min(durationSeconds, seekTimeSeconds); +} + +export function isRenderableSyncedLine(line: LyricsLine): boolean { + return line.kind !== 'silence' && line.text.trim().length > 0; +} + +export function getRenderableSyncedLines(lines: LyricsLine[]): RenderableSyncedLine[] { + const renderableLines: RenderableSyncedLine[] = []; + lines.forEach((line, cueIndex) => { + if (!isRenderableSyncedLine(line)) return; + renderableLines.push({ + line, + cueIndex, + displayIndex: renderableLines.length, + }); + }); + return renderableLines; +} + +function findNextRenderableLineTimestamp(lines: LyricsLine[], startIndex: number): number | null { + for (let index = startIndex; index < lines.length; index += 1) { + if (isRenderableSyncedLine(lines[index])) return lines[index].timestampMs; + } + return null; +} + +export function getSyncedLyricsDisplayLines( + lines: LyricsLine[], + options: SyncedLyricsTimingOptions = {} +): SyncedLyricsDisplayLine[] { + const displayLines: SyncedLyricsDisplayLine[] = []; + const postLineHoldMs = options.postLineHoldMs ?? LYRICS_POST_LINE_HOLD_MS; + const neutralGapThresholdMs = options.neutralGapThresholdMs ?? LYRICS_INFERRED_GAP_THRESHOLD_MS; + const durationMs = toDurationMs(options.durationSeconds); + + lines.forEach((line, cueIndex) => { + const displayIndex = displayLines.length; + if (isRenderableSyncedLine(line)) { + displayLines.push({ + kind: 'lyric', + line, + cueIndex, + afterCueIndex: null, + displayIndex, + key: `lyric:${line.timestampMs}:${cueIndex}`, + timestampMs: line.timestampMs, + text: line.text, + }); + + const nextCue = lines[cueIndex + 1] ?? null; + const nextCueGapMs = nextCue ? nextCue.timestampMs - line.timestampMs : null; + const outroGapMs = durationMs === null ? null : durationMs - line.timestampMs; + const shouldInsertGap = + (nextCueGapMs !== null && nextCueGapMs >= neutralGapThresholdMs) || + (!nextCue && outroGapMs !== null && outroGapMs >= neutralGapThresholdMs); + + if (shouldInsertGap) { + const gapTimestampMs = line.timestampMs + postLineHoldMs; + const progressEndMs = findNextRenderableLineTimestamp(lines, cueIndex + 1) ?? durationMs; + displayLines.push({ + kind: 'gap', + cueIndex: null, + afterCueIndex: cueIndex, + displayIndex: displayLines.length, + key: `gap-after:${line.timestampMs}:${cueIndex}`, + timestampMs: gapTimestampMs, + text: '', + progressStartMs: line.timestampMs, + progressEndMs, + }); + } + return; + } + + if (line.kind !== 'silence') return; + const progressEndMs = findNextRenderableLineTimestamp(lines, cueIndex + 1) ?? durationMs; + displayLines.push({ + kind: 'gap', + cueIndex, + afterCueIndex: null, + displayIndex, + key: `gap-cue:${line.timestampMs}:${cueIndex}`, + timestampMs: line.timestampMs, + text: '', + progressStartMs: line.timestampMs, + progressEndMs, + }); + }); + + return displayLines; +} + +export function getSyncedLyricsGapProgress(line: SyncedLyricsDisplayLine, currentTimeSeconds: number): number | null { + if (line.kind !== 'gap') return null; + if (line.progressEndMs === null || line.progressEndMs <= line.progressStartMs) return null; + + const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds); + const progress = (currentTimeMs - line.progressStartMs) / (line.progressEndMs - line.progressStartMs); + return Math.max(0, Math.min(1, progress)); +} + +export function getPreferredLyricsTranslation( + line: LyricsLine, + languagePriority: string[] +): LyricsTranslation | null { + const translations = line.translations ?? []; + if (translations.length === 0) return null; + + const normalizedPriority = languagePriority.map((lang) => lang.trim().toLocaleLowerCase()).filter(Boolean); + for (const preferredLang of normalizedPriority) { + const match = translations.find((translation) => translation.lang.toLocaleLowerCase() === preferredLang); + if (match) return match; + } + + return translations[0] ?? null; +} + +export interface LyricsWordTimingState { + activeWordIndex: number; + progressByIndex: number[]; +} + +export function resolveLyricsWordTiming(words: LyricsWord[], currentTimeSeconds: number): LyricsWordTimingState { + if (words.length === 0) { + return { + activeWordIndex: -1, + progressByIndex: [], + }; + } + + const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds); + let activeWordIndex = -1; + for (let index = 0; index < words.length; index += 1) { + if (words[index].timestampMs <= currentTimeMs) { + activeWordIndex = index; + continue; + } + break; + } + + const progressByIndex = words.map((word, index) => { + if (index < activeWordIndex) return 1; + if (index > activeWordIndex || activeWordIndex < 0) return 0; + + const nextWord = words[index + 1] ?? null; + if (!nextWord || nextWord.timestampMs <= word.timestampMs) return 1; + return Math.max(0, Math.min(1, (currentTimeMs - word.timestampMs) / (nextWord.timestampMs - word.timestampMs))); + }); + + return { + activeWordIndex, + progressByIndex, + }; +} + +export function hasRenderableSyncedLines(lines: LyricsLine[]): boolean { + return lines.some(isRenderableSyncedLine); +} + +function findCueIndexAtOrBefore(lines: LyricsLine[], currentTimeMs: number): number { + if (lines.length === 0) return -1; + + let low = 0; + let high = lines.length - 1; + let best = -1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (lines[mid].timestampMs <= currentTimeMs) { + best = mid; + low = mid + 1; + continue; + } + high = mid - 1; + } + + return best; +} + +function findDisplayIndexForCueIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number { + const match = displayLines.find((line) => line.cueIndex === cueIndex); + return match?.displayIndex ?? -1; +} + +function findGapDisplayIndexAfterCue(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number { + const match = displayLines.find((line) => line.kind === 'gap' && line.afterCueIndex === cueIndex); + return match?.displayIndex ?? -1; +} + +function findPreviousDisplayIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number { + for (let index = displayLines.length - 1; index >= 0; index -= 1) { + const displayLineCueIndex = displayLines[index].cueIndex ?? displayLines[index].afterCueIndex; + if (displayLineCueIndex !== null && displayLineCueIndex <= cueIndex) return displayLines[index].displayIndex; + } + return -1; +} + +function findNextDisplayIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number { + for (const line of displayLines) { + const displayLineCueIndex = line.cueIndex ?? line.afterCueIndex; + if (displayLineCueIndex !== null && displayLineCueIndex > cueIndex) return line.displayIndex; + } + return -1; +} + +function resolveNeutralFocusLineIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number { + const currentLineIndex = findDisplayIndexForCueIndex(displayLines, cueIndex); + if (currentLineIndex >= 0) return currentLineIndex; + const previousLineIndex = findPreviousDisplayIndex(displayLines, cueIndex); + if (previousLineIndex >= 0) return previousLineIndex; + const nextLineIndex = findNextDisplayIndex(displayLines, cueIndex); + if (nextLineIndex >= 0) return nextLineIndex; + return -1; +} + +export function resolveSyncedLyricsTiming( + lines: LyricsLine[], + currentTimeSeconds: number, + options: SyncedLyricsTimingOptions = {} +): SyncedLyricsTimingState { + const renderableLines = getRenderableSyncedLines(lines); + const displayLines = getSyncedLyricsDisplayLines(lines, options); + if (renderableLines.length === 0) { + return { + activeCueIndex: -1, + activeLineIndex: -1, + focusLineIndex: -1, + isNeutral: true, + }; + } + + const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds); + const latestCueIndex = findCueIndexAtOrBefore(lines, currentTimeMs); + if (latestCueIndex < 0) { + return { + activeCueIndex: -1, + activeLineIndex: -1, + focusLineIndex: 0, + isNeutral: true, + }; + } + + const latestCue = lines[latestCueIndex]; + if (!isRenderableSyncedLine(latestCue)) { + return { + activeCueIndex: latestCueIndex, + activeLineIndex: -1, + focusLineIndex: resolveNeutralFocusLineIndex(displayLines, latestCueIndex), + isNeutral: true, + }; + } + + const displayIndex = findDisplayIndexForCueIndex(displayLines, latestCueIndex); + const postLineHoldMs = options.postLineHoldMs ?? LYRICS_POST_LINE_HOLD_MS; + const neutralGapThresholdMs = options.neutralGapThresholdMs ?? LYRICS_INFERRED_GAP_THRESHOLD_MS; + const nextCue = lines[latestCueIndex + 1] ?? null; + const nextCueGapMs = nextCue ? nextCue.timestampMs - latestCue.timestampMs : null; + const shouldNeutralizeForNextCue = + nextCueGapMs !== null && + nextCueGapMs >= neutralGapThresholdMs && + currentTimeMs >= latestCue.timestampMs + postLineHoldMs; + + const durationMs = toDurationMs(options.durationSeconds); + const outroGapMs = durationMs === null ? null : durationMs - latestCue.timestampMs; + const shouldNeutralizeForOutro = + !nextCue && + outroGapMs !== null && + outroGapMs >= neutralGapThresholdMs && + currentTimeMs >= latestCue.timestampMs + postLineHoldMs; + + if (shouldNeutralizeForNextCue || shouldNeutralizeForOutro) { + const gapDisplayIndex = findGapDisplayIndexAfterCue(displayLines, latestCueIndex); + return { + activeCueIndex: latestCueIndex, + activeLineIndex: -1, + focusLineIndex: gapDisplayIndex >= 0 ? gapDisplayIndex : displayIndex, + isNeutral: true, + }; + } + + return { + activeCueIndex: latestCueIndex, + activeLineIndex: displayIndex, + focusLineIndex: displayIndex, + isNeutral: false, + }; +} + +export function findActiveSyncedLineIndex( + lines: LyricsLine[], + currentTimeSeconds: number, + options: SyncedLyricsTimingOptions = {} +): number { + return resolveSyncedLyricsTiming(lines, currentTimeSeconds, options).activeLineIndex; +} + +/** + * The "MANUAL XLRC • SYNCED" style status chip. Trimmed from the desktop + * variant so it takes only the pieces the mobile band has, not the full Track. + */ +export function getLyricsMetaChipText(options: { + hasTrack: boolean; + result: { status: 'hit'; lyrics: LyricsPayload; cached: boolean } | { status: string; reason?: string } | null; + hasSyncedLyrics: boolean; + isLoading: boolean; +}): string { + const { hasTrack, result, hasSyncedLyrics, isLoading } = options; + if (!hasTrack) return 'No Track'; + if (isLoading && !result) return 'Loading'; + if (result?.status === 'hit') { + const hit = result as { status: 'hit'; lyrics: LyricsPayload; cached: boolean }; + const sourceLabel = getLyricsPayloadSourceLabel(hit.lyrics); + const syncLabel = hasSyncedLyrics ? 'Synced' : 'Unsynced'; + const cachedLabel = hit.cached ? ' • Cached' : ''; + return `${sourceLabel} • ${syncLabel}${cachedLabel}`; + } + if (result?.status === 'transient_error') return 'Error'; + if (result?.status === 'not_found') { + const reason = (result as { reason?: string }).reason; + if (reason === 'online-disabled') return 'Online Off'; + if (reason === 'provider-unavailable') return 'Lyrics Slow'; + return 'Not Found'; + } + return 'Ready'; +} diff --git a/src/lyrics/types.ts b/src/lyrics/types.ts new file mode 100644 index 0000000..37ed7d8 --- /dev/null +++ b/src/lyrics/types.ts @@ -0,0 +1,62 @@ +// Lyrics data contract — ported field-for-field from desktop +// (astra/src/types/lyrics.ts) so the parsing/presentation logic ports cleanly. +// v1 covers online lookup (xlrcdb + lrclib) + a fullscreen synced view. The +// 'manual'/'embedded'/'lrc'/'xlrc' sources exist for label parity and the v2 +// local/embedded phase, even though v1 only produces 'xlrcdb'/'lrclib'. + +export type LyricsProvider = 'lrclib' | 'xlrcdb'; +export type LyricsSource = 'embedded' | 'lrclib' | 'manual' | 'lrc' | 'xlrc' | 'xlrcdb'; +export type LyricsFormat = 'plain' | 'lrc' | 'xlrc'; + +export interface LyricsFurigana { + start: number; + end: number; + base: string; + reading: string; +} + +export interface LyricsWord { + timestampMs: number; + text: string; + furigana?: LyricsFurigana[]; +} + +export interface LyricsTranslation { + lang: string; + text: string; +} + +export interface LyricsLine { + timestampMs: number; + text: string; + kind?: 'silence'; + words?: LyricsWord[]; + furigana?: LyricsFurigana[]; + translations?: LyricsTranslation[]; + voice?: string | null; +} + +export interface LyricsTrackQuery { + path: string; + title: string; + artist: string; + album?: string; + durationSeconds?: number; +} + +export interface LyricsPayload { + source: LyricsSource; + provider: LyricsProvider | null; + format: LyricsFormat; + plainLyrics: string | null; + syncedLyrics: string | null; + syncedLines: LyricsLine[]; +} + +export type LyricsLookupResult = + | { status: 'hit'; lyrics: LyricsPayload; cached: boolean } + | { + status: 'not_found'; + reason: 'embedded-missing' | 'online-disabled' | 'provider-not-found' | 'provider-unavailable'; + } + | { status: 'transient_error'; message: string; code?: string }; diff --git a/src/services/lyrics/lrclib.ts b/src/services/lyrics/lrclib.ts new file mode 100644 index 0000000..8f33349 --- /dev/null +++ b/src/services/lyrics/lrclib.ts @@ -0,0 +1,348 @@ +// LRCLIB provider — ported from desktop (astra/src/main/services/lyricsLrclib.ts). +// Two-stage lookup: exact metadata /get, then a scored /search fallback. Uses +// global fetch + AbortController (both available under RN). Unicode +// normalization goes through the Hermes-safe helpers in ./unicode. + +import { createLyricsPayload, normalizeLyricsText, parseLrcSyncedLines } from '@/lyrics/parsing'; +import type { LyricsPayload, LyricsTrackQuery } from '@/lyrics/types'; +import { COMBINING_MARKS_RE, CONTROL_CHARS_RE, NON_ALNUM_RE, safeNormalize } from './unicode'; + +export const LRCLIB_GET_URL = 'https://lrclib.net/api/get'; +export const LRCLIB_SEARCH_URL = 'https://lrclib.net/api/search'; +export const LRCLIB_PROJECT_URL = 'https://github.com/Boof2015/astra'; +export const LRCLIB_REQUEST_TIMEOUT_MS = 15_000; +export const LRCLIB_PROVIDER_COOLDOWN_MS = 60_000; + +const UNKNOWN_APP_VERSION = 'unknown'; + +export interface LrclibClientConfig { + appVersion: string; + requestTimeoutMs: number; + now: () => number; +} + +export type LrclibLookupResult = + | { status: 'hit'; lyrics: LyricsPayload } + | { status: 'not_found' } + | { status: 'provider_unavailable' } + | { status: 'transient_error'; message: string; code?: string }; + +type FetchJsonResult = + | { kind: 'ok'; payload: T } + | { kind: 'http_error'; status: number } + | { kind: 'timeout' } + | { kind: 'network_error' } + | { kind: 'invalid_payload' }; + +interface ScoredLrclibCandidate { + entry: Record; + score: number; +} + +export function normalizeLrclibAppVersion(value: string | null | undefined): string { + const normalized = typeof value === 'string' ? value.trim().replace(/\s+/g, '-') : ''; + return normalized.length > 0 ? normalized : UNKNOWN_APP_VERSION; +} + +export function createLrclibClientConfig(options: { + appVersion: string; + requestTimeoutMs?: number; + now?: () => number; +}): LrclibClientConfig { + return { + appVersion: normalizeLrclibAppVersion(options.appVersion), + requestTimeoutMs: options.requestTimeoutMs ?? LRCLIB_REQUEST_TIMEOUT_MS, + now: options.now ?? Date.now, + }; +} + +function createLrclibClientHeaders(config: Pick): Record { + const client = `Astra/${normalizeLrclibAppVersion(config.appVersion)} (${LRCLIB_PROJECT_URL})`; + return { + Accept: 'application/json', + 'Lrclib-Client': client, + 'User-Agent': client, + }; +} + +export function normalizeLrclibMetadataText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.replace(CONTROL_CHARS_RE, ' ').replace(/\s+/g, ' ').trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeMatchKey(value: string): string { + return safeNormalize(value, 'NFKD') + .replace(COMBINING_MARKS_RE, '') + .toLocaleLowerCase() + .replace(NON_ALNUM_RE, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeDurationSeconds(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.round(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.round(parsed); + } + } + return null; +} + +function scoreMatch(candidate: string | null, target: string): number { + if (!candidate) return 0; + const normalizedCandidate = normalizeMatchKey(candidate); + const normalizedTarget = normalizeMatchKey(target); + if (!normalizedCandidate || !normalizedTarget) return 0; + if (normalizedCandidate === normalizedTarget) return 100; + if (normalizedCandidate.startsWith(normalizedTarget) || normalizedTarget.startsWith(normalizedCandidate)) return 60; + if (normalizedCandidate.includes(normalizedTarget) || normalizedTarget.includes(normalizedCandidate)) return 30; + return 0; +} + +function isTransientHttpStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +function fetchResultToTransientCode(prefix: string, result: FetchJsonResult): string { + if (result.kind === 'timeout') return `${prefix}_timeout`; + if (result.kind === 'network_error') return `${prefix}_network_error`; + if (result.kind === 'invalid_payload') return `${prefix}_invalid_payload`; + if (result.kind === 'http_error') return `${prefix}_http_${result.status}`; + return `${prefix}_error`; +} + +async function fetchLrclibJson(url: string, config: LrclibClientConfig): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + try { + const response = await fetch(url, { + headers: createLrclibClientHeaders(config), + signal: controller.signal, + }); + + if (!response.ok) { + return { kind: 'http_error', status: response.status }; + } + + try { + const payload = await response.json(); + return { kind: 'ok', payload: payload as T }; + } catch { + return { kind: 'invalid_payload' }; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { kind: 'timeout' }; + } + return { kind: 'network_error' }; + } finally { + clearTimeout(timeout); + } +} + +function parseLrclibEntry(entry: Record): LyricsPayload | null { + const plainLyrics = normalizeLyricsText(entry.plainLyrics) ?? normalizeLyricsText(entry.plain_lyrics); + const syncedRaw = normalizeLyricsText(entry.syncedLyrics) ?? normalizeLyricsText(entry.synced_lyrics); + const syncedLines = syncedRaw ? parseLrcSyncedLines(syncedRaw) : []; + return createLyricsPayload('lrclib', 'lrclib', syncedLines.length > 0 ? 'lrc' : 'plain', plainLyrics, syncedRaw, syncedLines); +} + +function scoreSearchEntry(entry: Record, query: LyricsTrackQuery): number { + const titleValue = normalizeLrclibMetadataText(entry.trackName) ?? normalizeLrclibMetadataText(entry.track_name); + const artistValue = normalizeLrclibMetadataText(entry.artistName) ?? normalizeLrclibMetadataText(entry.artist_name); + const albumValue = normalizeLrclibMetadataText(entry.albumName) ?? normalizeLrclibMetadataText(entry.album_name); + const durationValue = normalizeDurationSeconds(entry.duration); + + const titleScore = scoreMatch(titleValue, query.title); + const artistScore = scoreMatch(artistValue, query.artist); + if (titleScore === 0 || artistScore === 0) return 0; + + let score = titleScore * 5 + artistScore * 4; + if (query.album) { + score += scoreMatch(albumValue, query.album) * 2; + } + + const queryDuration = normalizeDurationSeconds(query.durationSeconds); + if (queryDuration !== null && durationValue !== null) { + const delta = Math.abs(queryDuration - durationValue); + if (delta <= 2) { + score += 120; + } else if (delta <= 5) { + score += 80; + } else if (delta <= 10) { + score += 40; + } + } + + return score; +} + +async function lookupLrclibByMetadata(query: LyricsTrackQuery, config: LrclibClientConfig): Promise { + const params = new URLSearchParams({ + track_name: query.title, + artist_name: query.artist, + }); + const album = normalizeLrclibMetadataText(query.album); + if (album) { + params.set('album_name', album); + } + const duration = normalizeDurationSeconds(query.durationSeconds); + if (duration !== null) { + params.set('duration', String(duration)); + } + + const response = await fetchLrclibJson>(`${LRCLIB_GET_URL}?${params.toString()}`, config); + if (response.kind === 'http_error') { + if (response.status === 404) { + return { status: 'not_found' }; + } + if (isTransientHttpStatus(response.status)) { + return { + status: 'transient_error', + message: 'LRCLIB metadata lookup failed due to a transient HTTP error.', + code: fetchResultToTransientCode('lrclib_get', response), + }; + } + return { status: 'not_found' }; + } + + if (response.kind !== 'ok') { + return { + status: 'transient_error', + message: 'LRCLIB metadata lookup failed due to a transient network error.', + code: fetchResultToTransientCode('lrclib_get', response), + }; + } + + if (!response.payload || typeof response.payload !== 'object' || Array.isArray(response.payload)) { + return { + status: 'transient_error', + message: 'LRCLIB metadata lookup returned an invalid payload.', + code: 'lrclib_get_invalid_payload', + }; + } + + const parsed = parseLrclibEntry(response.payload); + if (!parsed) return { status: 'not_found' }; + return { status: 'hit', lyrics: parsed }; +} + +async function lookupLrclibBySearch(query: LyricsTrackQuery, config: LrclibClientConfig): Promise { + const searchTerm = [query.title, query.artist, query.album ?? ''] + .map((value) => normalizeLrclibMetadataText(value)) + .filter((value): value is string => Boolean(value)) + .join(' '); + if (!searchTerm) return { status: 'not_found' }; + + const params = new URLSearchParams({ q: searchTerm }); + const response = await fetchLrclibJson(`${LRCLIB_SEARCH_URL}?${params.toString()}`, config); + if (response.kind === 'http_error') { + if (response.status === 404) return { status: 'not_found' }; + if (isTransientHttpStatus(response.status)) { + return { + status: 'transient_error', + message: 'LRCLIB search lookup failed due to a transient HTTP error.', + code: fetchResultToTransientCode('lrclib_search', response), + }; + } + return { status: 'not_found' }; + } + + if (response.kind !== 'ok') { + return { + status: 'transient_error', + message: 'LRCLIB search lookup failed due to a transient network error.', + code: fetchResultToTransientCode('lrclib_search', response), + }; + } + + if (!Array.isArray(response.payload)) { + return { + status: 'transient_error', + message: 'LRCLIB search lookup returned an invalid payload.', + code: 'lrclib_search_invalid_payload', + }; + } + + const candidates: ScoredLrclibCandidate[] = []; + for (const item of response.payload) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const entry = item as Record; + const score = scoreSearchEntry(entry, query); + if (score <= 0) continue; + candidates.push({ entry, score }); + } + + candidates.sort((left, right) => right.score - left.score); + for (const candidate of candidates) { + const parsed = parseLrclibEntry(candidate.entry); + if (!parsed) continue; + return { status: 'hit', lyrics: parsed }; + } + + return { status: 'not_found' }; +} + +async function lookupLrclibRaw(query: LyricsTrackQuery, config: LrclibClientConfig): Promise { + const metadataLookup = await lookupLrclibByMetadata(query, config); + if (metadataLookup.status === 'hit' || metadataLookup.status === 'transient_error') { + return metadataLookup; + } + return lookupLrclibBySearch(query, config); +} + +export class LrclibLookupCoordinator { + private readonly config: LrclibClientConfig; + private cooldownUntil = 0; + private readonly inFlightLookups = new Map>(); + + constructor(config: LrclibClientConfig) { + this.config = config; + } + + isCoolingDown(): boolean { + return this.config.now() < this.cooldownUntil; + } + + async lookup( + query: LyricsTrackQuery, + lookupKey: string, + options: { forceRefresh?: boolean } = {} + ): Promise { + const forceRefresh = Boolean(options.forceRefresh); + if (!forceRefresh && this.isCoolingDown()) { + return { status: 'provider_unavailable' }; + } + + const result = await this.lookupDeduped(query, lookupKey); + if (result.status === 'transient_error') { + if (!forceRefresh) { + this.cooldownUntil = this.config.now() + LRCLIB_PROVIDER_COOLDOWN_MS; + return { status: 'provider_unavailable' }; + } + return result; + } + + this.cooldownUntil = 0; + return result; + } + + private lookupDeduped(query: LyricsTrackQuery, lookupKey: string): Promise { + const existing = this.inFlightLookups.get(lookupKey); + if (existing) return existing; + + const lookup = lookupLrclibRaw(query, this.config).finally(() => { + if (this.inFlightLookups.get(lookupKey) === lookup) { + this.inFlightLookups.delete(lookupKey); + } + }); + this.inFlightLookups.set(lookupKey, lookup); + return lookup; + } +} diff --git a/src/services/lyrics/unicode.ts b/src/services/lyrics/unicode.ts new file mode 100644 index 0000000..2944526 --- /dev/null +++ b/src/services/lyrics/unicode.ts @@ -0,0 +1,48 @@ +// Hermes-safe Unicode helpers for the lyrics providers. Desktop uses +// String.prototype.normalize and `\p{…}` regex property escapes freely; RN's +// Hermes engine may lack full ICU normalization or property-escape support on a +// given build, and an unsupported `\p{…}` regex *literal* is a parse error that +// would break the whole bundle. So we (a) wrap normalize in try/catch and +// (b) build any property-escape regex with `new RegExp` inside try/catch, +// falling back to an explicit-range regex (built from \u escapes) when the +// engine rejects it. + +export function safeNormalize(value: string, form: 'NFC' | 'NFD' | 'NFKC' | 'NFKD'): string { + try { + return value.normalize(form); + } catch { + return value; + } +} + +function makeUnicodeRegex(pattern: string, flags: string, fallbackPattern: string): RegExp { + try { + return new RegExp(pattern, flags); + } catch { + // The property-escape form was rejected; the fallback uses only \u ranges. + return new RegExp(fallbackPattern, flags.replace('u', '')); + } +} + +// Combining marks (\p{M}). Fallback covers the common combining-mark blocks. +export const COMBINING_MARKS_RE = makeUnicodeRegex( + '\\p{M}+', + 'gu', + '[\\u0300-\\u036f\\u1ab0-\\u1aff\\u1dc0-\\u1dff\\u20d0-\\u20ff\\ufe20-\\ufe2f]+' +); + +// Anything that is NOT a letter or number ([^\p{L}\p{N}]). Fallback keeps ASCII +// alphanumerics plus the common Latin/Greek/Cyrillic/CJK/Kana/Hangul ranges and +// collapses the rest (punctuation, symbols, whitespace) to a single space. +export const NON_ALNUM_RE = makeUnicodeRegex( + '[^\\p{L}\\p{N}]+', + 'gu', + '[^0-9A-Za-z\\u00c0-\\u024f\\u0370-\\u03ff\\u0400-\\u04ff\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af\\uff00-\\uffef]+' +); + +// Control characters (\p{Cc}). Fallback covers C0 + DEL + C1. +export const CONTROL_CHARS_RE = makeUnicodeRegex( + '\\p{Cc}+', + 'gu', + '[\\u0000-\\u001f\\u007f-\\u009f]+' +); diff --git a/src/services/lyrics/xlrcdb.ts b/src/services/lyrics/xlrcdb.ts new file mode 100644 index 0000000..aabe084 --- /dev/null +++ b/src/services/lyrics/xlrcdb.ts @@ -0,0 +1,179 @@ +// XLRCDB provider — ported from desktop (astra/src/main/services/lyricsXlrcdb.ts). +// Delegates artist/title/duration matching to the `@boof2015/xlrc` package's +// `lookup()` against the static GitHub-Pages dataset, wrapping RN's global fetch +// with an AbortController timeout. XLRCDB requires a duration to match. + +import { + lookup as lookupXlrcdb, + serializeXLRC, + type FetchLike, + type FetchResponseLike, + type XLRCFile, +} from '@boof2015/xlrc'; +import { createLyricsPayload } from '@/lyrics/parsing'; +import type { LyricsPayload, LyricsTrackQuery } from '@/lyrics/types'; + +export const XLRCDB_SOURCE_URL = 'https://boof2015.github.io/xlrcdb'; +export const XLRCDB_REQUEST_TIMEOUT_MS = 15_000; +export const XLRCDB_PROVIDER_COOLDOWN_MS = 60_000; + +export interface XlrcdbClientConfig { + sourceUrl: string; + requestTimeoutMs: number; + now: () => number; +} + +export type XlrcdbLookupResult = + | { status: 'hit'; lyrics: LyricsPayload } + | { status: 'not_found' } + | { status: 'skipped'; reason: 'duration_missing' } + | { status: 'provider_unavailable' } + | { status: 'transient_error'; message: string; code?: string }; + +export function createXlrcdbClientConfig( + options: { sourceUrl?: string; requestTimeoutMs?: number; now?: () => number } = {} +): XlrcdbClientConfig { + return { + sourceUrl: (options.sourceUrl ?? XLRCDB_SOURCE_URL).replace(/\/+$/u, ''), + requestTimeoutMs: options.requestTimeoutMs ?? XLRCDB_REQUEST_TIMEOUT_MS, + now: options.now ?? Date.now, + }; +} + +function normalizeDurationSeconds(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.round(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.round(parsed); + } + } + return null; +} + +function createXlrcdbPayload(file: XLRCFile): LyricsPayload | null { + return createLyricsPayload('xlrcdb', 'xlrcdb', 'xlrc', null, serializeXLRC(file), []); +} + +function createTimeoutFetch( + config: XlrcdbClientConfig, + setFailureKind: (kind: 'timeout' | 'network_error') => void +): FetchLike { + return async (input: string): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + try { + return await fetch(input, { signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + setFailureKind('timeout'); + } else { + setFailureKind('network_error'); + } + throw error; + } finally { + clearTimeout(timeout); + } + }; +} + +function toTransientCode( + reason: 'fetch_error' | 'parse_error', + failureKind: 'timeout' | 'network_error' | null +): string { + if (reason === 'parse_error') return 'xlrcdb_parse_error'; + if (failureKind === 'timeout') return 'xlrcdb_timeout'; + if (failureKind === 'network_error') return 'xlrcdb_network_error'; + return 'xlrcdb_fetch_error'; +} + +async function lookupXlrcdbRaw(query: LyricsTrackQuery, config: XlrcdbClientConfig): Promise { + const duration = normalizeDurationSeconds(query.durationSeconds); + if (duration === null) { + return { status: 'skipped', reason: 'duration_missing' }; + } + + let failureKind: 'timeout' | 'network_error' | null = null; + const result = await lookupXlrcdb({ + artist: query.artist, + title: query.title, + length: duration, + source: config.sourceUrl, + fetch: createTimeoutFetch(config, (kind) => { + failureKind = kind; + }), + }); + + if (result.found) { + const lyrics = createXlrcdbPayload(result.lyrics); + return lyrics ? { status: 'hit', lyrics } : { status: 'not_found' }; + } + + if (result.reason === 'artist_not_found' || result.reason === 'track_not_found') { + return { status: 'not_found' }; + } + + return { + status: 'transient_error', + message: + result.reason === 'parse_error' + ? 'XLRCDB lookup returned lyrics that could not be parsed.' + : 'XLRCDB lookup failed due to a transient network error.', + code: toTransientCode(result.reason, failureKind), + }; +} + +export class XlrcdbLookupCoordinator { + private readonly config: XlrcdbClientConfig; + private cooldownUntil = 0; + private readonly inFlightLookups = new Map>(); + + constructor(config: XlrcdbClientConfig) { + this.config = config; + } + + isCoolingDown(): boolean { + return this.config.now() < this.cooldownUntil; + } + + async lookup( + query: LyricsTrackQuery, + lookupKey: string, + options: { forceRefresh?: boolean } = {} + ): Promise { + const forceRefresh = Boolean(options.forceRefresh); + if (!forceRefresh && this.isCoolingDown()) { + return { status: 'provider_unavailable' }; + } + + const result = await this.lookupDeduped(query, lookupKey); + if (result.status === 'transient_error') { + if (!forceRefresh) { + this.cooldownUntil = this.config.now() + XLRCDB_PROVIDER_COOLDOWN_MS; + return { status: 'provider_unavailable' }; + } + return result; + } + + if (result.status !== 'skipped') { + this.cooldownUntil = 0; + } + return result; + } + + private lookupDeduped(query: LyricsTrackQuery, lookupKey: string): Promise { + const existing = this.inFlightLookups.get(lookupKey); + if (existing) return existing; + + const lookup = lookupXlrcdbRaw(query, this.config).finally(() => { + if (this.inFlightLookups.get(lookupKey) === lookup) { + this.inFlightLookups.delete(lookupKey); + } + }); + this.inFlightLookups.set(lookupKey, lookup); + return lookup; + } +} diff --git a/src/stores/lyricsStore.ts b/src/stores/lyricsStore.ts new file mode 100644 index 0000000..2a7f57f --- /dev/null +++ b/src/stores/lyricsStore.ts @@ -0,0 +1,94 @@ +// In-memory UI state for lyrics — one entry per track path, backed by the +// cache-first orchestrator (src/lyrics/lyrics.ts). The orchestrator already +// dedupes in-flight network work and persists to SQLite; this store adds the +// loading flag + last result the now-playing lyrics band renders, plus a small +// LRU cap so revisiting tracks stays instant without growing unbounded. + +import { create } from 'zustand'; +import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics'; +import type { LyricsLookupResult } from '@/lyrics/types'; +import type { Track } from '@/types/audio'; + +const MAX_ENTRIES = 64; + +export interface LyricsUiEntry { + loading: boolean; + result: LyricsLookupResult | null; +} + +interface LyricsStore { + onlineEnabled: boolean; + byPath: Record; + loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise; + setOnlineEnabled: (enabled: boolean) => void; +} + +// Latest request id per path — guards against a stale response overwriting a +// newer one (e.g. rapid track changes reusing the same store entry). +const requestIds = new Map(); +let requestSeq = 0; + +function pruneToLru(byPath: Record): Record { + const keys = Object.keys(byPath); + if (keys.length <= MAX_ENTRIES) return byPath; + const next = { ...byPath }; + for (const key of keys.slice(0, keys.length - MAX_ENTRIES)) { + delete next[key]; + requestIds.delete(key); + } + return next; +} + +export const useLyricsStore = create((set, get) => ({ + onlineEnabled: true, + byPath: {}, + + loadForTrack: async (track, options = {}) => { + if (!track?.path) return; + const path = track.path; + const force = Boolean(options.force); + + const existing = get().byPath[path]; + if (!force && existing && (existing.result || existing.loading)) return; + + const requestId = ++requestSeq; + requestIds.set(path, requestId); + + const query = buildLyricsQuery(track); + if (!query) { + set((state) => ({ + byPath: pruneToLru({ + ...state.byPath, + [path]: { loading: false, result: { status: 'not_found', reason: 'embedded-missing' } }, + }), + })); + return; + } + + set((state) => ({ + byPath: pruneToLru({ + ...state.byPath, + [path]: { loading: true, result: existing?.result ?? null }, + }), + })); + + let result: LyricsLookupResult; + try { + result = await getLyricsForTrack(query, { forceRefresh: force, onlineEnabled: get().onlineEnabled }); + } catch (error) { + result = { + status: 'transient_error', + message: error instanceof Error ? error.message : 'Lyrics lookup failed.', + }; + } + + // Drop the response if a newer request for this path superseded it. + if (requestIds.get(path) !== requestId) return; + + set((state) => ({ + byPath: pruneToLru({ ...state.byPath, [path]: { loading: false, result } }), + })); + }, + + setOnlineEnabled: (enabled) => set({ onlineEnabled: enabled }), +})); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 353fa82..1e5f013 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -12,6 +12,7 @@ const ARTIST_GROUPING_KEY = 'artist_grouping_mode'; const INCLUDE_SINGLES_KEY = 'album_include_singles'; const SCOPE_MODE_KEY = 'scope_mode'; const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible'; +const LYRICS_VISIBLE_KEY = 'lyrics_visible'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -34,12 +35,15 @@ interface SettingsStore { includeSingles: boolean; scopeMode: ScopeMode; scopeStageVisible: boolean; + /** Whether the now-playing top half shows lyrics instead of art/scope. */ + lyricsVisible: boolean; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; setIncludeSingles: (include: boolean) => Promise; setScopeMode: (mode: ScopeMode) => Promise; setScopeStageVisible: (visible: boolean) => Promise; + setLyricsVisible: (visible: boolean) => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -47,22 +51,25 @@ export const useSettingsStore = create((set, get) => ({ includeSingles: false, scopeMode: 'spectrum', scopeStageVisible: false, + lyricsVisible: false, loaded: false, load: async () => { if (get().loaded) return; const db = await openLibraryDb(); - const [grouping, includeSingles, scope, scopeStageVisible] = await Promise.all([ + const [grouping, includeSingles, scope, scopeStageVisible, lyricsVisible] = await Promise.all([ getSetting(db, ARTIST_GROUPING_KEY), getSetting(db, INCLUDE_SINGLES_KEY), getSetting(db, SCOPE_MODE_KEY), getSetting(db, SCOPE_STAGE_VISIBLE_KEY), + getSetting(db, LYRICS_VISIBLE_KEY), ]); set({ artistGroupingMode: parseGroupingMode(grouping), includeSingles: parseBoolean(includeSingles), scopeMode: parseScopeMode(scope), scopeStageVisible: parseBoolean(scopeStageVisible), + lyricsVisible: parseBoolean(lyricsVisible), loaded: true, }); }, @@ -94,4 +101,11 @@ export const useSettingsStore = create((set, get) => ({ const db = await openLibraryDb(); await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false'); }, + + setLyricsVisible: async (visible) => { + if (get().lyricsVisible === visible) return; + set({ lyricsVisible: visible }); + const db = await openLibraryDb(); + await setSetting(db, LYRICS_VISIBLE_KEY, visible ? 'true' : 'false'); + }, }));