diff --git a/src/components/Player/components/controls.tsx b/src/components/Player/components/controls.tsx index a162bd83..f6b567d5 100644 --- a/src/components/Player/components/controls.tsx +++ b/src/components/Player/components/controls.tsx @@ -9,8 +9,13 @@ import { useToggleShuffle, } from '../../../hooks/player/callbacks' import { useRepeatMode, useShuffle } from '../../../stores/player/queue' +import { RepeatMode } from '@jellyfin/sdk/lib/generated-client/models/repeat-mode' -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 = useRepeatMode() @@ -23,12 +28,14 @@ export default function Controls(): React.JSX.Element { return ( - toggleShuffle(shuffled)} - /> + {!onLyricsScreen && ( + toggleShuffle(shuffled)} + /> + )} @@ -53,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 f0c2f452..ccd4edda 100644 --- a/src/components/Player/components/lyrics.tsx +++ b/src/components/Player/components/lyrics.tsx @@ -5,7 +5,7 @@ import BlurredBackground from './blurred-background' import { SafeAreaView } from 'react-native-safe-area-context' import { useProgress } from '../../../hooks/player' import { useSeekTo } from '../../../hooks/player/callbacks' -import React, { useEffect, useMemo, useRef, useCallback } from 'react' +import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react' import Animated, { useSharedValue, useAnimatedStyle, @@ -16,9 +16,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 @@ -32,7 +36,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( @@ -41,17 +45,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 @@ -113,49 +119,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} + + + ) }, @@ -168,16 +195,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() 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 [] @@ -203,6 +244,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) @@ -229,48 +281,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?.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?.id, parsedLyrics.length]) // Reset manual selection when the actual position catches up useEffect(() => { @@ -318,6 +416,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) => { @@ -332,11 +451,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, @@ -345,7 +461,7 @@ export default function Lyrics({ } } }, - [parsedLyrics.length, height], + [parsedLyrics.length, height, getItemCenterY], ) // Handle seeking to specific lyric timestamp @@ -381,10 +497,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( @@ -393,46 +512,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?.title} + + + {nowPlaying?.artist} + + {/* 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 0a523bd1..a014c6a0 100644 --- a/src/components/Player/components/scrubber.tsx +++ b/src/components/Player/components/scrubber.tsx @@ -9,19 +9,17 @@ import { useProgress } from '../../../hooks/player' import QualityBadge from './quality-badge' import { useDisplayAudioQualityBadge } from '../../../stores/settings/player' import { useCurrentTrack } from '../../../stores/player/queue' -import { - useSharedValue, - useAnimatedReaction, - withTiming, - Easing, - ReduceMotion, -} from 'react-native-reanimated' +import { useSharedValue, useAnimatedReaction, withTiming, Easing } from 'react-native-reanimated' import { runOnJS } from 'react-native-worklets' import Slider from '@jellify-music/react-native-reanimated-slider' import { triggerHaptic } from '../../../hooks/use-haptic-feedback' import getTrackDto, { getTrackMediaSourceInfo } from '../../../utils/mapping/track-extra-payload' -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() @@ -78,6 +76,10 @@ export default function Scrubber(): React.JSX.Element { } }, ) + const handleValueChange = async (value: number) => { + await seekTo(value) + onSeekComplete?.(value) + } return ( @@ -86,7 +88,7 @@ export default function Scrubber(): React.JSX.Element { maxValue={duration} backgroundColor={theme.neutral.val} color={theme.primary.val} - onValueChange={seekTo} + onValueChange={handleValueChange} thumbWidth={getTokenValue('$3')} trackHeight={getTokenValue('$2')} gestureActiveRef={isSeeking} diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 9aa6a3f4..7a10e069 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -35,7 +35,6 @@ export default function Miniplayer(): React.JSX.Element | null { const skip = useSkip() const previous = usePrevious() const theme = useTheme() - const navigation = useNavigation>() const translateX = useSharedValue(0) @@ -77,13 +76,29 @@ export default function Miniplayer(): React.JSX.Element | null { } }) - const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + const openPlayer = () => { + navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + } const pressStyle = { opacity: 0.6, } if (!nowPlaying) return null + // Guard: during track transitions nowPlaying can be briefly null + if (!item) { + return ( + + + + ) + } + return ( - {nowPlaying?.title ?? 'Nothing Playing'} + {nowPlaying.title ?? 'Nothing Playing'} - {nowPlaying?.artist ?? 'Unknown Artist'} + {nowPlaying.artist ?? 'Unknown Artist'} 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'