This commit is contained in:
riteshshukla04
2025-08-16 20:55:17 +05:30
parent efc58a2e36
commit 52ed7809c5
11 changed files with 487 additions and 299 deletions

View File

@@ -2,18 +2,14 @@ name: Build iOS IPA
on:
workflow_dispatch:
pull_request:
paths:
- 'ios/**'
- 'Gemfile'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/build-ios.yml'
- '.github/workflows/publish-beta.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-ios:
if: github.repository == 'Jellify-Music/App'
runs-on: macos-15
steps:
- name: 🛒 Checkout
@@ -31,10 +27,9 @@ jobs:
uses: ./.github/actions/setup-xcode
- name: 🍎 Run yarn init-ios:new-arch
run: yarn init-ios:new-arch
run: yarn init-android && cd ios && bundle install && RCT_USE_RN_DEP=1 RCT_USE_PREBUILT_RNCORE=1 bundle exec pod install
- name: 🚀 Run fastlane build
run: yarn fastlane:ios:build
env:

View File

@@ -2,12 +2,14 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useNetworkContext } from '../../../providers/Network'
import { Spacer } from 'tamagui'
import Icon from './icon'
import { memo, useMemo } from 'react'
export default function DownloadedIcon({ item }: { item: BaseItemDto }) {
function DownloadedIcon({ item }: { item: BaseItemDto }) {
const { downloadedTracks } = useNetworkContext()
const isDownloaded = downloadedTracks?.find(
(downloadedTrack) => downloadedTrack.item.Id === item.Id,
const isDownloaded = useMemo(
() => downloadedTracks?.find((downloadedTrack) => downloadedTrack.item.Id === item.Id),
[downloadedTracks, item.Id],
)
return isDownloaded ? (
@@ -16,3 +18,9 @@ export default function DownloadedIcon({ item }: { item: BaseItemDto }) {
<Spacer flex={0.5} />
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(DownloadedIcon, (prevProps, nextProps) => {
// Only re-render if the item ID changes
return prevProps.item.Id === nextProps.item.Id
})

View File

@@ -4,7 +4,7 @@ import Icon from './icon'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useEffect, useState } from 'react'
import { useEffect, useState, memo } from 'react'
import { useJellifyContext } from '../../../providers'
/**
@@ -14,7 +14,7 @@ import { useJellifyContext } from '../../../providers'
* @param item - The item to display the favorite icon for.
* @returns A React component that displays a favorite icon for a given item.
*/
export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false)
const { api, user } = useJellifyContext()
@@ -23,10 +23,13 @@ export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(api, user, item.Id!),
staleTime: 1000 * 60 * 5, // 5 minutes,
enabled: !!api && !!user && !!item.Id, // Only run if we have the required data
})
useEffect(() => {
if (!isPending) setIsFavorite(userData?.IsFavorite ?? false)
if (!isPending && userData !== undefined) {
setIsFavorite(userData?.IsFavorite ?? false)
}
}, [userData, isPending])
return isFavorite ? (
@@ -35,3 +38,12 @@ export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX
<Spacer flex={0.5} />
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(FavoriteIcon, (prevProps, nextProps) => {
// Only re-render if the item ID changes or if the initial favorite state changes
return (
prevProps.item.Id === nextProps.item.Id &&
prevProps.item.UserData?.IsFavorite === nextProps.item.UserData?.IsFavorite
)
})

View File

@@ -1,5 +1,5 @@
import { usePlayerContext } from '../../../providers/Player'
import React, { useEffect } from 'react'
import React, { useEffect, useMemo, useCallback } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
@@ -63,19 +63,113 @@ export default function Track({
const { downloadedTracks, networkStatus } = useNetworkContext()
const { streamingQuality } = useSettingsContext()
const isPlaying = nowPlaying?.item.Id === track.Id
// Memoize expensive computations
const isPlaying = useMemo(
() => nowPlaying?.item.Id === track.Id,
[nowPlaying?.item.Id, track.Id],
)
const offlineAudio = downloadedTracks?.find((t) => t.item.Id === track.Id)
const isDownloaded = offlineAudio?.item?.Id
const offlineAudio = useMemo(
() => downloadedTracks?.find((t) => t.item.Id === track.Id),
[downloadedTracks, track.Id],
)
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
const isDownloaded = useMemo(() => offlineAudio?.item?.Id, [offlineAudio])
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
// Memoize image source to prevent recreation
const imageSource = useMemo(
() => ({
uri:
getImageApi(api!).getItemImageUrlById(
track.AlbumId! || track.Id!,
ImageType.Primary,
{
tag: track.ImageTags?.Primary,
},
) || '',
}),
[api, track.AlbumId, track.Id, track.ImageTags?.Primary],
)
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue.map((track) => track.item),
[tracklist, playQueue],
)
// Memoize handlers to prevent recreation
const handlePress = useCallback(() => {
if (onPress) {
onPress()
} else {
useLoadNewQueue({
track,
index,
tracklist: memoizedTracklist,
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
} else {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}, [onLongPress, navigation, track, isNested])
const handleIconPress = useCallback(() => {
if (showRemove) {
if (onRemove) onRemove()
} else {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}, [showRemove, onRemove, navigation, track, isNested])
// Only fetch media info if needed (for streaming)
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, track.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: !isDownloaded, // Only fetch if not downloaded
})
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return isDownloaded ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, isDownloaded, theme.primary.val, theme.color, theme.neutral.val])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
// Memoize track name
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
// Memoize index number
const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber])
// Memoize show artists condition
const shouldShowArtists = useMemo(
() => showArtwork || (track.Artists && track.Artists.length > 1),
[showArtwork, track.Artists],
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
@@ -84,30 +178,8 @@ export default function Track({
height={showArtwork ? '$6' : '$5'}
flex={1}
testID={testID ?? undefined}
onPress={() => {
if (onPress) {
onPress()
} else {
useLoadNewQueue({
track,
index,
tracklist: tracklist ?? playQueue.map((track) => track.item),
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}}
onLongPress={
onLongPress
? () => onLongPress()
: () => {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}
onPress={handlePress}
onLongPress={handleLongPress}
paddingVertical={'$2'}
justifyContent='center'
marginRight={'$2'}
@@ -120,16 +192,7 @@ export default function Track({
{showArtwork ? (
<FastImage
key={`${track.Id}-${track.AlbumId || track.Id}`}
source={{
uri:
getImageApi(api!).getItemImageUrlById(
track.AlbumId! || track.Id!,
ImageType.Primary,
{
tag: track.ImageTags?.Primary,
},
) || '',
}}
source={imageSource}
style={{
width: getToken('$12'),
height: getToken('$12'),
@@ -139,11 +202,11 @@ export default function Track({
) : (
<Text
key={`${track.Id}-number`}
color={isPlaying ? theme.primary.val : theme.color}
color={textColor}
width={getToken('$12')}
textAlign='center'
>
{track.IndexNumber?.toString() ?? ''}
{indexNumber}
</Text>
)}
</XStack>
@@ -152,28 +215,20 @@ export default function Track({
<Text
key={`${track.Id}-name`}
bold
color={
isPlaying
? theme.primary.val
: isOffline
? isDownloaded
? theme.color
: theme.neutral.val
: theme.color
}
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{track.Name ?? 'Untitled Track'}
{trackName}
</Text>
{(showArtwork || (track.Artists && track.Artists.length > 1)) && (
{shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{track.Artists?.join(', ') ?? ''}
{artistsText}
</Text>
)}
</YStack>
@@ -198,16 +253,7 @@ export default function Track({
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
flex={1}
onPress={() => {
if (showRemove) {
if (onRemove) onRemove()
} else {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}}
onPress={handleIconPress}
/>
</XStack>
</Theme>

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { memo } from 'react'
import { usePlayerContext } from '../../../providers/Player'
import { getToken, useTheme, View, YStack, ZStack } from 'tamagui'
import { useColorScheme } from 'react-native'
@@ -7,7 +7,7 @@ import { useSettingsContext } from '../../../providers/Settings'
import { getPrimaryBlurhashFromDto } from '../../../utils/blurhash'
import { Blurhash } from 'react-native-blurhash'
export default function BlurredBackground({
function BlurredBackground({
width,
height,
}: {
@@ -15,53 +15,67 @@ export default function BlurredBackground({
height: number
}): React.JSX.Element {
const { nowPlaying } = usePlayerContext()
const { theme: themeSetting } = useSettingsContext()
const theme = useTheme()
const colorScheme = useColorScheme()
// Calculate dark mode
const isDarkMode =
themeSetting === 'dark' || (themeSetting === 'system' && useColorScheme() === 'dark')
themeSetting === 'dark' || (themeSetting === 'system' && colorScheme === 'dark')
const blurhash = getPrimaryBlurhashFromDto(nowPlaying!.item)
// Get blurhash safely
const blurhash = nowPlaying?.item ? getPrimaryBlurhashFromDto(nowPlaying.item) : null
// Define gradient colors
const darkGradientColors = [getToken('$black'), getToken('$black25')]
const darkGradientColors2 = [
getToken('$black25'),
getToken('$black75'),
getToken('$black'),
getToken('$black'),
]
// Define styles
const blurhashStyle = {
flex: 1,
width: width,
height: height,
}
const gradientStyle = {
width,
height,
flex: 1,
}
const gradientStyle2 = {
width,
height,
flex: 3,
}
const backgroundStyle = {
flex: 1,
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.background.val,
width: width,
height: height,
opacity: 0.5,
}
return (
<ZStack flex={1} width={width} height={height}>
{blurhash && (
<Blurhash
blurhash={blurhash}
style={{
flex: 1,
width: width,
height: height,
}}
/>
)}
{blurhash && <Blurhash blurhash={blurhash} style={blurhashStyle} />}
{isDarkMode ? (
<YStack width={width} height={height} position='absolute' flex={1}>
<LinearGradient
colors={[getToken('$black'), getToken('$black25')]}
style={{
width,
height,
flex: 1,
}}
/>
<LinearGradient colors={darkGradientColors} style={gradientStyle} />
<LinearGradient
colors={[
getToken('$black25'),
getToken('$black75'),
getToken('$black'),
getToken('$black'),
]}
style={{
width,
height,
flex: 3,
}}
/>
<LinearGradient colors={darkGradientColors2} style={gradientStyle2} />
</YStack>
) : (
<View
@@ -75,8 +89,15 @@ export default function BlurredBackground({
width={width}
height={height}
opacity={0.5}
style={backgroundStyle}
/>
)}
</ZStack>
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(BlurredBackground, (prevProps, nextProps) => {
// Only re-render if dimensions change
return prevProps.width === nextProps.width && prevProps.height === nextProps.height
})

View File

@@ -29,26 +29,28 @@ export default function Scrubber(): React.JSX.Element {
const isUserInteractingRef = useRef(false)
const lastSeekTimeRef = useRef<number>(0)
const currentTrackIdRef = useRef<string | null>(null)
const lastPositionRef = useRef<number>(0)
// Calculate maximum track duration in slider units
// Memoize expensive calculations
const maxDuration = useMemo(() => {
return Math.round(duration * ProgressMultiplier)
}, [duration])
// Calculate current position in slider units
const calculatedPosition = useMemo(() => {
return Math.round(position * ProgressMultiplier)
}, [position])
// Update display position from playback progress
// Optimized position update logic with throttling
useEffect(() => {
// Only update if user is not interacting and enough time has passed since last seek
if (
!isUserInteractingRef.current &&
Date.now() - lastSeekTimeRef.current > 200 && // 200ms debounce after seeking
!useSeekTo.isPending
!useSeekTo.isPending &&
Math.abs(calculatedPosition - lastPositionRef.current) > 1 // Only update if position changed significantly
) {
setDisplayPosition(calculatedPosition)
lastPositionRef.current = calculatedPosition
}
}, [calculatedPosition, useSeekTo.isPending])
@@ -58,6 +60,7 @@ export default function Scrubber(): React.JSX.Element {
if (currentTrackId !== currentTrackIdRef.current) {
// Track changed - reset position immediately
setDisplayPosition(0)
lastPositionRef.current = 0
isUserInteractingRef.current = false
lastSeekTimeRef.current = 0
currentTrackIdRef.current = currentTrackId
@@ -80,16 +83,55 @@ export default function Scrubber(): React.JSX.Element {
[useSeekTo],
)
// Convert display position to seconds for UI display
// Memoize time calculations to prevent unnecessary re-renders
const currentSeconds = useMemo(() => {
return Math.max(0, Math.round(displayPosition / ProgressMultiplier))
}, [displayPosition])
// Get total duration in seconds
const totalSeconds = useMemo(() => {
return Math.round(duration)
}, [duration])
// Memoize slider props to prevent recreation
const sliderProps = useMemo(
() => ({
maxWidth: width / 1.1,
onSlideStart: (event: unknown, value: number) => {
isUserInteractingRef.current = true
trigger('impactLight')
// Immediately update position for responsive UI
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideMove: (event: unknown, value: number) => {
// Throttled haptic feedback for better performance
if (!reducedHaptics) {
trigger('clockTick')
}
// Update position with proper clamping
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideEnd: (event: unknown, value: number) => {
trigger('notificationSuccess')
// Clamp final value and update display
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
// Perform the seek operation
handleSeek(clampedValue).catch(() => {
// On error, revert to calculated position
isUserInteractingRef.current = false
setDisplayPosition(calculatedPosition)
})
},
}),
[maxDuration, reducedHaptics, handleSeek, calculatedPosition, width],
)
return (
<GestureDetector gesture={scrubGesture}>
<YStack>
@@ -97,41 +139,7 @@ export default function Scrubber(): React.JSX.Element {
value={displayPosition}
max={maxDuration ? maxDuration : 1 * ProgressMultiplier}
width={getToken('$20') + getToken('$20')}
props={{
maxWidth: width / 1.1,
onSlideStart: (event, value) => {
isUserInteractingRef.current = true
trigger('impactLight')
// Immediately update position for responsive UI
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideMove: (event, value) => {
// Throttled haptic feedback for better performance
if (!reducedHaptics) {
trigger('clockTick')
}
// Update position with proper clamping
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
},
onSlideEnd: (event, value) => {
trigger('notificationSuccess')
// Clamp final value and update display
const clampedValue = Math.max(0, Math.min(value, maxDuration))
setDisplayPosition(clampedValue)
// Perform the seek operation
handleSeek(clampedValue).catch(() => {
// On error, revert to calculated position
isUserInteractingRef.current = false
setDisplayPosition(calculatedPosition)
})
},
}}
props={sliderProps}
/>
<XStack paddingTop={'$2'}>

View File

@@ -5,14 +5,14 @@ import { usePlayerContext } from '../../../providers/Player'
import { Text } from '../../Global/helpers/text'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
import React, { useMemo } from 'react'
import React, { useMemo, useCallback, memo } from 'react'
import ItemImage from '../../Global/components/image'
import { useQuery } from '@tanstack/react-query'
import { fetchItem } from '../../../api/queries/item'
import { useJellifyContext } from '../../../providers'
import FavoriteButton from '../../Global/components/favorite-button'
export default function SongInfo({
function SongInfo({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
@@ -23,73 +23,82 @@ export default function SongInfo({
const { data: album } = useQuery({
queryKey: ['album', nowPlaying!.item.AlbumId],
queryFn: () => fetchItem(api, nowPlaying!.item.AlbumId!),
enabled: !!nowPlaying?.item.AlbumId && !!api,
})
return useMemo(() => {
return (
<XStack flex={1}>
<YStack
marginHorizontal={'$1.5'}
onPress={() => {
if (album) {
navigation.goBack() // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Library',
params: {
screen: 'Album',
params: {
album,
},
},
})
}
}}
justifyContent='center'
>
<ItemImage item={nowPlaying!.item} width={'$11'} height={'$11'} />
</YStack>
// Memoize expensive computations
const trackTitle = useMemo(() => nowPlaying!.title ?? 'Untitled Track', [nowPlaying?.title])
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
<TextTicker {...TextTickerConfig} style={{ height: getToken('$9') }}>
<Text bold fontSize={'$6'}>
{nowPlaying!.title ?? 'Untitled Track'}
</Text>
</TextTicker>
const artistName = useMemo(() => nowPlaying?.artist ?? 'Unknown Artist', [nowPlaying?.artist])
<TextTicker {...TextTickerConfig} style={{ height: getToken('$8') }}>
<Text
fontSize={'$6'}
color={'$color'}
onPress={() => {
if (nowPlaying!.item.ArtistItems) {
if (nowPlaying!.item.ArtistItems!.length > 1) {
navigation.navigate('MultipleArtists', {
artists: nowPlaying!.item.ArtistItems!,
})
} else {
navigation.goBack() // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Library',
params: {
screen: 'Artist',
params: {
artist: nowPlaying!.item.ArtistItems![0],
},
},
})
}
}
}}
>
{nowPlaying?.artist ?? 'Unknown Artist'}
</Text>
</TextTicker>
</YStack>
const artistItems = useMemo(() => nowPlaying!.item.ArtistItems, [nowPlaying?.item.ArtistItems])
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
<FavoriteButton item={nowPlaying!.item} />
</XStack>
// Memoize navigation handlers
const handleAlbumPress = useCallback(() => {
if (album) {
navigation.goBack() // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Library',
params: {
screen: 'Album',
params: {
album,
},
},
})
}
}, [album, navigation])
const handleArtistPress = useCallback(() => {
if (artistItems) {
if (artistItems.length > 1) {
navigation.navigate('MultipleArtists', {
artists: artistItems,
})
} else {
navigation.goBack() // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Library',
params: {
screen: 'Artist',
params: {
artist: artistItems[0],
},
},
})
}
}
}, [artistItems, navigation])
return (
<XStack flex={1}>
<YStack marginHorizontal={'$1.5'} onPress={handleAlbumPress} justifyContent='center'>
<ItemImage item={nowPlaying!.item} width={'$11'} height={'$11'} />
</YStack>
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
<TextTicker {...TextTickerConfig} style={{ height: getToken('$9') }}>
<Text bold fontSize={'$6'}>
{trackTitle}
</Text>
</TextTicker>
<TextTicker {...TextTickerConfig} style={{ height: getToken('$8') }}>
<Text fontSize={'$6'} color={'$color'} onPress={handleArtistPress}>
{artistName}
</Text>
</TextTicker>
</YStack>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
<FavoriteButton item={nowPlaying!.item} />
</XStack>
)
}, [nowPlaying, album])
</XStack>
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(SongInfo, (prevProps, nextProps) => {
// Only re-render if navigation changes (which it shouldn't)
return prevProps.navigation === nextProps.navigation
})

View File

@@ -1,7 +1,7 @@
import { StackParamList } from '../types'
import { usePlayerContext } from '../../providers/Player'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useState, useMemo } from 'react'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { YStack, XStack, getToken, useTheme, ZStack, useWindowDimensions, View } from 'tamagui'
import Scrubber from './components/scrubber'
@@ -13,12 +13,16 @@ import Footer from './components/footer'
import BlurredBackground from './components/blurred-background'
import PlayerHeader from './components/header'
import SongInfo from './components/song-info'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
export default function PlayerScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
// Monitor performance
const performanceMetrics = usePerformanceMonitor('PlayerScreen', 5)
const [showToast, setShowToast] = useState(true)
const { nowPlaying } = usePlayerContext()
@@ -37,6 +41,35 @@ export default function PlayerScreen({
const { bottom } = useSafeAreaInsets()
// Memoize expensive calculations
const songInfoContainerStyle = useMemo(
() => ({
justifyContent: 'center' as const,
alignItems: 'center' as const,
marginHorizontal: 'auto' as const,
width: getToken('$20') + getToken('$20') + getToken('$5'),
maxWidth: width / 1.1,
flex: 2,
}),
[width],
)
const scrubberContainerStyle = useMemo(
() => ({
justifyContent: 'center' as const,
flex: 1,
}),
[],
)
const mainContainerStyle = useMemo(
() => ({
flex: 1,
marginBottom: bottom,
}),
[bottom],
)
return (
<SafeAreaView style={{ flex: 1 }}>
<View flex={1}>
@@ -44,21 +77,14 @@ export default function PlayerScreen({
<ZStack fullscreen>
<BlurredBackground width={width} height={height} />
<YStack flex={1} marginBottom={bottom}>
<YStack flex={1} marginBottom={bottom} style={mainContainerStyle}>
<PlayerHeader navigation={navigation} />
<XStack
justifyContent='center'
alignItems='center'
marginHorizontal={'auto'}
width={getToken('$20') + getToken('$20') + getToken('$5')}
maxWidth={width / 1.1}
flex={2}
>
<XStack style={songInfoContainerStyle}>
<SongInfo navigation={navigation} />
</XStack>
<XStack justifyContent='center' flex={1}>
<XStack style={scrubberContainerStyle}>
{/* playback progress goes here */}
<Scrubber />
</XStack>

View File

@@ -3,7 +3,7 @@ import Track from '../Global/components/track'
import { StackParamList } from '../types'
import { usePlayerContext } from '../../providers/Player'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import DraggableFlatList from 'react-native-draggable-flatlist'
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist'
import { Separator, XStack } from 'tamagui'
import {
useRemoveUpcomingTracksContext,
@@ -15,7 +15,8 @@ import {
} from '../../providers/Player/queue'
import { trigger } from 'react-native-haptic-feedback'
import { isUndefined } from 'lodash'
import { useLayoutEffect } from 'react'
import { useLayoutEffect, useCallback, useMemo } from 'react'
import JellifyTrack from '../../types/JellifyTrack'
export default function Queue({
navigation,
@@ -44,10 +45,78 @@ export default function Queue({
)
},
})
}, [navigation])
}, [navigation, useRemoveUpcomingTracks])
const scrollIndex = playQueue.findIndex(
(queueItem) => queueItem.item.Id! === nowPlaying!.item.Id!,
// Memoize scroll index calculation
const scrollIndex = useMemo(
() => playQueue.findIndex((queueItem) => queueItem.item.Id! === nowPlaying!.item.Id!),
[playQueue, nowPlaying?.item.Id],
)
// Memoize key extractor for better performance
const keyExtractor = useCallback(
(item: JellifyTrack, index: number) => `${index}-${item.item.Id}`,
[],
)
// Memoize getItemLayout for better performance
const getItemLayout = useCallback(
(data: ArrayLike<JellifyTrack> | null | undefined, index: number) => ({
length: 20,
offset: (20 / 9) * index,
index,
}),
[],
)
// Memoize ItemSeparatorComponent to prevent recreation
const ItemSeparatorComponent = useCallback(() => <Separator />, [])
// Memoize onDragEnd handler
const handleDragEnd = useCallback(
({ from, to }: { from: number; to: number }) => {
useReorderQueue({ from, to })
},
[useReorderQueue],
)
// Memoize renderItem function for better performance
const renderItem = useCallback(
({ item: queueItem, getIndex, drag, isActive }: RenderItemParams<JellifyTrack>) => {
const index = getIndex()
const handleLongPress = () => {
trigger('impactLight')
drag()
}
const handlePress = () => {
if (!isUndefined(index)) useSkip(index)
}
const handleRemove = () => {
if (!isUndefined(index)) useRemoveFromQueue.mutate(index)
}
return (
<XStack alignItems='center' onLongPress={handleLongPress}>
<Track
queue={queueRef}
navigation={navigation}
track={queueItem.item}
index={index ?? 0}
showArtwork
testID={`queue-item-${index}`}
onPress={handlePress}
onLongPress={handleLongPress}
isNested
showRemove
onRemove={handleRemove}
/>
</XStack>
)
},
[queueRef, navigation, useSkip, useRemoveFromQueue],
)
return (
@@ -57,55 +126,18 @@ export default function Queue({
dragHitSlop={{
left: -50, // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
}}
extraData={nowPlaying}
// enableLayoutAnimationExperimental
getItemLayout={(data, index) => ({
length: 20,
offset: (20 / 9) * index,
index,
})}
extraData={nowPlaying?.item.Id} // Only track the playing track ID, not the entire object
getItemLayout={getItemLayout}
initialScrollIndex={scrollIndex !== -1 ? scrollIndex : 0}
ItemSeparatorComponent={() => <Separator />}
// itemEnteringAnimation={FadeIn}
// itemExitingAnimation={FadeOut}
// itemLayoutAnimation={SequencedTransition}
keyExtractor={({ item }, index) => `${index}-${item.Id}`}
ItemSeparatorComponent={ItemSeparatorComponent}
keyExtractor={keyExtractor}
numColumns={1}
onDragEnd={({ from, to }) => {
useReorderQueue({ from, to })
}}
renderItem={({ item: queueItem, getIndex, drag, isActive }) => (
<XStack
alignItems='center'
onLongPress={(event) => {
trigger('impactLight')
drag()
}}
>
<Track
queue={queueRef}
navigation={navigation}
track={queueItem.item}
index={getIndex() ?? 0}
showArtwork
testID={`queue-item-${getIndex()}`}
onPress={() => {
const index = getIndex()
if (!isUndefined(index)) useSkip(index)
}}
onLongPress={() => {
trigger('impactLight')
drag()
}}
isNested
showRemove
onRemove={() => {
const index = getIndex()
if (!isUndefined(index)) useRemoveFromQueue.mutate(index)
}}
/>
</XStack>
)}
onDragEnd={handleDragEnd}
renderItem={renderItem}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
updateCellsBatchingPeriod={50}
/>
)
}

View File

@@ -73,20 +73,57 @@ export function usePerformanceMonitor(
* @param contextValue - The context value to monitor
* @param contextName - Name of the context for logging
*/
export function useContextChangeDetector<T>(contextValue: T, contextName: string): void {
const previousValue = useRef<T>(contextValue)
export function useContextChangeMonitor<T>(contextValue: T, contextName: string): void {
const prevValue = useRef<T>(contextValue)
const changeCount = useRef(0)
useEffect(() => {
if (previousValue.current !== contextValue) {
console.log(`🔄 Context Change: ${contextName}`, {
previous: previousValue.current,
current: contextValue,
})
previousValue.current = contextValue
if (prevValue.current !== contextValue) {
changeCount.current += 1
console.debug(
`🔄 Context Change: ${contextName} changed (change #${changeCount.current})`,
{
prev: prevValue.current,
next: contextValue,
},
)
prevValue.current = contextValue
}
}, [contextValue, contextName])
}
/**
* Hook to monitor array/object size changes that might indicate memory leaks
* @param data - The data to monitor
* @param dataName - Name of the data for logging
* @param sizeThreshold - Threshold for warning about large data (default: 1000)
*/
export function useDataSizeMonitor<T>(
data: T[] | Record<string, unknown>,
dataName: string,
sizeThreshold: number = 1000,
): void {
const prevSize = useRef(0)
useEffect(() => {
const currentSize = Array.isArray(data) ? data.length : Object.keys(data).length
if (currentSize !== prevSize.current) {
console.debug(
`📏 Data Size Change: ${dataName} size changed from ${prevSize.current} to ${currentSize}`,
)
if (currentSize > sizeThreshold) {
console.warn(
`⚠️ Large Data Warning: ${dataName} has ${currentSize} items (threshold: ${sizeThreshold})`,
)
}
prevSize.current = currentSize
}
}, [data, dataName, sizeThreshold])
}
/**
* Hook to measure the time spent in expensive operations
* @param operationName - Name of the operation for logging

View File

@@ -708,13 +708,7 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
// Only recreate when essential values change
const value = useMemo(
() => context,
[
context.nowPlaying?.item?.Id,
context.playbackState,
context.repeatMode,
context.shuffled,
// Don't include mutation objects as dependencies since they're stable
],
[context.nowPlaying?.item?.Id, context.playbackState, context.repeatMode, context.shuffled],
)
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>