mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-17 18:51:24 -05:00
Merge branch 'main' of github.com:Jellify-Music/App into feature/nitro-player
This commit is contained in:
@@ -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 (
|
||||
<XStack alignItems='center' justifyContent='space-between'>
|
||||
<Icon
|
||||
small
|
||||
color={shuffled ? '$primary' : '$color'}
|
||||
name='shuffle'
|
||||
onPress={() => toggleShuffle(shuffled)}
|
||||
/>
|
||||
{!onLyricsScreen && (
|
||||
<Icon
|
||||
small
|
||||
color={shuffled ? '$primary' : '$color'}
|
||||
name='shuffle'
|
||||
onPress={() => toggleShuffle(shuffled)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Spacer />
|
||||
|
||||
@@ -53,12 +60,14 @@ export default function Controls(): React.JSX.Element {
|
||||
|
||||
<Spacer />
|
||||
|
||||
<Icon
|
||||
small
|
||||
color={repeatMode === 'off' ? '$color' : '$primary'}
|
||||
name={repeatMode === 'track' ? 'repeat-once' : 'repeat'}
|
||||
onPress={async () => toggleRepeatMode()}
|
||||
/>
|
||||
{!onLyricsScreen && (
|
||||
<Icon
|
||||
small
|
||||
color={repeatMode === 'off' ? '$color' : '$primary'}
|
||||
name={repeatMode === 'track' ? 'repeat-once' : 'repeat'}
|
||||
onPress={async () => toggleRepeatMode()}
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ParsedLyricLine>)
|
||||
const AnimatedFlatList = Animated.FlatList<ParsedLyricLine>
|
||||
|
||||
// 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<number>
|
||||
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 (
|
||||
<Animated.View
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 12,
|
||||
paddingVertical: 3,
|
||||
paddingHorizontal: 20,
|
||||
minHeight: 60,
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 4,
|
||||
marginVertical: 0,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
backgroundStyle,
|
||||
]}
|
||||
onTouchEnd={handlePress}
|
||||
>
|
||||
<AnimatedText
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
fontSize: 18,
|
||||
lineHeight: 28,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
alignSelf: 'stretch',
|
||||
minWidth: 0,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
textColorStyle,
|
||||
backgroundStyle,
|
||||
]}
|
||||
>
|
||||
{item.text}
|
||||
</AnimatedText>
|
||||
</Animated.View>
|
||||
<AnimatedText
|
||||
style={[
|
||||
{
|
||||
fontSize: 18,
|
||||
lineHeight: 28,
|
||||
textAlign: 'left',
|
||||
fontWeight: '500',
|
||||
},
|
||||
textColorStyle,
|
||||
]}
|
||||
>
|
||||
{item.text}
|
||||
</AnimatedText>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
)
|
||||
},
|
||||
@@ -168,16 +195,30 @@ export default function Lyrics({
|
||||
}: {
|
||||
navigation: NativeStackNavigationProp<PlayerParamList>
|
||||
}): 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<FlatList<ParsedLyricLine>>(null)
|
||||
const viewportHeightRef = useRef(height)
|
||||
const isInitialMountRef = useRef(true)
|
||||
const itemHeightsRef = useRef<Record<number, number>>({})
|
||||
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<ParsedLyricLine[]>(() => {
|
||||
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<NodeJS.Timeout | null>(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<number | null>(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<string | undefined>(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<ParsedLyricLine> = useCallback(
|
||||
@@ -393,46 +512,65 @@ export default function Lyrics({
|
||||
<LyricLineItem
|
||||
item={item}
|
||||
index={index}
|
||||
onLayout={onItemLayout}
|
||||
currentLineIndex={currentLineIndex}
|
||||
onPress={handleLyricPress}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[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 (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<View flex={1}>
|
||||
<ZStack fullscreen>
|
||||
<BlurredBackground />
|
||||
<YStack fullscreen justifyContent='center' alignItems='center'>
|
||||
<Text fontSize={18} color='$neutral' textAlign='center'>
|
||||
No lyrics available
|
||||
</Text>
|
||||
</YStack>
|
||||
</ZStack>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
const blockSwipeGesture = Gesture.Pan().minDistance(0)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
|
||||
<View flex={1}>
|
||||
<ZStack fullscreen>
|
||||
<BlurredBackground />
|
||||
|
||||
<YStack fullscreen>
|
||||
{/* Header with back button */}
|
||||
<XStack
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
@@ -442,46 +580,95 @@ export default function Lyrics({
|
||||
>
|
||||
<XStack
|
||||
alignItems='center'
|
||||
onPress={handleBackPress}
|
||||
onPress={() => handleBackPress()}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Icon small name='chevron-left' />
|
||||
</XStack>
|
||||
<YStack>
|
||||
<Text
|
||||
fontSize={16}
|
||||
fontWeight='bold'
|
||||
color={color}
|
||||
textAlign='center'
|
||||
>
|
||||
{nowPlaying?.title}
|
||||
</Text>
|
||||
<Text fontSize={14} color={color} textAlign='center'>
|
||||
{nowPlaying?.artist}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Spacer width={28} /> {/* Balance the layout */}
|
||||
</XStack>
|
||||
|
||||
<AnimatedFlatList
|
||||
ref={flatListRef}
|
||||
data={parsedLyrics}
|
||||
renderItem={renderLyricItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: height * 0.1,
|
||||
paddingBottom: height * 0.5,
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
removeClippedSubviews={false}
|
||||
maxToRenderPerBatch={15}
|
||||
windowSize={15}
|
||||
initialNumToRender={15}
|
||||
onScrollToIndexFailed={(error) => {
|
||||
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 ? (
|
||||
<AnimatedFlatList
|
||||
ref={flatListRef}
|
||||
data={parsedLyrics}
|
||||
renderItem={renderLyricItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
onLayout={handleFlatListLayout}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: height * 0.1,
|
||||
paddingBottom: height * 0.5 + 100, // Extra for miniplayer overlay
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
removeClippedSubviews={false}
|
||||
maxToRenderPerBatch={15}
|
||||
windowSize={15}
|
||||
initialNumToRender={15}
|
||||
onScrollToIndexFailed={(error) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<YStack justifyContent='center' alignItems='center' flex={1}>
|
||||
{showNoLyricsMessage && (
|
||||
<Text fontSize={18} color='$color' textAlign='center'>
|
||||
No lyrics available
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
<GestureDetector gesture={blockSwipeGesture}>
|
||||
<YStack
|
||||
justifyContent='flex-start'
|
||||
gap={'$3'}
|
||||
flexShrink={1}
|
||||
padding='$5'
|
||||
paddingBottom='$7'
|
||||
>
|
||||
<Scrubber
|
||||
onSeekComplete={(position) => {
|
||||
const index = findLyricIndexForPosition(position)
|
||||
if (index >= 0) {
|
||||
currentLineIndex.value = withTiming(index, {
|
||||
duration: 200,
|
||||
})
|
||||
scrollToLyric(index)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Controls onLyricsScreen />
|
||||
</YStack>
|
||||
</GestureDetector>
|
||||
</YStack>
|
||||
</ZStack>
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<YStack alignItems='stretch' gap={'$3'}>
|
||||
@@ -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}
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function Miniplayer(): React.JSX.Element | null {
|
||||
const skip = useSkip()
|
||||
const previous = usePrevious()
|
||||
const theme = useTheme()
|
||||
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
|
||||
|
||||
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 (
|
||||
<YStack
|
||||
backgroundColor={theme.background.val}
|
||||
padding={'$2'}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
<Text> </Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View
|
||||
@@ -126,12 +141,12 @@ export default function Miniplayer(): React.JSX.Element | null {
|
||||
key={`${nowPlaying!.id}-mini-player-song-info`}
|
||||
>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold>{nowPlaying?.title ?? 'Nothing Playing'}</Text>
|
||||
<Text bold>{nowPlaying.title ?? 'Nothing Playing'}</Text>
|
||||
</TextTicker>
|
||||
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text height={'$0.5'}>
|
||||
{nowPlaying?.artist ?? 'Unknown Artist'}
|
||||
{nowPlaying.artist ?? 'Unknown Artist'}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
</Animated.View>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user