lyrics support + lookup

This commit is contained in:
Boof2015
2026-07-08 23:16:10 -04:00
parent acc6bdf3eb
commit 6ccc73401c
19 changed files with 2478 additions and 29 deletions
+253
View File
@@ -0,0 +1,253 @@
// The scrolling synced-lyrics list for lyrics mode. Fills its parent (flex:1),
// left-aligned, self-measuring. The active line advances off the same smooth
// playback clock the waveform uses (RNTP stays authoritative) and auto-scrolls to
// a comfortable reading anchor; manual scroll pauses the follow and surfaces a
// Recenter pill. Top/bottom gradient overlays soften the edges into the chrome
// (react-native-svg — no native rebuild). Plain (unsynced) hits render as a
// static scroll; loading/not-found/error states get a centered message.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native';
import { Text } from '@/components/Text';
import { useColors } from '@/theme/themed';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { useLyricsStore } from '@/stores/lyricsStore';
import {
getLyricsLineSeekTimeSeconds,
getSyncedLyricsDisplayLines,
getSyncedLyricsGapProgress,
hasRenderableSyncedLines,
resolveSyncedLyricsTiming,
} from '@/lyrics/presentation';
import { LyricsLine, type LyricsLineTier } from './LyricsLine';
import type { Track } from '@/types/audio';
const TRANSLATION_PRIORITY = ['en', 'ja-Latn'];
const ANCHOR_RATIO = 0.4;
const H_PADDING = 22;
// The displayed active line lags the audio by a fixed pipeline delay (RNTP
// position reporting + poll/smoothing) that the desktop doesn't have, so advance
// the lyrics clock by this much. Tune to taste — bigger = earlier highlight.
const LYRICS_LEAD_MS = 350;
interface LyricsBandProps {
track: Track;
currentTime: number;
duration: number;
isPlaying: boolean;
onSeek: (seconds: number) => void;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) {
const colors = useColors();
const entry = useLyricsStore((s) => s.byPath[track.path]);
const loadForTrack = useLyricsStore((s) => s.loadForTrack);
useEffect(() => {
void loadForTrack(track);
}, [track, loadForTrack]);
const [size, setSize] = useState({ w: 0, h: 0 });
const onContainerLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setSize((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
};
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
// Lead the audio to counter display-pipeline lag (see LYRICS_LEAD_MS).
const lyricsTime = smoothTime + LYRICS_LEAD_MS / 1000;
const result = entry?.result ?? null;
const isLoading = entry?.loading ?? !entry;
const syncedLines = useMemo(
() => (result?.status === 'hit' ? result.lyrics.syncedLines : []),
[result]
);
const displayLines = useMemo(
() => getSyncedLyricsDisplayLines(syncedLines, { durationSeconds: duration }),
[syncedLines, duration]
);
const hasSynced = hasRenderableSyncedLines(syncedLines);
const timing = resolveSyncedLyricsTiming(syncedLines, lyricsTime, { durationSeconds: duration });
const focusIndex = timing.focusLineIndex;
// Uniform size for every line (LyricsLine no longer scales font per tier), so pick
// a comfortable reading size rather than the old oversized active value.
const baseSize = size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, 24)) : 22;
// --- auto-scroll centering ---
const scrollRef = useRef<ScrollView>(null);
const offsets = useRef<number[]>([]);
const heights = useRef<number[]>([]);
const [followPaused, setFollowPaused] = useState(false);
const centerOn = useCallback(
(displayIndex: number, animated: boolean) => {
if (displayIndex < 0 || size.h <= 0) return;
const y = offsets.current[displayIndex];
const h = heights.current[displayIndex];
if (y == null || h == null) return;
const target = Math.max(0, y + h / 2 - size.h * ANCHOR_RATIO);
scrollRef.current?.scrollTo({ y: target, animated });
},
[size.h]
);
// Reset follow + jump to the focus line when the track (or its lyrics) changes.
useEffect(() => {
offsets.current = [];
heights.current = [];
const timer = setTimeout(() => {
setFollowPaused(false);
centerOn(timing.focusLineIndex, false);
}, 90);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track.path, displayLines.length]);
// Follow the active/focus line as playback advances.
useEffect(() => {
if (followPaused) return;
centerOn(focusIndex, true);
}, [focusIndex, followPaused, centerOn]);
const recenter = useCallback(() => {
setFollowPaused(false);
centerOn(focusIndex, true);
}, [centerOn, focusIndex]);
const message = !hasSynced
? result?.status === 'transient_error'
? 'Lyrics lookup ran into a problem. A retry may work.'
: result?.status === 'not_found'
? result.reason === 'online-disabled'
? 'Online lyrics lookup is off.'
: result.reason === 'provider-unavailable'
? "Lyrics providers didn't respond in time."
: 'No lyrics found for this track.'
: isLoading
? 'Finding lyrics…'
: 'Lyrics are ready when a track is playing.'
: null;
// --- plain (unsynced) hit ---
if (result?.status === 'hit' && !hasSynced) {
return (
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
<ScrollView
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: H_PADDING }}
showsVerticalScrollIndicator={false}
>
<Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}>
{result.lyrics.plainLyrics ?? ''}
</Text>
</ScrollView>
</View>
);
}
// --- loading / not-found / error ---
if (!hasSynced) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }}>
<Text variant="body" color={colors.textTertiary} style={{ textAlign: 'center' }}>
{message}
</Text>
</View>
);
}
// --- synced view ---
return (
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
<ScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScrollBeginDrag={() => setFollowPaused(true)}
contentContainerStyle={{
paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120,
paddingHorizontal: H_PADDING,
alignItems: 'stretch',
}}
>
{displayLines.map((displayLine) => {
const onLayout = (event: LayoutChangeEvent) => {
offsets.current[displayLine.displayIndex] = event.nativeEvent.layout.y;
heights.current[displayLine.displayIndex] = event.nativeEvent.layout.height;
};
if (displayLine.kind === 'gap') {
const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0;
const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral;
return (
<View
key={displayLine.key}
onLayout={onLayout}
style={{ paddingVertical: 16, opacity: isCurrentGap ? 0.9 : 0.25 }}
>
<View
style={{
width: clamp(size.w * 0.3, 110, 220),
height: 3,
borderRadius: 2,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
}}
>
<View style={{ width: `${Math.round(progress * 100)}%`, height: 3, backgroundColor: colors.textSecondary }} />
</View>
</View>
);
}
const distance = displayLine.displayIndex - focusIndex;
const isActive = !timing.isNeutral && displayLine.displayIndex === timing.activeLineIndex;
const absDistance = Math.abs(distance);
const tier: LyricsLineTier = isActive ? 'active' : absDistance <= 1 ? 'near' : absDistance === 2 ? 'far' : 'distant';
const seekSeconds = getLyricsLineSeekTimeSeconds(displayLine.timestampMs, duration, 0);
return (
<LyricsLine
key={displayLine.key}
line={displayLine.line}
tier={tier}
baseSize={baseSize}
translationPriority={TRANSLATION_PRIORITY}
onSeek={() => {
if (seekSeconds != null) onSeek(seekSeconds);
}}
onLayout={onLayout}
/>
);
})}
</ScrollView>
{followPaused ? (
<Pressable
onPress={recenter}
hitSlop={10}
style={{
position: 'absolute',
bottom: 8,
alignSelf: 'center',
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.accent,
backgroundColor: colors.glassBg,
}}
>
<Text variant="mono" color={colors.accentText} style={{ fontSize: 9, letterSpacing: 1, textTransform: 'uppercase' }}>
Recenter
</Text>
</Pressable>
) : null}
</View>
);
}
+169
View File
@@ -0,0 +1,169 @@
// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile /
// Apple-Music reading style), with furigana ruby columns when present and an
// optional translation line beneath. Tapping seeks to the line.
//
// Every line uses the SAME font size, weight, and metrics, so text wrapping and
// line heights are identical across tiers — the layout never reflows as the active
// line moves, which is what caused the surrounding lines to shift/flicker. The
// active line is emphasized only by opacity, a small scale, and an accent glow —
// all paint/transform, never layout — and both animate so tier changes ease
// instead of snapping.
//
// RN has no <ruby>, so ruby is a stacked column: a small reading Text over the
// base Text. Every segment is the identical box (a View reserving `readingHeight`
// of top space with the base below) so all bases land on one line; the reading is
// absolutely positioned in that top zone, base-width and shrunk to fit, so a wide
// reading never widens the column and spreads the sentence apart.
import { memo, useEffect, useMemo } from 'react';
import { Pressable, View, type LayoutChangeEvent } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { useColors } from '@/theme/themed';
import { getPreferredLyricsTranslation } from '@/lyrics/presentation';
import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types';
export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant';
interface LyricsLineProps {
line: LyricsLineData;
tier: LyricsLineTier;
baseSize: number;
translationPriority: string[];
onSeek: () => void;
onLayout?: (event: LayoutChangeEvent) => void;
}
// scale/opacity only — never anything that reflows layout. Active scale is kept
// small enough that the grow overflows into the horizontal padding, not off-screen.
const TIER: Record<LyricsLineTier, { scale: number; opacity: number }> = {
active: { scale: 1.06, opacity: 1 },
near: { scale: 1.0, opacity: 0.52 },
far: { scale: 0.965, opacity: 0.27 },
distant: { scale: 0.93, opacity: 0.13 },
};
const EASE = Easing.bezier(0.22, 1, 0.36, 1);
const DURATION = 220;
interface Segment {
text: string;
reading?: string;
}
function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Segment[] {
if (!furigana || furigana.length === 0) return [{ text }];
const sorted = [...furigana].sort((a, b) => a.start - b.start);
const segments: Segment[] = [];
let cursor = 0;
for (const entry of sorted) {
if (entry.start < cursor || entry.end > text.length) continue;
if (entry.start > cursor) segments.push({ text: text.slice(cursor, entry.start) });
segments.push({ text: text.slice(entry.start, entry.end), reading: entry.reading });
cursor = entry.end;
}
if (cursor < text.length) segments.push({ text: text.slice(cursor) });
return segments;
}
function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) {
const colors = useColors();
const target = TIER[tier];
// Uniform metrics for every line (the whole point — no wrap/reflow between tiers).
const size = baseSize;
const lineHeight = Math.round(size * 1.2);
const readingSize = Math.max(9, Math.round(size * 0.5));
const readingHeight = Math.round(readingSize * 1.25);
const translation = useMemo(
() => getPreferredLyricsTranslation(line, translationPriority),
[line, translationPriority]
);
const hasFurigana = Boolean(line.furigana && line.furigana.length > 0);
const segments = useMemo(() => buildSegments(line.text, line.furigana), [line.text, line.furigana]);
const opacity = useSharedValue(target.opacity);
const scale = useSharedValue(target.scale);
useEffect(() => {
opacity.value = withTiming(target.opacity, { duration: DURATION, easing: EASE });
scale.value = withTiming(target.scale, { duration: DURATION, easing: EASE });
}, [target.opacity, target.scale, opacity, scale]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
const textColor = colors.textPrimary;
const mainShadow =
tier === 'active'
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
: undefined;
return (
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
<Pressable onPress={onSeek} style={{ paddingVertical: 7 }}>
<View style={{ alignItems: 'flex-start' }}>
{hasFurigana ? (
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
>
{segments.map((segment, index) => (
<View key={index} style={{ paddingTop: readingHeight }}>
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, ...mainShadow }}>
{segment.text}
</Text>
{segment.reading ? (
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
adjustsFontSizeToFit
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: readingHeight,
fontSize: readingSize,
lineHeight: readingHeight,
textAlign: 'center',
}}
>
{segment.reading}
</Text>
) : null}
</View>
))}
</View>
) : (
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, textAlign: 'left', ...mainShadow }}>
{line.text}
</Text>
)}
{translation ? (
<Text
variant="body"
color={colors.textSecondary}
style={{
fontSize: Math.max(11, Math.round(size * 0.52)),
lineHeight: Math.round(size * 0.66),
textAlign: 'left',
marginTop: 3,
opacity: 0.85,
}}
>
{translation.text}
</Text>
) : null}
</View>
</Pressable>
</Animated.View>
);
}
export const LyricsLine = memo(LyricsLineComponent);
+193
View File
@@ -0,0 +1,193 @@
// Lyrics mode for now-playing — a lyrics-first takeover that replaces the whole
// art/controls body (and the standard header). Lyrics fill the screen over the
// blurred-art wash; a slim strip on top (dismiss · track · favorite · exit) and a
// minimal control bar below (progress + prev/play/next) stay permanently visible
// so you never lose control to go fully immersive. The ♫ toggle exits back to the
// art view; swipe-down still closes the player.
import { Image } from 'expo-image';
import { Pressable, View } from 'react-native';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { MarqueeText } from '@/components/MarqueeText';
import { AstraLogo } from '@/components/AstraLogo';
import { SeekBar } from '@/components/SeekBar';
import { LyricsBand } from './LyricsBand';
import { spacing, radius } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { Track } from '@/types/audio';
interface LyricsViewProps {
track: Track;
currentTime: number;
duration: number;
isPlaying: boolean;
isLoading: boolean;
isFavorite: boolean;
onSeek: (seconds: number) => void;
onPlayPause: () => void;
onNext: () => void;
onPrev: () => void;
onToggleFavorite: () => void;
onExitLyrics: () => void;
onDismiss: () => void;
}
export function LyricsView({
track,
currentTime,
duration,
isPlaying,
isLoading,
isFavorite,
onSeek,
onPlayPause,
onNext,
onPrev,
onToggleFavorite,
onExitLyrics,
onDismiss,
}: LyricsViewProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.root}>
<View style={styles.strip}>
<Pressable onPress={onDismiss} hitSlop={12} style={styles.stripBtn} accessibilityLabel="Close player">
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
</Pressable>
<View style={styles.thumb}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
) : (
<AstraLogo size={18} />
)}
</View>
<View style={styles.stripText}>
<MarqueeText variant="label" style={styles.stripTitle}>
{track.title}
</MarqueeText>
<Text variant="caption" numberOfLines={1} color={colors.textTertiary}>
{track.artist}
</Text>
</View>
<Pressable
onPress={onToggleFavorite}
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={20}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
onPress={onExitLyrics}
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel="Hide lyrics"
accessibilityState={{ selected: true }}
>
<MaterialCommunityIcons name="script-text-outline" size={20} color={colors.accent} />
</Pressable>
</View>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={onSeek}
/>
<View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
<View style={styles.transport}>
<Pressable onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
</Pressable>
<Pressable onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={28}
color={colors.bgPrimary}
/>
</Pressable>
<Pressable onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
</Pressable>
</View>
</View>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
},
strip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingBottom: spacing.sm,
},
stripBtn: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
},
thumb: {
width: 40,
height: 40,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
thumbImage: {
width: 40,
height: 40,
},
stripText: {
flex: 1,
minWidth: 0,
},
stripTitle: {
color: colors.textPrimary,
},
controls: {
paddingTop: spacing.sm,
},
transport: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xl,
marginTop: spacing.xs,
},
transportBtn: {
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
playButton: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
}));