Merge branch 'main' of github.com:Jellify-Music/App into feature/nitro-player

This commit is contained in:
Violet Caulfield
2026-02-26 21:20:15 -06:00
5 changed files with 363 additions and 149 deletions

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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'