mirror of
https://github.com/Jellify-Music/App.git
synced 2026-01-06 02:50:30 -06:00
Perv v2
This commit is contained in:
13
.github/workflows/build-ios.yml
vendored
13
.github/workflows/build-ios.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user