Inspired by a dashboard design the user shared. - App: new palette and fonts, borderless rounded panels with soft shadows, a pink-to-blue gradient card for your own song (text and controls switch to white on it), flat cards for friends, a 'Hi Chris, welcome back' header, an outline bell, and a pink pill on the active tab. - Website: the same look, plus a wide-screen layout with a sidebar, the page in a rounded panel, and a right column with your profile, recent activity and recent plays. Phones keep the top bar and bottom tabs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
100 lines
4.4 KiB
TypeScript
100 lines
4.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
import Animated, { LinearTransition } from 'react-native-reanimated';
|
|
import useSWR from 'swr';
|
|
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
|
import { colors, fonts, space, useInk } from '@/lib/theme';
|
|
|
|
/** Lines shown around the current one while following along. */
|
|
const BEFORE = 1;
|
|
const AFTER = 3;
|
|
/** Lines shown before "All lyrics" when there's nothing to follow. */
|
|
const PREVIEW = 8;
|
|
|
|
/**
|
|
* A song's lyrics. With timed lyrics and a live position (`np`), shows a few
|
|
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
|
*/
|
|
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
|
const ink = useInk();
|
|
const note = [styles.note, { color: ink.muted }];
|
|
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
|
|
const [full, setFull] = useState(false);
|
|
|
|
if (isLoading) return <Text style={note}>Finding lyrics…</Text>;
|
|
if (error) return <Text style={note}>Couldn't load lyrics right now.</Text>;
|
|
if (!data) return <Text style={note}>No lyrics found for this song.</Text>;
|
|
if (data.instrumental && !data.synced && !data.plain) return <Text style={note}>♪ Instrumental</Text>;
|
|
|
|
const synced = data.synced;
|
|
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
|
const following = Boolean(synced && live && !full);
|
|
const text = synced ? synced.map((l) => l.text).join('\n') : (data.plain ?? '');
|
|
const all = text.split('\n');
|
|
const clipped = !following && !full && all.length > PREVIEW;
|
|
const canToggle = (synced && live) || clipped || full;
|
|
|
|
return (
|
|
<View style={styles.wrap}>
|
|
<Pressable onPress={() => setFull((v) => !v)} disabled={!canToggle} accessibilityRole="button" accessibilityLabel={full ? 'Show fewer lyrics' : 'Show all lyrics'}>
|
|
{following && synced && np ? (
|
|
// Each fresh position from the server restarts the clock.
|
|
<FollowAlong key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} lines={synced} np={np} />
|
|
) : (
|
|
(clipped ? all.slice(0, PREVIEW) : all).map((t, i) => (
|
|
<Text key={i} style={[styles.line, styles.plain]}>
|
|
{t}
|
|
</Text>
|
|
))
|
|
)}
|
|
</Pressable>
|
|
<View style={styles.footer}>
|
|
{canToggle && (
|
|
<Text style={[styles.toggle, ink.hero && { color: '#ffffff' }]} onPress={() => setFull((v) => !v)}>
|
|
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
|
</Text>
|
|
)}
|
|
<Text style={[styles.source, { color: ink.muted }]} onPress={() => Linking.openURL(data.source.url)}>
|
|
Lyrics from {data.source.name}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** The line being sung, one before it and a few after, moving with the song. */
|
|
function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) {
|
|
const [receivedAt] = useState(() => Date.now());
|
|
const [now, setNow] = useState(receivedAt);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 500);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0;
|
|
const current = currentLyricIndex(lines, position);
|
|
const from = Math.max(0, current - BEFORE);
|
|
return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => {
|
|
const index = from + i;
|
|
const state = index < current ? styles.past : index === current ? styles.now : styles.next;
|
|
return (
|
|
<Animated.Text key={`${l.timeMs}-${index}`} layout={LinearTransition.duration(300)} style={[styles.line, state]}>
|
|
{l.text || '♪'}
|
|
</Animated.Text>
|
|
);
|
|
});
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
wrap: { gap: space.sm },
|
|
note: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
|
line: { color: colors.text, fontSize: 14.5, lineHeight: 22, fontFamily: fonts.body },
|
|
plain: { opacity: 0.9 },
|
|
past: { opacity: 0.4 },
|
|
now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 },
|
|
next: { opacity: 0.65 },
|
|
footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
|
toggle: { color: colors.accent, fontSize: 13, fontFamily: fonts.bodyMedium },
|
|
source: { color: colors.muted, fontSize: 11, fontFamily: fonts.body, marginLeft: 'auto' },
|
|
});
|