From 14b3ac2cb9e86c7731e4055df5533bdb774bb458 Mon Sep 17 00:00:00 2001 From: Stephen Arg Date: Thu, 26 Feb 2026 09:20:06 -0500 Subject: [PATCH] Lyrics Screen Improvements (#1011) * Adjusted styling, scroll to behavior, and added miniplayer to lyrics page * Prettier fix * Fixed scroll to height by dynamically determining line height * Adjusted miniplayer a bit to go back to player and adjusted when no lyrics message shows between songs * Swapped the miniplayer for a scrubber and controls, added song name and artists to top, added features to jump to current lyric line after scrubbing, and fixed issue where no lyrics message popped in between songs * Prettier style changes * Fixed issue where you could swipe back from lyrics page and some styling * Color changes to previous lyric lines and now only lyric section is swipable * Color changes * Addressed comments --- src/components/Player/components/controls.tsx | 34 +- src/components/Player/components/lyrics.tsx | 435 +++++++++++++----- src/components/Player/components/scrubber.tsx | 15 +- src/components/Player/mini-player.tsx | 32 +- src/screens/Player/index.tsx | 1 + 5 files changed, 370 insertions(+), 147 deletions(-) diff --git a/src/components/Player/components/controls.tsx b/src/components/Player/components/controls.tsx index 97d07589..ad4bddc9 100644 --- a/src/components/Player/components/controls.tsx +++ b/src/components/Player/components/controls.tsx @@ -11,7 +11,11 @@ import { } from '../../../hooks/player/callbacks' import { useRepeatModeStoreValue, useShuffle } from '../../../stores/player/queue' -export default function Controls(): React.JSX.Element { +export default function Controls({ + onLyricsScreen, +}: { + onLyricsScreen?: boolean +}): React.JSX.Element { const previous = usePrevious() const skip = useSkip() const repeatMode = useRepeatModeStoreValue() @@ -24,12 +28,14 @@ export default function Controls(): React.JSX.Element { return ( - toggleShuffle(shuffled)} - /> + {!onLyricsScreen && ( + toggleShuffle(shuffled)} + /> + )} @@ -54,12 +60,14 @@ export default function Controls(): React.JSX.Element { - toggleRepeatMode()} - /> + {!onLyricsScreen && ( + toggleRepeatMode()} + /> + )} ) } diff --git a/src/components/Player/components/lyrics.tsx b/src/components/Player/components/lyrics.tsx index d16120ff..e4f16002 100644 --- a/src/components/Player/components/lyrics.tsx +++ b/src/components/Player/components/lyrics.tsx @@ -6,7 +6,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useProgress } from '../../../hooks/player/queries' import { useSeekTo } from '../../../hooks/player/callbacks' import { UPDATE_INTERVAL } from '../../../configs/player.config' -import React, { useEffect, useMemo, useRef, useCallback } from 'react' +import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react' import Animated, { useSharedValue, useAnimatedStyle, @@ -17,9 +17,13 @@ import Animated, { SharedValue, } from 'react-native-reanimated' import { FlatList, ListRenderItem } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { trigger } from 'react-native-haptic-feedback' import Icon from '../../Global/components/icon' import useRawLyrics from '../../../api/queries/lyrics' +import { useCurrentTrack } from '../../../stores/player/queue' +import Scrubber from './scrubber' +import Controls from './controls' interface LyricLine { Text: string @@ -33,7 +37,7 @@ interface ParsedLyricLine { } const AnimatedText = Animated.createAnimatedComponent(Text) -const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) +const AnimatedFlatList = Animated.FlatList // Memoized lyric line component for better performance const LyricLineItem = React.memo( @@ -42,17 +46,19 @@ const LyricLineItem = React.memo( index, currentLineIndex, onPress, + onLayout: onItemLayout, }: { item: ParsedLyricLine index: number currentLineIndex: SharedValue onPress: (startTime: number, index: number) => void + onLayout?: (index: number, height: number) => void }) => { const theme = useTheme() // Get theme-aware colors const primaryColor = theme.color.val // Primary text color (adapts to dark/light) - const neutralColor = theme.neutral.val // Secondary text color + const neutralColor = theme.color.val + '95' // Secondary text color const highlightColor = theme.primary.val // Highlight color (primaryDark/primaryLight) const translucentColor = theme.translucent?.val // Theme-aware translucent background const backgroundHighlight = translucentColor || theme.primary.val + '15' // Fallback with 15% opacity @@ -114,49 +120,70 @@ const LyricLineItem = React.memo( } }) - const handlePress = useCallback(() => { - onPress(item.startTime, index) - }, [item.startTime, index, onPress]) + const tapGesture = useMemo( + () => + Gesture.Tap() + .maxDistance(10) + .maxDuration(500) + .runOnJS(true) + .onEnd((_e, success) => { + if (success) { + onPress(item.startTime, index) + } + }), + [item.startTime, index, onPress], + ) + + const handleLayout = useCallback( + (e: { nativeEvent: { layout: { height: number } } }) => { + onItemLayout?.(index, e.nativeEvent.layout.height) + }, + [index, onItemLayout], + ) return ( - - + - {item.text} - - + + {item.text} + + + ) }, @@ -169,16 +196,30 @@ export default function Lyrics({ }: { navigation: NativeStackNavigationProp }): React.JSX.Element { + const theme = useTheme() const { data: lyrics } = useRawLyrics() + const nowPlaying = useCurrentTrack() const { height } = useWindowDimensions() const { position } = useProgress(UPDATE_INTERVAL) const seekTo = useSeekTo() const flatListRef = useRef>(null) + const viewportHeightRef = useRef(height) + const isInitialMountRef = useRef(true) + const itemHeightsRef = useRef>({}) const currentLineIndex = useSharedValue(-1) const scrollY = useSharedValue(0) const isUserScrolling = useSharedValue(false) + const color = theme.color.val + + const handleFlatListLayout = useCallback( + (e: { nativeEvent: { layout: { height: number } } }) => { + viewportHeightRef.current = e.nativeEvent.layout.height + }, + [], + ) + // Convert lyrics from ticks to seconds and parse const parsedLyrics = useMemo(() => { if (!lyrics) return [] @@ -204,6 +245,17 @@ export default function Lyrics({ [parsedLyrics], ) + // Delay showing "No lyrics available" to avoid flash during track transitions + const [showNoLyricsMessage, setShowNoLyricsMessage] = useState(false) + useEffect(() => { + if (parsedLyrics.length > 0) { + setShowNoLyricsMessage(false) + return + } + const timer = setTimeout(() => setShowNoLyricsMessage(true), 3000) + return () => clearTimeout(timer) + }, [parsedLyrics.length]) + // Track manually selected lyric for immediate feedback const manuallySelectedIndex = useSharedValue(-1) const manualSelectTimeout = useRef(null) @@ -230,48 +282,94 @@ export default function Lyrics({ return found }, [position, lyricStartTimes]) - // Simple auto-scroll that keeps highlighted lyric in center - const scrollToCurrentLyric = useCallback(() => { - if ( - currentLyricIndex >= 0 && - currentLyricIndex < parsedLyrics.length && - flatListRef.current && - !isUserScrolling.value - ) { - try { - // Use scrollToIndex with viewPosition 0.5 to center the lyric - flatListRef.current.scrollToIndex({ - index: currentLyricIndex, - animated: true, - viewPosition: 0.5, // 0.5 = center of visible area - }) - } catch (error) { - // Fallback to scrollToOffset if scrollToIndex fails - console.warn('scrollToIndex failed, using fallback') - const estimatedItemHeight = 80 - const targetOffset = Math.max( - 0, - currentLyricIndex * estimatedItemHeight - height * 0.4, - ) + const ESTIMATED_ITEM_HEIGHT = 70 + const CONTENT_PADDING_TOP = height * 0.1 - flatListRef.current.scrollToOffset({ - offset: targetOffset, - animated: true, - }) + const pendingScrollOffsetRef = useRef(null) + + const getItemHeight = useCallback((index: number) => { + return itemHeightsRef.current[index] ?? ESTIMATED_ITEM_HEIGHT + }, []) + + const getItemCenterY = useCallback( + (index: number) => { + let offset = CONTENT_PADDING_TOP + for (let i = 0; i < index; i++) { + offset += getItemHeight(i) } - } - }, [currentLyricIndex, parsedLyrics.length, height]) + return offset + getItemHeight(index) / 2 + }, + [CONTENT_PADDING_TOP, getItemHeight], + ) + const onItemLayout = useCallback((index: number, itemHeight: number) => { + itemHeightsRef.current[index] = itemHeight + }, []) + + // On mount: scroll to center current line. Otherwise: only scroll when current line is within center 75% useEffect(() => { // Only update if there's no manual selection active if (manuallySelectedIndex.value === -1) { currentLineIndex.value = withTiming(currentLyricIndex, { duration: 300 }) } - // Delay scroll to allow for smooth animation - const scrollTimeout = setTimeout(scrollToCurrentLyric, 100) + if ( + currentLyricIndex < 0 || + currentLyricIndex >= parsedLyrics.length || + !flatListRef.current || + isUserScrolling.value + ) { + return + } + + const forceScroll = isInitialMountRef.current + if (!forceScroll) { + // Center 75% check: only scroll when current line is within center 75% of viewport + const viewportHeight = viewportHeightRef.current + const currentScrollY = scrollY.value + const center75Top = currentScrollY + viewportHeight * 0.125 + const center75Bottom = currentScrollY + viewportHeight * 0.875 + const currentLineCenter = getItemCenterY(currentLyricIndex) + const isInCenter75 = + currentLineCenter >= center75Top && currentLineCenter <= center75Bottom + if (!isInCenter75) return + } + + const viewportHeight = viewportHeightRef.current + const itemCenterY = getItemCenterY(currentLyricIndex) + const targetOffset = Math.max(0, itemCenterY - viewportHeight / 2) + + const doScroll = () => { + if (!flatListRef.current) return + if (forceScroll) isInitialMountRef.current = false + pendingScrollOffsetRef.current = null + flatListRef.current.scrollToOffset({ + offset: targetOffset, + animated: true, + }) + } + + if (forceScroll) { + pendingScrollOffsetRef.current = targetOffset + } + + const scrollTimeout = setTimeout(doScroll, 300) return () => clearTimeout(scrollTimeout) - }, [currentLyricIndex, scrollToCurrentLyric]) + }, [currentLyricIndex, parsedLyrics.length, height, getItemCenterY]) + + // When track changes (next song), scroll to top + const prevTrackIdRef = useRef(undefined) + useEffect(() => { + const trackId = nowPlaying?.item?.Id + if (prevTrackIdRef.current !== undefined && prevTrackIdRef.current !== trackId) { + if (flatListRef.current && parsedLyrics.length) { + flatListRef.current.scrollToOffset({ offset: 0, animated: false }) + } + isInitialMountRef.current = true + } + prevTrackIdRef.current = trackId + itemHeightsRef.current = {} + }, [nowPlaying?.item?.Id, parsedLyrics.length]) // Reset manual selection when the actual position catches up useEffect(() => { @@ -319,6 +417,27 @@ export default function Lyrics({ } }, []) + // Find lyric index for a given playback position (same logic as currentLyricIndex) + const findLyricIndexForPosition = useCallback( + (pos: number) => { + if (lyricStartTimes.length === 0) return -1 + let low = 0 + let high = lyricStartTimes.length - 1 + let found = -1 + while (low <= high) { + const mid = Math.floor((low + high) / 2) + if (pos >= lyricStartTimes[mid]) { + found = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return found + }, + [lyricStartTimes], + ) + // Scroll to specific lyric keeping it centered const scrollToLyric = useCallback( (lyricIndex: number) => { @@ -333,11 +452,8 @@ export default function Lyrics({ } catch (error) { // Fallback to scrollToOffset if scrollToIndex fails console.warn('scrollToIndex failed, using fallback') - const estimatedItemHeight = 80 - const targetOffset = Math.max( - 0, - lyricIndex * estimatedItemHeight - height * 0.4, - ) + const itemCenterY = getItemCenterY(lyricIndex) + const targetOffset = Math.max(0, itemCenterY - viewportHeightRef.current / 2) flatListRef.current.scrollToOffset({ offset: targetOffset, @@ -346,7 +462,7 @@ export default function Lyrics({ } } }, - [parsedLyrics.length, height], + [parsedLyrics.length, height, getItemCenterY], ) // Handle seeking to specific lyric timestamp @@ -382,10 +498,13 @@ export default function Lyrics({ ) // Handle back navigation - const handleBackPress = useCallback(() => { - trigger('impactLight') // Haptic feedback for navigation - navigation.goBack() - }, [navigation]) + const handleBackPress = useCallback( + (triggerHaptic: boolean | undefined = true) => { + if (triggerHaptic) trigger('impactLight') // Haptic feedback for navigation + navigation.goBack() + }, + [navigation], + ) // Optimized render item for FlatList const renderLyricItem: ListRenderItem = useCallback( @@ -394,46 +513,65 @@ export default function Lyrics({ ) }, - [currentLineIndex, handleLyricPress], + [currentLineIndex, handleLyricPress, onItemLayout], ) - // Removed getItemLayout to prevent crashes with dynamic content heights + const contentPaddingTop = height * 0.1 + + const getItemOffset = useCallback( + (index: number) => { + let offset = contentPaddingTop + for (let i = 0; i < index; i++) { + offset += getItemHeight(i) + } + return offset + }, + [contentPaddingTop, getItemHeight], + ) + + const getItemLayout = useCallback( + (_: unknown, index: number) => ({ + length: getItemHeight(index), + offset: getItemOffset(index), + index, + }), + [getItemHeight, getItemOffset], + ) + + const handleContentSizeChange = useCallback((_w: number, contentHeight: number) => { + const pending = pendingScrollOffsetRef.current + const viewportHeight = viewportHeightRef.current + // Content must be tall enough to scroll to target (max offset = contentHeight - viewportHeight) + if (pending !== null && flatListRef.current && contentHeight >= pending + viewportHeight) { + pendingScrollOffsetRef.current = null + isInitialMountRef.current = false + flatListRef.current.scrollToOffset({ + offset: pending, + animated: true, + }) + } + }, []) const keyExtractor = useCallback( (item: ParsedLyricLine, index: number) => `lyric-${index}-${item.startTime}`, [], ) - if (!parsedLyrics.length) { - return ( - - - - - - - No lyrics available - - - - - - ) - } + const blockSwipeGesture = Gesture.Pan().minDistance(0) return ( - + - {/* Header with back button */} handleBackPress()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > + + + {nowPlaying?.item?.Name} + + + {nowPlaying?.item?.ArtistItems?.map( + (artist) => artist.Name, + ).join(', ')} + + {/* Balance the layout */} - { - console.warn('ScrollToIndex failed:', error) - // Fallback to scrollToOffset - if (flatListRef.current) { - const targetOffset = Math.max( - 0, - error.index * 80 - height * 0.4, - ) - flatListRef.current.scrollToOffset({ - offset: targetOffset, - animated: true, - }) - } - }} - /> + {parsedLyrics.length > 0 ? ( + { + console.warn('ScrollToIndex failed:', error) + // Fallback to scrollToOffset + if (flatListRef.current) { + const itemCenterY = getItemCenterY(error.index) + const targetOffset = Math.max( + 0, + itemCenterY - viewportHeightRef.current / 2, + ) + flatListRef.current.scrollToOffset({ + offset: targetOffset, + animated: true, + }) + } + }} + /> + ) : ( + + {showNoLyricsMessage && ( + + No lyrics available + + )} + + )} + + + { + const index = findLyricIndexForPosition(position) + if (index >= 0) { + currentLineIndex.value = withTiming(index, { + duration: 200, + }) + scrollToLyric(index) + } + }} + /> + + + diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx index b1e42f93..b177d25f 100644 --- a/src/components/Player/components/scrubber.tsx +++ b/src/components/Player/components/scrubber.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { getTokenValue, Spacer, Text, useTheme, XStack, YStack } from 'tamagui' import { useSeekTo } from '../../../hooks/player/callbacks' import { @@ -15,7 +15,11 @@ import { runOnJS } from 'react-native-worklets' import Slider from '@jellify-music/react-native-reanimated-slider' import { triggerHaptic } from '../../../hooks/use-haptic-feedback' -export default function Scrubber(): React.JSX.Element { +interface ScrubberProps { + onSeekComplete?: (position: number) => void +} + +export default function Scrubber({ onSeekComplete }: ScrubberProps = {}): React.JSX.Element { const seekTo = useSeekTo() const nowPlaying = useCurrentTrack() @@ -67,6 +71,11 @@ export default function Scrubber(): React.JSX.Element { }, ) + const handleValueChange = async (value: number) => { + await seekTo(value) + onSeekComplete?.(value) + } + return ( >() const translateX = useSharedValue(0) @@ -73,12 +72,28 @@ export default function Miniplayer(): React.JSX.Element { } }) - const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + const openPlayer = () => { + navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + } const pressStyle = { opacity: 0.6, } + // Guard: during track transitions nowPlaying can be briefly null + if (!nowPlaying?.item) { + return ( + + + + ) + } + return ( - {nowPlaying?.title ?? 'Nothing Playing'} + {nowPlaying.title ?? 'Nothing Playing'} - {nowPlaying?.artist ?? 'Unknown Artist'} + {nowPlaying.artist ?? 'Unknown Artist'} @@ -163,5 +178,6 @@ function MiniPlayerProgress(): React.JSX.Element { } function calculateProgressPercentage(progress: TrackPlayerProgress | undefined): number { - return Math.round((progress!.position / progress!.duration) * 100) + if (!progress || progress.duration <= 0) return 0 + return Math.round((progress.position / progress.duration) * 100) } diff --git a/src/screens/Player/index.tsx b/src/screens/Player/index.tsx index 5fdbfb3a..c1f7816c 100644 --- a/src/screens/Player/index.tsx +++ b/src/screens/Player/index.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react' +import { View } from 'react-native' import PlayerScreen from '../../components/Player' import Queue from '../../components/Queue' import { createNativeStackNavigator } from '@react-navigation/native-stack'