Bugfix/player controls performance (#539)

* make player buttons and scrubber bar snappier by removing unnecessary state subscriptions caused by the useMutation hook

* fix similar artists from being cutoff on the artist page
This commit is contained in:
Violet Caulfield
2025-09-20 07:43:52 -05:00
committed by GitHub
parent 8898b12162
commit d992e5a38b
22 changed files with 1024 additions and 1033 deletions
+2 -2
View File
@@ -47,7 +47,7 @@
"@react-navigation/native-stack": "^7.3.26",
"@sentry/react-native": "7.1.0",
"@shopify/flash-list": "^2.0.3",
"@tamagui/config": "1.132.25",
"@tamagui/config": "1.133.0",
"@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0",
@@ -94,7 +94,7 @@
"react-native-worklets": "0.4.1",
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "1.132.25",
"tamagui": "1.133.0",
"zustand": "^5.0.8"
},
"devDependencies": {
+1 -1
View File
@@ -136,7 +136,7 @@ function AlbumTrackListHeader(): React.JSX.Element {
const [networkStatus] = useNetworkStatus()
const streamingDeviceProfile = useStreamingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const { album, discs } = useAlbumContext()
+1 -1
View File
@@ -30,7 +30,7 @@ export default function ArtistHeader(): React.JSX.Element {
const streamingDeviceProfile = useStreamingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const theme = useTheme()
+11 -8
View File
@@ -8,6 +8,7 @@ import ItemRow from '../Global/components/item-row'
import ArtistHeader from './header'
import { Text } from '../Global/helpers/text'
import SimilarArtists from './similar'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function ArtistNavigation({
navigation,
@@ -55,13 +56,15 @@ export default function ArtistNavigation({
)
return (
<SectionList
contentInsetAdjustmentBehavior='automatic'
sections={sections}
ListHeaderComponent={ArtistHeader}
renderSectionHeader={renderSectionHeader}
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
ListFooterComponent={SimilarArtists}
/>
<SafeAreaView edges={['right', 'left']}>
<SectionList
contentInsetAdjustmentBehavior='automatic'
sections={sections}
ListHeaderComponent={ArtistHeader}
renderSectionHeader={renderSectionHeader}
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
ListFooterComponent={SimilarArtists}
/>
</SafeAreaView>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default function SimilarArtists(): React.JSX.Element {
return (
<YStack flex={1}>
<Text
padding={'$3'}
margin={'$3'}
fontSize={'$6'}
bold
>{`Similar to ${artist.Name ?? 'Unknown Artist'}`}</Text>
+1 -3
View File
@@ -1,11 +1,10 @@
import React from 'react'
import { getToken, ScrollView, Separator, View, YStack } from 'tamagui'
import { getToken, ScrollView, View, YStack } from 'tamagui'
import RecentlyAdded from './helpers/just-added'
import { useDiscoverContext } from '../../providers/Discover'
import { RefreshControl } from 'react-native'
import PublicPlaylists from './helpers/public-playlists'
import SuggestedArtists from './helpers/suggested-artists'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function Index(): React.JSX.Element {
const { refreshing, refresh, publicPlaylists, suggestedArtistsInfiniteQuery } =
@@ -19,7 +18,6 @@ export default function Index(): React.JSX.Element {
}}
contentInsetAdjustmentBehavior='automatic'
removeClippedSubviews
paddingBottom={'$15'}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
>
<YStack gap={'$3'}>
@@ -50,7 +50,7 @@ export default function ItemRow({
const deviceProfile = useStreamingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const warmContext = useItemContext()
+2 -1
View File
@@ -62,7 +62,7 @@ export default function Track({
const { data: nowPlaying } = useNowPlaying()
const { data: playQueue } = useQueue()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const [networkStatus] = useNetworkStatus()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
@@ -172,6 +172,7 @@ export default function Track({
marginRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
>
<XStack
alignContent='center'
@@ -28,7 +28,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const { horizontalItems } = useDisplayContext()
return (
@@ -29,7 +29,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const tracksInfiniteQuery = useRecentlyPlayedTracks()
+1 -2
View File
@@ -1,11 +1,10 @@
import { ScrollView, RefreshControl } from 'react-native'
import { YStack, Separator, getToken } from 'tamagui'
import { YStack, getToken } from 'tamagui'
import RecentArtists from './helpers/recent-artists'
import RecentlyPlayed from './helpers/recently-played'
import FrequentArtists from './helpers/frequent-artists'
import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { usePreventRemove } from '@react-navigation/native'
import { SafeAreaView } from 'react-native-safe-area-context'
import useHomeQueries from '../../api/queries/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
+44 -39
View File
@@ -4,60 +4,61 @@ import IconButton from '../../../components/Global/helpers/icon-button'
import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
import React, { useMemo } from 'react'
export default function PlayPauseButton({
function PlayPauseButtonComponent({
size,
flex,
}: {
size?: number | undefined
flex?: number | undefined
}): React.JSX.Element {
const { mutate: togglePlayback } = useTogglePlayback()
const togglePlayback = useTogglePlayback()
const state = usePlaybackState()
let button: React.JSX.Element = <></>
const largeIcon = useMemo(() => isUndefined(size) || size >= 20, [size])
console.log('state', state)
switch (state) {
case State.Playing: {
button = (
<IconButton
circular
largeIcon={isUndefined(size) || size >= 20}
size={size}
name='pause'
testID='pause-button-test-id'
onPress={togglePlayback}
/>
)
break
}
case State.Buffering:
case State.Loading: {
button = (
<Circle size={size} disabled borderWidth={'$1.5'} borderColor={'$primary'}>
<Spinner margin={10} size='small' color={'$primary'} />
</Circle>
)
break
}
const button = useMemo(() => {
switch (state) {
case State.Playing: {
return (
<IconButton
circular
largeIcon={largeIcon}
size={size}
name='pause'
testID='pause-button-test-id'
onPress={togglePlayback}
/>
)
}
default: {
button = (
<IconButton
circular
largeIcon={isUndefined(size) || size >= 20}
size={size}
name='play'
testID='play-button-test-id'
onPress={togglePlayback}
/>
)
break
case State.Buffering:
case State.Loading: {
return (
<Circle size={size} disabled borderWidth={'$1.5'} borderColor={'$primary'}>
<Spinner margin={10} size='small' color={'$primary'} />
</Circle>
)
}
default: {
return (
<IconButton
circular
largeIcon={largeIcon}
size={size}
name='play'
testID='play-button-test-id'
onPress={togglePlayback}
/>
)
}
}
}
}, [state, size, largeIcon, togglePlayback])
return (
<View justifyContent='center' alignItems='center' flex={flex}>
@@ -65,3 +66,7 @@ export default function PlayPauseButton({
</View>
)
}
const PlayPauseButton = React.memo(PlayPauseButtonComponent)
export default PlayPauseButton
@@ -13,8 +13,8 @@ import {
import { useShuffle } from '../../../stores/player/queue'
export default function Controls(): React.JSX.Element {
const { mutate: previous } = usePrevious()
const { mutate: skip } = useSkip()
const previous = usePrevious()
const skip = useSkip()
const { data: repeatMode } = useRepeatMode()
const { mutate: toggleRepeatMode } = useToggleRepeatMode()
+1 -1
View File
@@ -173,7 +173,7 @@ export default function Lyrics({
const { lyrics } = route.params
const { width, height } = useWindowDimensions()
const { position } = useProgress(UPDATE_INTERVAL)
const { mutate: seekTo } = useSeekTo()
const seekTo = useSeekTo()
const theme = useTheme()
const flatListRef = useRef<FlatList<ParsedLyricLine>>(null)
@@ -16,7 +16,7 @@ import useHapticFeedback from '../../../hooks/use-haptic-feedback'
const scrubGesture = Gesture.Pan()
export default function Scrubber(): React.JSX.Element {
const { isPending: seekPending, mutateAsync: seekToAsync } = useSeekTo()
const seekTo = useSeekTo()
const { data: nowPlaying } = useNowPlaying()
const { width } = useSafeAreaFrame()
@@ -56,13 +56,12 @@ export default function Scrubber(): React.JSX.Element {
if (
!isUserInteractingRef.current &&
Date.now() - lastSeekTimeRef.current > 200 && // 200ms debounce after seeking
!seekPending &&
Math.abs(calculatedPosition - lastPositionRef.current) > 1 // Only update if position changed significantly
) {
setDisplayPosition(calculatedPosition)
lastPositionRef.current = calculatedPosition
}
}, [calculatedPosition, seekPending])
}, [calculatedPosition])
// Handle track changes
useEffect(() => {
@@ -83,14 +82,14 @@ export default function Scrubber(): React.JSX.Element {
const seekTime = Math.max(0, position / ProgressMultiplier)
lastSeekTimeRef.current = Date.now()
return seekToAsync(seekTime).finally(() => {
return seekTo(seekTime).finally(() => {
// Small delay to let the seek settle before allowing updates
setTimeout(() => {
isUserInteractingRef.current = false
}, 100)
})
},
[seekToAsync],
[seekTo],
)
// Memoize time calculations to prevent unnecessary re-renders
+6 -6
View File
@@ -26,8 +26,8 @@ import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations'
export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
const { data: nowPlaying } = useNowPlaying()
const { mutate: skip } = useSkip()
const { mutate: previous } = usePrevious()
const skip = useSkip()
const previous = usePrevious()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -83,8 +83,8 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
)
return (
<View testID='miniplayer-test-id'>
<GestureDetector gesture={gesture}>
<GestureDetector gesture={gesture}>
<Animated.View testID='miniplayer-test-id' entering={FadeIn} exiting={FadeOut}>
<YStack>
<MiniPlayerProgress />
<XStack paddingBottom={'$1'} alignItems='center' onPress={openPlayer}>
@@ -138,8 +138,8 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
</XStack>
</XStack>
</YStack>
</GestureDetector>
</View>
</Animated.View>
</GestureDetector>
)
})
+1 -1
View File
@@ -29,7 +29,7 @@ export default function Queue({
const { mutate: removeUpcomingTracks } = useRemoveUpcomingTracks()
const { mutate: removeFromQueue } = useRemoveFromQueue()
const { mutate: reorderQueue } = useReorderQueue()
const { mutate: skip } = useSkip()
const skip = useSkip()
const trigger = useHapticFeedback()
@@ -30,8 +30,6 @@ export default function PlayliistTracklistHeader(
playlistTracks: BaseItemDto[],
canEdit: boolean | undefined,
): React.JSX.Element {
const { api } = useJellifyContext()
const { width } = useSafeAreaFrame()
const { setEditing, scroll } = usePlaylistContext()
@@ -136,7 +134,7 @@ function PlaylistHeaderControls({
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
const isDownloading = pendingDownloads.length != 0
const { api } = useJellifyContext()
+1 -1
View File
@@ -19,7 +19,7 @@ const CarPlayContextInitializer = () => {
const deviceProfile = useStreamingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const loadNewQueue = useLoadNewQueue()
useEffect(() => {
function onConnect() {
@@ -1,7 +1,6 @@
import { isUndefined } from 'lodash'
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../../player/config'
import TrackPlayer from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
export async function previous(): Promise<void> {
const { position } = await TrackPlayer.getProgress()
+70 -81
View File
@@ -30,6 +30,7 @@ import {
PLAY_QUEUE_QUERY_KEY,
} from '../constants/query-keys'
import { usePlayerQueueStore, useShuffle } from '../../../stores/player/queue'
import { useCallback } from 'react'
const PLAYER_MUTATION_OPTIONS = {
retry: false,
@@ -51,51 +52,49 @@ export const usePlay = () => {
* A mutation to handle toggling the playback state
*/
export const useTogglePlayback = () => {
const state = usePlaybackState()
const isCasting =
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async () => {
trigger('impactMedium')
return useCallback(async () => {
trigger('impactMedium')
const { state } = await TrackPlayer.getPlaybackState()
if (state === State.Playing) {
console.debug('Pausing playback')
// handlePlaybackStateChanged(State.Paused)
if (isCasting && remoteClient) {
remoteClient.pause()
return
} else {
TrackPlayer.pause()
return
}
}
const { duration, position } = await TrackPlayer.getProgress()
if (state === State.Playing) {
console.debug('Pausing playback')
// handlePlaybackStateChanged(State.Paused)
if (isCasting && remoteClient) {
const mediaStatus = await remoteClient.getMediaStatus()
const streamPosition = mediaStatus?.streamPosition
if (streamPosition && duration <= streamPosition) {
await remoteClient.seek({
position: 0,
resumeState: 'play',
})
}
await remoteClient.play()
remoteClient.pause()
return
} else {
TrackPlayer.pause()
return
}
// if the track has ended, seek to start and play
if (duration <= position) {
await TrackPlayer.seekTo(0)
}
}
// handlePlaybackStateChanged(State.Playing)
return TrackPlayer.play()
},
})
const { duration, position } = await TrackPlayer.getProgress()
if (isCasting && remoteClient) {
const mediaStatus = await remoteClient.getMediaStatus()
const streamPosition = mediaStatus?.streamPosition
if (streamPosition && duration <= streamPosition) {
await remoteClient.seek({
position: 0,
resumeState: 'play',
})
}
await remoteClient.play()
return
}
// if the track has ended, seek to start and play
if (duration <= position) {
await TrackPlayer.seekTo(0)
}
// handlePlaybackStateChanged(State.Playing)
return TrackPlayer.play()
}, [isCasting, remoteClient, trigger])
}
export const useToggleRepeatMode = () => {
@@ -131,9 +130,10 @@ export const useSeekTo = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: async (position: number) => {
return useCallback(
async (position: number) => {
trigger('impactLight')
console.log('position', position)
if (isCasting && remoteClient) {
await remoteClient.seek({
@@ -144,7 +144,8 @@ export const useSeekTo = () => {
}
await TrackPlayer.seekTo(position)
},
})
[isCasting, remoteClient, trigger],
)
}
/**
@@ -210,13 +211,11 @@ export const useLoadNewQueue = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: async () => {
return useCallback(
async (variables: QueueMutation) => {
trigger('impactLight')
await TrackPlayer.pause()
},
mutationFn: (variables: QueueMutation) => loadQueue({ ...variables, downloadedTracks }),
onSuccess: async ({ finalStartIndex, tracks }, { startPlayback, queue }) => {
const { finalStartIndex, tracks } = await loadQueue({ ...variables, downloadedTracks })
console.debug('Successfully loaded new queue')
if (isCasting && remoteClient) {
await TrackPlayer.skip(finalStartIndex)
@@ -226,54 +225,47 @@ export const useLoadNewQueue = () => {
await TrackPlayer.skip(finalStartIndex)
if (startPlayback) await TrackPlayer.play()
if (variables.startPlayback) await TrackPlayer.play()
queryClient.setQueryData(PLAY_QUEUE_QUERY_KEY, tracks)
queryClient.setQueryData(ACTIVE_INDEX_QUERY_KEY, finalStartIndex)
queryClient.setQueryData(NOW_PLAYING_QUERY_KEY, tracks[finalStartIndex])
usePlayerQueueStore.getState().setQueueRef(queue)
usePlayerQueueStore.getState().setQueueRef(variables.queue)
refetchPlayerQueue()
},
onError: async (error: Error) => {
trigger('notificationError')
console.error('Failed to load new queue', error)
},
onSettled: refetchPlayerQueue,
})
[isCasting, remoteClient, navigation, downloadedTracks, trigger],
)
}
export const usePrevious = () =>
useMutation({
mutationFn: previous,
onSuccess: async () => {
console.debug('Skipped to previous track')
refetchNowPlaying()
},
onError: async (error: Error) => {
console.error('Failed to skip to previous track:', error)
},
})
export const usePrevious = () => {
const trigger = useHapticFeedback()
return useCallback(async () => {
trigger('impactMedium')
await previous()
console.debug('Skipped to previous track')
refetchNowPlaying()
}, [trigger])
}
export const useSkip = () => {
const trigger = useHapticFeedback()
return useMutation({
onMutate: (index?: number | undefined) => {
return useCallback(
async (index?: number | undefined) => {
trigger('impactMedium')
console.debug(
`Skip to next triggered. ${!isUndefined(index) ? `Index is using ${index} as index since it was provided` : ''}`,
)
},
mutationFn: skip,
onSuccess: async () => {
skip(index)
console.debug('Skipped to next track')
refetchNowPlaying()
},
onError: async (error: Error) => {
console.error('Failed to skip to next track:', error)
},
})
[trigger],
)
}
export const useRemoveFromQueue = () => {
@@ -365,13 +357,10 @@ export const useToggleShuffle = () => {
}
export const useAudioNormalization = () =>
useMutation({
onMutate: () => console.debug('Normalizing audio level'),
mutationFn: async (track: JellifyTrack) => {
const volume = calculateTrackVolume(track)
await TrackPlayer.setVolume(volume)
return volume
},
onSuccess: (volume) => console.debug(`Audio level set to ${volume}`),
onError: (error) => console.error('Failed to apply audio normalization', error),
})
useCallback(async (track: JellifyTrack) => {
console.debug('Normalizing audio level')
const volume = calculateTrackVolume(track)
await TrackPlayer.setVolume(volume)
console.debug(`Audio level set to ${volume}`)
return volume
}, [])
+871 -871
View File
File diff suppressed because it is too large Load Diff