mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
lyrics support + lookup
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+41
-2
@@ -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,6 +519,7 @@ export default function NowPlayingScreen() {
|
||||
}}
|
||||
/>
|
||||
<View style={[styles.shell, { width: layout.contentWidth }]}>
|
||||
{!lyricsMode && (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerSide}>
|
||||
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
||||
@@ -528,6 +535,21 @@ export default function NowPlayingScreen() {
|
||||
</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}
|
||||
@@ -538,8 +560,25 @@ export default function NowPlayingScreen() {
|
||||
</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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
@@ -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
@@ -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.
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]+'
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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');
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user