lyrics support + lookup

This commit is contained in:
Boof2015
2026-07-08 23:16:10 -04:00
parent acc6bdf3eb
commit 6ccc73401c
19 changed files with 2478 additions and 29 deletions
+7
View File
@@ -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",
+2
View File
@@ -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"
},
+65 -26
View File
@@ -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() {
}}
/>
<View style={[styles.shell, { width: layout.contentWidth }]}>
<View style={styles.header}>
<View style={styles.headerSide}>
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable>
{!lyricsMode && (
<View style={styles.header}>
<View style={styles.headerSide}>
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.headerMid}>
<Text variant="caption" style={styles.eyebrow}>
PLAYING FROM
</Text>
<Text variant="label" numberOfLines={1} style={styles.source}>
{source}
</Text>
</View>
<View style={[styles.headerSide, styles.headerActions]}>
{!isDesktopTarget && track ? (
<Pressable
style={styles.headerBtn}
onPress={() => void setLyricsVisible(!lyricsVisible)}
hitSlop={12}
accessibilityLabel={lyricsVisible ? 'Hide lyrics' : 'Show lyrics'}
accessibilityState={{ selected: lyricsVisible }}
>
<MaterialCommunityIcons
name="script-text-outline"
size={20}
color={lyricsVisible ? colors.accent : colors.textSecondary}
/>
</Pressable>
) : null}
<Pressable
style={styles.headerBtn}
onPress={openMenu}
hitSlop={12}
accessibilityLabel="More options"
>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
</View>
</View>
<View style={styles.headerMid}>
<Text variant="caption" style={styles.eyebrow}>
PLAYING FROM
</Text>
<Text variant="label" numberOfLines={1} style={styles.source}>
{source}
</Text>
</View>
<View style={[styles.headerSide, styles.headerActions]}>
<Pressable
style={styles.headerBtn}
onPress={openMenu}
hitSlop={12}
accessibilityLabel="More options"
>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
</View>
</View>
)}
{isDesktopTarget ? (
{lyricsMode && track ? (
<LyricsView
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
isLoading={isLoading}
isFavorite={isFavorite}
onSeek={(seconds) => void seekTo(seconds)}
onPlayPause={togglePlay}
onNext={skipToNext}
onPrev={skipToPrevious}
onToggleFavorite={() => void toggleFavorite(track)}
onExitLyrics={() => void setLyricsVisible(false)}
onDismiss={() => dismissSheet()}
/>
) : isDesktopTarget ? (
activeTrack ? (
<View style={[styles.player, layout.isWide && styles.playerWide]}>
<View
@@ -1108,7 +1147,7 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'center',
},
headerSide: {
width: 48,
width: 68,
flexDirection: 'row',
alignItems: 'center',
},
+253
View File
@@ -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<ScrollView>(null);
const offsets = useRef<number[]>([]);
const heights = useRef<number[]>([]);
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 (
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
<ScrollView
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: H_PADDING }}
showsVerticalScrollIndicator={false}
>
<Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}>
{result.lyrics.plainLyrics ?? ''}
</Text>
</ScrollView>
</View>
);
}
// --- loading / not-found / error ---
if (!hasSynced) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }}>
<Text variant="body" color={colors.textTertiary} style={{ textAlign: 'center' }}>
{message}
</Text>
</View>
);
}
// --- synced view ---
return (
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
<ScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScrollBeginDrag={() => 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 (
<View
key={displayLine.key}
onLayout={onLayout}
style={{ paddingVertical: 16, opacity: isCurrentGap ? 0.9 : 0.25 }}
>
<View
style={{
width: clamp(size.w * 0.3, 110, 220),
height: 3,
borderRadius: 2,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
}}
>
<View style={{ width: `${Math.round(progress * 100)}%`, height: 3, backgroundColor: colors.textSecondary }} />
</View>
</View>
);
}
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 (
<LyricsLine
key={displayLine.key}
line={displayLine.line}
tier={tier}
baseSize={baseSize}
translationPriority={TRANSLATION_PRIORITY}
onSeek={() => {
if (seekSeconds != null) onSeek(seekSeconds);
}}
onLayout={onLayout}
/>
);
})}
</ScrollView>
{followPaused ? (
<Pressable
onPress={recenter}
hitSlop={10}
style={{
position: 'absolute',
bottom: 8,
alignSelf: 'center',
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.accent,
backgroundColor: colors.glassBg,
}}
>
<Text variant="mono" color={colors.accentText} style={{ fontSize: 9, letterSpacing: 1, textTransform: 'uppercase' }}>
Recenter
</Text>
</Pressable>
) : null}
</View>
);
}
+169
View File
@@ -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 <ruby>, 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<LyricsLineTier, { scale: number; opacity: number }> = {
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 (
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
<Pressable onPress={onSeek} style={{ paddingVertical: 7 }}>
<View style={{ alignItems: 'flex-start' }}>
{hasFurigana ? (
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
>
{segments.map((segment, index) => (
<View key={index} style={{ paddingTop: readingHeight }}>
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, ...mainShadow }}>
{segment.text}
</Text>
{segment.reading ? (
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
adjustsFontSizeToFit
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: readingHeight,
fontSize: readingSize,
lineHeight: readingHeight,
textAlign: 'center',
}}
>
{segment.reading}
</Text>
) : null}
</View>
))}
</View>
) : (
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, textAlign: 'left', ...mainShadow }}>
{line.text}
</Text>
)}
{translation ? (
<Text
variant="body"
color={colors.textSecondary}
style={{
fontSize: Math.max(11, Math.round(size * 0.52)),
lineHeight: Math.round(size * 0.66),
textAlign: 'left',
marginTop: 3,
opacity: 0.85,
}}
>
{translation.text}
</Text>
) : null}
</View>
</Pressable>
</Animated.View>
);
}
export const LyricsLine = memo(LyricsLineComponent);
+193
View File
@@ -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 (
<View style={styles.root}>
<View style={styles.strip}>
<Pressable onPress={onDismiss} hitSlop={12} style={styles.stripBtn} accessibilityLabel="Close player">
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
</Pressable>
<View style={styles.thumb}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
) : (
<AstraLogo size={18} />
)}
</View>
<View style={styles.stripText}>
<MarqueeText variant="label" style={styles.stripTitle}>
{track.title}
</MarqueeText>
<Text variant="caption" numberOfLines={1} color={colors.textTertiary}>
{track.artist}
</Text>
</View>
<Pressable
onPress={onToggleFavorite}
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={20}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
onPress={onExitLyrics}
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel="Hide lyrics"
accessibilityState={{ selected: true }}
>
<MaterialCommunityIcons name="script-text-outline" size={20} color={colors.accent} />
</Pressable>
</View>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={onSeek}
/>
<View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
<View style={styles.transport}>
<Pressable onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
</Pressable>
<Pressable onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={28}
color={colors.bgPrimary}
/>
</Pressable>
<Pressable onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
</Pressable>
</View>
</View>
</View>
);
}
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',
},
}));
+107
View File
@@ -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<LyricsCacheEntry | null> {
const row = await db.get<LyricsCacheRow>(
`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<void> {
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(),
]
);
}
+27 -2
View File
@@ -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<void> {
Binary file not shown.
+76
View File
@@ -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);
});
+288
View File
@@ -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);
}
+108
View File
@@ -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'
);
});
+437
View File
@@ -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';
}
+62
View File
@@ -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 };
+348
View File
@@ -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<T> =
| { kind: 'ok'; payload: T }
| { kind: 'http_error'; status: number }
| { kind: 'timeout' }
| { kind: 'network_error' }
| { kind: 'invalid_payload' };
interface ScoredLrclibCandidate {
entry: Record<string, unknown>;
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<LrclibClientConfig, 'appVersion'>): Record<string, string> {
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<unknown>): 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<T>(url: string, config: LrclibClientConfig): Promise<FetchJsonResult<T>> {
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<string, unknown>): 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<string, unknown>, 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<LrclibLookupResult> {
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<Record<string, unknown>>(`${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<LrclibLookupResult> {
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<unknown[]>(`${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<string, unknown>;
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<LrclibLookupResult> {
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<string, Promise<LrclibLookupResult>>();
constructor(config: LrclibClientConfig) {
this.config = config;
}
isCoolingDown(): boolean {
return this.config.now() < this.cooldownUntil;
}
async lookup(
query: LyricsTrackQuery,
lookupKey: string,
options: { forceRefresh?: boolean } = {}
): Promise<LrclibLookupResult> {
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<LrclibLookupResult> {
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;
}
}
+48
View File
@@ -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]+'
);
+179
View File
@@ -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<FetchResponseLike> => {
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<XlrcdbLookupResult> {
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<string, Promise<XlrcdbLookupResult>>();
constructor(config: XlrcdbClientConfig) {
this.config = config;
}
isCoolingDown(): boolean {
return this.config.now() < this.cooldownUntil;
}
async lookup(
query: LyricsTrackQuery,
lookupKey: string,
options: { forceRefresh?: boolean } = {}
): Promise<XlrcdbLookupResult> {
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<XlrcdbLookupResult> {
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;
}
}
+94
View File
@@ -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<string, LyricsUiEntry>;
loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise<void>;
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<string, number>();
let requestSeq = 0;
function pruneToLru(byPath: Record<string, LyricsUiEntry>): Record<string, LyricsUiEntry> {
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<LyricsStore>((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 }),
}));
+15 -1
View File
@@ -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<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
setIncludeSingles: (include: boolean) => Promise<void>;
setScopeMode: (mode: ScopeMode) => Promise<void>;
setScopeStageVisible: (visible: boolean) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -47,22 +51,25 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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');
},
}));