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'