Fixing Le Bugers: UI Polish & Performance Tuning (#724)

* fix: update sort icon name and label in ArtistTabBar

* fix: optimize image URL generation and improve performance in Playlists and Tracks components

* homescreen improvements

* deduplicate tracks in FrequentlyPlayedTracks and RecentlyPlayed components

* enhance storage management with versioning and slimmed track persistence

* refactor HorizontalCardList to allow customizable estimatedItemSize

* update sort button label in ArtistTabBar for clarity

* refactor media info fetching and improve search debounce logic

* refactor navigation parameters in artist and track components for simplicity

* refactor PlayPauseButton to manage optimistic UI state and improve playback handling

* Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads

* Revert "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads"

This reverts commit 1c63b748b6.

* Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads

* Revert "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads"

This reverts commit f9e0e82e57.

* Reapply "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads"

This reverts commit 6710d3404c.

* Update project configuration: refine build phases, adjust code signing identity, and format flags

* Fix TypeScript errors in Search component and improve playback state handler in Player queries

* Refactor ItemRow component to accept queueName prop and update queue handling

* lot's o fixes to item cards and item rows

* memoize tracks component

* fix jest

* simplify navigation in FrequentArtists, FrequentlyPlayedTracks, RecentArtists, and RecentlyPlayed components

* Update axios version and enhance image handling options in components

* Enhance ItemImage component with imageOptions for better image handling in PlayerHeader and Miniplayer

* moves buffers to player config

---------

Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Co-authored-by: Violet Caulfield <violet@cosmonautical.cloud>
This commit is contained in:
skalthoff
2025-12-03 18:07:30 -08:00
committed by GitHub
parent f2761e3d88
commit 238dd0340a
29 changed files with 562 additions and 175 deletions

80
App.tsx
View File

@@ -1,5 +1,5 @@
import './gesture-handler'
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import 'react-native-url-polyfill/auto'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import Jellify from './src/components/jellify'
@@ -24,7 +24,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import navigationRef from './navigation'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
import { BUFFERS, PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
import { useThemeSetting } from './src/stores/settings/app'
LogBox.ignoreAllLogs()
@@ -34,47 +34,47 @@ export default function App(): React.JSX.Element {
const performanceMetrics = usePerformanceMonitor('App', 3)
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
const playerInitializedRef = useRef<boolean>(false)
/**
* Enhanced Android buffer settings for gapless playback
*
* @see
*/
const buffers =
Platform.OS === 'android'
? {
maxCacheSize: 50 * 1024, // 50MB cache
maxBuffer: 30, // 30 seconds buffer
playBuffer: 2.5, // 2.5 seconds play buffer
backBuffer: 5, // 5 seconds back buffer
}
: {}
useEffect(() => {
// Guard against double initialization (React StrictMode, hot reload)
if (playerInitializedRef.current) return
playerInitializedRef.current = true
TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback,
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth],
androidAudioContentType: AndroidAudioContentType.Music,
minBuffer: 30, // 30 seconds minimum buffer
...buffers,
})
.then(() =>
TrackPlayer.updateOptions({
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
// Stop playback and remove notification when app is killed to prevent battery drain
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
}),
)
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback,
iosCategoryOptions: [
IOSCategoryOptions.AllowAirPlay,
IOSCategoryOptions.AllowBluetooth,
],
androidAudioContentType: AndroidAudioContentType.Music,
minBuffer: 30, // 30 seconds minimum buffer
...BUFFERS,
})
.then(() =>
TrackPlayer.updateOptions({
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
// Stop playback and remove notification when app is killed to prevent battery drain
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
}),
)
.catch((error) => {
// Player may already be initialized (e.g., after hot reload)
// This is expected and not a fatal error
console.log('[TrackPlayer] Setup caught:', error?.message ?? error)
})
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
})
}, []) // Empty deps - only run once on mount
const [reloader, setReloader] = useState(0)

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
@@ -23,7 +22,7 @@
"@tanstack/react-query-persist-client": "5.89.0",
"@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "1.12.2",
"axios": "1.13.2",
"bundle": "^2.1.0",
"dlx": "^0.2.1",
"invert-color": "^2.0.0",
@@ -1018,7 +1017,7 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="],
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
"babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="],

View File

@@ -18,6 +18,27 @@ type DownloadedFileInfo = {
size: number
}
const getExtensionFromUrl = (url: string): string | null => {
const sanitized = url.split('?')[0]
const lastSegment = sanitized.split('/').pop() ?? ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
return match?.[1] ?? null
}
const normalizeExtension = (ext: string | undefined | null) => {
if (!ext) return null
const clean = ext.toLowerCase()
return clean === 'mpeg' ? 'mp3' : clean
}
const extensionFromContentType = (contentType: string | undefined): string | null => {
if (!contentType) return null
if (!contentType.includes('/')) return null
const [, subtypeRaw] = contentType.split('/')
const container = subtypeRaw.split(';')[0]
return normalizeExtension(container)
}
export type DeleteDownloadsResult = {
deletedCount: number
freedBytes: number
@@ -29,23 +50,30 @@ export async function downloadJellyfinFile(
name: string,
songName: string,
setDownloadProgress: JellifyDownloadProgressState,
preferredExtension?: string | null,
): Promise<DownloadedFileInfo> {
try {
// Fetch the file
const headRes = await axios.head(url)
const contentType = headRes.headers['content-type']
const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const hintedExtension = normalizeExtension(preferredExtension)
// Step 2: Get extension from content-type
let extension = 'mp3' // default extension
if (contentType && contentType.includes('/')) {
const parts = contentType.split('/')
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
if (container !== 'mpeg') {
extension = container // don't use mpeg as an extension, use the default extension
let extension = urlExtension ?? hintedExtension ?? null
if (!extension) {
try {
const headRes = await axios.head(url)
const headExtension = extensionFromContentType(headRes.headers['content-type'])
if (headExtension) extension = headExtension
} catch (error) {
console.warn(
'HEAD request failed when determining download type, using default',
error,
)
}
}
// Step 3: Build path
if (!extension) extension = 'bin' // fallback without assuming a specific codec
// Build path
const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
@@ -138,6 +166,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
track.mediaSourceInfo?.Container,
)
let downloadedArtworkFile: DownloadedFileInfo | undefined
if (track.artwork) {
@@ -146,6 +175,7 @@ export const saveAudio = async (
track.item.Id as string,
track.title as string,
setDownloadProgress,
undefined,
)
}
track.url = downloadedTrackFile.uri

View File

@@ -2,21 +2,44 @@ import { Api } from '@jellyfin/sdk'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
// Default image size for list thumbnails (optimized for common row heights)
const DEFAULT_THUMBNAIL_SIZE = 200
export interface ImageUrlOptions {
/** Maximum width of the requested image */
maxWidth?: number
/** Maximum height of the requested image */
maxHeight?: number
/** Image quality (0-100) */
quality?: number
}
export function getItemImageUrl(
api: Api | undefined,
item: BaseItemDto,
type: ImageType,
options?: ImageUrlOptions,
): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item
if (!api) return undefined
// Use provided dimensions or default thumbnail size for list performance
const imageParams = {
tag: undefined as string | undefined,
maxWidth: options?.maxWidth ?? DEFAULT_THUMBNAIL_SIZE,
maxHeight: options?.maxHeight ?? DEFAULT_THUMBNAIL_SIZE,
quality: options?.quality ?? 90,
}
return AlbumId
? getImageApi(api).getItemImageUrlById(AlbumId, type, {
...imageParams,
tag: AlbumPrimaryImageTag ?? undefined,
})
: Id
? getImageApi(api).getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags ? ImageTags[type] : undefined,
})
: undefined

View File

@@ -31,6 +31,7 @@ const useStreamedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY,
})
@@ -60,6 +61,7 @@ export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY,
})

View File

@@ -77,11 +77,12 @@ export default function ArtistTabBar({
>
<Icon
name={
sortBy === ItemSortBy.DateCreated ? 'calendar' : 'sort-alphabetical'
sortBy === ItemSortBy.DateCreated
? 'calendar'
: 'sort-alphabetical-ascending'
}
color={'$borderColor'}
/>
/>{' '}
<Text color={'$borderColor'}>
{sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'}
</Text>

View File

@@ -76,6 +76,7 @@ export default function ArtistHeader(): React.JSX.Element {
height={'$20'}
type={ImageType.Backdrop}
cornered
imageOptions={{ maxWidth: width * 2, maxHeight: 640 }}
/>
<YStack alignItems='center' paddingHorizontal={'$3'}>

View File

@@ -2,7 +2,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item
import { FlashList, FlashListProps } from '@shopify/flash-list'
import React from 'react'
interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
type HorizontalCardListProps = Omit<FlashListProps<BaseItemDto>, 'estimatedItemSize'> & {
estimatedItemSize?: number
}
/**
* Displays a Horizontal FlatList of 20 ItemCards
@@ -13,14 +15,17 @@ interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
export default function HorizontalCardList({
data,
renderItem,
estimatedItemSize = 150,
...props
}: HorizontalCardListProps): React.JSX.Element {
return (
<FlashList
<FlashList<BaseItemDto>
horizontal
data={data}
renderItem={renderItem}
removeClippedSubviews
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
estimatedItemSize={estimatedItemSize}
style={{
overflow: 'hidden',
}}

View File

@@ -6,7 +6,7 @@ import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { Blurhash } from 'react-native-blurhash'
import { getBlurhashFromDto } from '../../../utils/blurhash'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { getItemImageUrl } from '../../../api/queries/image/utils'
import { getItemImageUrl, ImageUrlOptions } from '../../../api/queries/image/utils'
import { memo, useCallback, useMemo, useState } from 'react'
import { useApi } from '../../../stores'
@@ -18,6 +18,8 @@ interface ItemImageProps {
width?: Token | number | string | undefined
height?: Token | number | string | undefined
testID?: string | undefined
/** Image resolution options for requesting higher quality images */
imageOptions?: ImageUrlOptions
}
const ItemImage = memo(
@@ -29,10 +31,14 @@ const ItemImage = memo(
width,
height,
testID,
imageOptions,
}: ItemImageProps): React.JSX.Element {
const api = useApi()
const imageUrl = useMemo(() => getItemImageUrl(api, item, type), [api, item.Id, type])
const imageUrl = useMemo(
() => getItemImageUrl(api, item, type, imageOptions),
[api, item.Id, type, imageOptions],
)
return imageUrl ? (
<Image
@@ -56,7 +62,10 @@ const ItemImage = memo(
prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height &&
prevProps.testID === nextProps.testID,
prevProps.testID === nextProps.testID &&
prevProps.imageOptions?.maxWidth === nextProps.imageOptions?.maxWidth &&
prevProps.imageOptions?.maxHeight === nextProps.imageOptions?.maxHeight &&
prevProps.imageOptions?.quality === nextProps.imageOptions?.quality,
)
interface ItemBlurhashProps {

View File

@@ -39,7 +39,7 @@ function ItemCardComponent({
useEffect(() => {
if (item.Type === 'Audio') warmContext(item)
}, [item.Id, warmContext])
}, [item.Id, item.Type, warmContext])
const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress])

View File

@@ -30,12 +30,14 @@ import { useIsFavorite } from '../../../api/queries/user-data'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useApi } from '../../../stores'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { Queue } from '../../../player/types/queue-item'
interface ItemRowProps {
item: BaseItemDto
circular?: boolean
onPress?: () => void
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
queueName?: Queue
}
/**
@@ -50,7 +52,13 @@ interface ItemRowProps {
* @returns
*/
const ItemRow = memo(
function ItemRow({ item, circular, navigation, onPress }: ItemRowProps): React.JSX.Element {
function ItemRow({
item,
circular,
navigation,
onPress,
queueName,
}: ItemRowProps): React.JSX.Element {
const artworkAreaWidth = useSharedValue(0)
const api = useApi()
@@ -91,7 +99,7 @@ const ItemRow = memo(
track: item,
tracklist: [item],
index: 0,
queue: 'Search',
queue: queueName ?? 'Search',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
@@ -115,7 +123,7 @@ const ItemRow = memo(
break
}
}
}, [loadNewQueue, item.Id, navigation])
}, [onPress, loadNewQueue, item.Id, navigation, queueName])
const renderRunTime = useMemo(
() => item.Type === BaseItemKind.Audio && !hideRunTimes,
@@ -229,14 +237,12 @@ const ItemRow = memo(
</SwipeableRow>
)
},
(prevProps, nextProps) => {
return (
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onPress === !!nextProps.onPress &&
prevProps.navigation === nextProps.navigation
)
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
prevProps.navigation === nextProps.navigation &&
prevProps.queueName === nextProps.queueName &&
!!prevProps.onPress === !!nextProps.onPress,
)
const ItemRowDetails = memo(

View File

@@ -17,7 +17,6 @@ import ItemImage from './image'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
import { useDownloadedTrack } from '../../../api/queries/download'
import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
@@ -29,6 +28,10 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori
import { StackActions } from '@react-navigation/native'
import { useSwipeableRowContext } from './swipeable-row-context'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { queryClient, ONE_HOUR } from '../../../constants/query-client'
import { fetchMediaInfo } from '../../../api/queries/media/utils'
import MediaInfoQueryKey from '../../../api/queries/media/keys'
import JellifyTrack from '../../../types/JellifyTrack'
export interface TrackProps {
track: BaseItemDto
@@ -45,6 +48,19 @@ export interface TrackProps {
editing?: boolean | undefined
}
const queueItemsCache = new WeakMap<JellifyTrack[], BaseItemDto[]>()
const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => {
if (!queue?.length) return []
const cached = queueItemsCache.get(queue)
if (cached) return cached
const mapped = queue.map((entry) => entry.item)
queueItemsCache.set(queue, mapped)
return mapped
}
const Track = memo(
function Track({
track,
@@ -75,8 +91,6 @@ const Track = memo(
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const offlineAudio = useDownloadedTrack(track.Id)
const { mutate: addFavorite } = useAddFavorite()
@@ -98,7 +112,7 @@ const Track = memo(
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
() => tracklist ?? getQueueItems(playQueue),
[tracklist, playQueue],
)
@@ -119,40 +133,61 @@ const Track = memo(
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
}, [
onPress,
api,
deviceProfile,
networkStatus,
track,
index,
memoizedTracklist,
queue,
loadNewQueue,
])
const fetchStreamingMediaSourceInfo = useCallback(async () => {
if (!api || !deviceProfile || !track.Id) return undefined
const queryKey = MediaInfoQueryKey({ api, deviceProfile, itemId: track.Id })
try {
const info = await queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchMediaInfo(api, deviceProfile, track.Id),
staleTime: ONE_HOUR,
gcTime: ONE_HOUR,
})
return info.MediaSources?.[0]
} catch (error) {
console.warn('Failed to fetch media info for context sheet', error)
return undefined
}
}, [api, deviceProfile, track.Id])
const openContextSheet = useCallback(async () => {
const streamingMediaSourceInfo = await fetchStreamingMediaSourceInfo()
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [fetchStreamingMediaSourceInfo, track, navigation, offlineAudio?.mediaSourceInfo])
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
return
}
}, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio])
void openContextSheet()
}, [onLongPress, openContextSheet])
const handleIconPress = useCallback(() => {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
void openContextSheet()
}, [openContextSheet])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
@@ -216,6 +251,11 @@ const Track = memo(
[leftSettings, rightSettings, swipeHandlers],
)
const textColor = useMemo(
() => (isPlaying ? theme.primary.val : theme.color.val),
[isPlaying],
)
const runtimeComponent = useMemo(
() =>
hideRunTimes ? (

View File

@@ -55,9 +55,7 @@ export default function FrequentArtists(): React.JSX.Element {
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('MostPlayedArtists', {
artistsInfiniteQuery: frequentArtistsInfiniteQuery,
})
navigation.navigate('MostPlayedArtists')
}}
>
<H5 marginLeft={'$2'}>Most Played</H5>

View File

@@ -43,9 +43,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('MostPlayedTracks', {
tracksInfiniteQuery,
})
navigation.navigate('MostPlayedTracks')
}}
>
<H5 marginLeft={'$2'}>On Repeat</H5>

View File

@@ -23,10 +23,8 @@ export default function RecentArtists(): React.JSX.Element {
const { horizontalItems } = useDisplayContext()
const handleHeaderPress = useCallback(() => {
navigation.navigate('RecentArtists', {
artistsInfiniteQuery: recentArtistsInfiniteQuery,
})
}, [navigation, recentArtistsInfiniteQuery])
navigation.navigate('RecentArtists')
}, [navigation])
const renderItem = useCallback(
({ item: recentArtist }: { item: BaseItemDto }) => (

View File

@@ -44,9 +44,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
<XStack
alignItems='center'
onPress={() => {
navigation.navigate('RecentTracks', {
tracksInfiniteQuery,
})
navigation.navigate('RecentTracks')
}}
>
<H5 marginLeft={'$2'}>Play it again</H5>

View File

@@ -93,7 +93,11 @@ function PlayerArtwork(): React.JSX.Element {
...animatedStyle,
}}
>
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' />
<ItemImage
item={nowPlaying!.item}
testID='player-image-test-id'
imageOptions={{ maxWidth: 800, maxHeight: 800 }}
/>
</Animated.View>
)}
</YStack>

View File

@@ -201,22 +201,36 @@ export default function Lyrics({
}
}, [lyrics])
const lyricStartTimes = useMemo(
() => parsedLyrics.map((line) => line.startTime),
[parsedLyrics],
)
// Track manually selected lyric for immediate feedback
const manuallySelectedIndex = useSharedValue(-1)
const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null)
// Find current lyric line based on playback position
const currentLyricIndex = useMemo(() => {
if (!position || parsedLyrics.length === 0) return -1
if (position === null || position === undefined || lyricStartTimes.length === 0) return -1
// Find the last lyric that has started
for (let i = parsedLyrics.length - 1; i >= 0; i--) {
if (position >= parsedLyrics[i].startTime) {
return i
// Binary search to find the last startTime <= position
let low = 0
let high = lyricStartTimes.length - 1
let found = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (position >= lyricStartTimes[mid]) {
found = mid
low = mid + 1
} else {
high = mid - 1
}
}
return -1
}, [position, parsedLyrics])
return found
}, [position, lyricStartTimes])
// Simple auto-scroll that keeps highlighted lyric in center
const scrollToCurrentLyric = useCallback(() => {

View File

@@ -99,7 +99,12 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-album-image`}
>
<ItemImage item={nowPlaying!.item} width={'$11'} height={'$11'} />
<ItemImage
item={nowPlaying!.item}
width={'$11'}
height={'$11'}
imageOptions={{ maxWidth: 200, maxHeight: 200 }}
/>
</Animated.View>
</YStack>

View File

@@ -1,3 +1,4 @@
import React, { useCallback } from 'react'
import { RefreshControl } from 'react-native-gesture-handler'
import { Separator, useTheme } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
@@ -8,6 +9,12 @@ import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '@/src/screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
// Extracted as stable component to prevent recreation on each render
function ListSeparatorComponent(): React.JSX.Element {
return <Separator />
}
const ListSeparator = React.memo(ListSeparatorComponent)
export interface PlaylistsProps {
canEdit?: boolean | undefined
playlists: BaseItemDto[] | undefined
@@ -30,10 +37,29 @@ export default function Playlists({
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
// Memoized key extractor to prevent recreation on each render
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, [])
// Memoized render item to prevent recreation on each render
const renderItem = useCallback(
({ item: playlist }: { index: number; item: BaseItemDto }) => (
<ItemRow item={playlist} navigation={navigation} />
),
[navigation],
)
// Memoized end reached handler
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage()
}
}, [hasNextPage, fetchNextPage])
return (
<FlashList
contentInsetAdjustmentBehavior='automatic'
data={playlists}
keyExtractor={keyExtractor}
refreshControl={
<RefreshControl
refreshing={isPending || isFetchingNextPage}
@@ -41,15 +67,9 @@ export default function Playlists({
tintColor={theme.primary.val}
/>
}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ index, item: playlist }) => (
<ItemRow item={playlist} navigation={navigation} />
)}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
ItemSeparatorComponent={ListSeparator}
renderItem={renderItem}
onEndReached={handleEndReached}
removeClippedSubviews
/>
)

View File

@@ -0,0 +1,74 @@
import { MMKV } from 'react-native-mmkv'
import { StateStorage } from 'zustand/middleware'
import { storage } from './storage'
// Import app version from package.json
const APP_VERSION = '0.21.3' // This should match package.json version
const STORAGE_VERSION_KEY = 'storage-schema-version'
/**
* Storage schema versions - increment when making breaking changes to persisted state
* This allows clearing specific stores when their schema changes
*/
export const STORAGE_SCHEMA_VERSIONS: Record<string, number> = {
'player-queue-storage': 2, // Bumped to v2 for slim persistence
}
/**
* Checks if a specific store needs to be cleared due to version bump
* and clears it if necessary
*/
export function migrateStorageIfNeeded(storeName: string, storage: MMKV): void {
const versionKey = `${STORAGE_VERSION_KEY}:${storeName}`
const storedVersion = storage.getNumber(versionKey)
const currentVersion = STORAGE_SCHEMA_VERSIONS[storeName] ?? 1
if (storedVersion !== currentVersion) {
// Clear the stale storage for this specific store
storage.delete(storeName)
// Update the version
storage.set(versionKey, currentVersion)
console.log(
`[Storage] Migrated ${storeName} from v${storedVersion ?? 0} to v${currentVersion}`,
)
}
}
/**
* Creates a versioned MMKV state storage that automatically clears stale data
* when the schema version changes. This is useful for stores that persist
* data that may become incompatible between app versions.
*
* @param storeName The unique name for this store (used as the MMKV key)
* @returns A StateStorage compatible object for Zustand's persist middleware
*/
export function createVersionedMmkvStorage(storeName: string): StateStorage {
// Run migration check on storage creation
migrateStorageIfNeeded(storeName, storage)
return {
getItem: (key: string) => {
const value = storage.getString(key)
return value === undefined ? null : value
},
setItem: (key: string, value: string) => {
storage.set(key, value)
},
removeItem: (key: string) => {
storage.delete(key)
},
}
}
/**
* Clears all versioned storage entries. Useful for debugging or forcing
* a complete cache reset.
*/
export function clearAllVersionedStorage(): void {
Object.keys(STORAGE_SCHEMA_VERSIONS).forEach((storeName) => {
storage.delete(storeName)
storage.delete(`${STORAGE_VERSION_KEY}:${storeName}`)
})
console.log('[Storage] Cleared all versioned storage')
}

View File

@@ -7,6 +7,14 @@ interface PerformanceMetrics {
totalRenderTime: number
}
// No-op metrics for production builds
const EMPTY_METRICS: PerformanceMetrics = {
renderCount: 0,
lastRenderTime: 0,
averageRenderTime: 0,
totalRenderTime: 0,
}
/**
* Hook to monitor component performance and detect excessive re-renders
* @param componentName - Name of the component for logging
@@ -17,6 +25,7 @@ export function usePerformanceMonitor(
componentName: string,
threshold: number = 10,
): PerformanceMetrics {
// Skip all performance monitoring in production for zero overhead
const renderCount = useRef(0)
const renderTimes = useRef<number[]>([])
const lastRenderStart = useRef(Date.now())
@@ -56,6 +65,8 @@ export function usePerformanceMonitor(
lastRenderStart.current = Date.now()
})
if (!__DEV__) return EMPTY_METRICS
const averageRenderTime =
renderTimes.current.length > 0
? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length

View File

@@ -1,3 +1,5 @@
import { Platform } from 'react-native'
/**
* Interval in milliseconds for progress updates from the track player
* Lower value provides smoother scrubber movement but uses more resources
@@ -16,3 +18,13 @@ export const SKIP_TO_PREVIOUS_THRESHOLD: number = 4
* event will be emitted from the track player
*/
export const PROGRESS_UPDATE_EVENT_INTERVAL: number = 30
export const BUFFERS =
Platform.OS === 'android'
? {
maxCacheSize: 50 * 1024, // 50MB cache
maxBuffer: 30, // 30 seconds buffer
playBuffer: 2.5, // 2.5 seconds play buffer
backBuffer: 5, // 5 seconds back buffer
}
: {}

View File

@@ -1,12 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export type Queue =
| BaseItemDto
| 'Recently Played'
| 'Search'
| 'Favorite Tracks'
| 'Downloaded Tracks'
| 'On Repeat'
| 'Instant Mix'
| 'Library'
| 'Artist Tracks'
/**
* Describes where playback was initiated from.
* Allows known queue labels (e.g., "Recently Played") as well as dynamic strings like search terms.
*/
export type Queue = BaseItemDto | string

View File

@@ -7,7 +7,7 @@ import {
import usePlayerEngineStore from '../../../stores/player/engine'
import { PlayerEngine } from '../../../stores/player/engine'
import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast'
import { useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
export const useProgress = (UPDATE_INTERVAL: number): Progress => {
const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL)
@@ -58,16 +58,33 @@ export const usePlaybackState = (): State | undefined => {
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const [playbackState, setPlaybackState] = useState<State | undefined>(state)
useMemo(() => {
useEffect(() => {
let unsubscribe: (() => void) | undefined
if (client && isCasting) {
client.onMediaStatusUpdated((status) => {
const handler = (status: { playerState?: MediaPlayerState | null } | null) => {
if (status?.playerState) {
setPlaybackState(castToRNTPState(status.playerState))
}
})
}
const maybeUnsubscribe = client.onMediaStatusUpdated(handler)
// EmitterSubscription has a remove() method, wrap it as a function
if (
maybeUnsubscribe &&
typeof maybeUnsubscribe === 'object' &&
'remove' in maybeUnsubscribe
) {
const subscription = maybeUnsubscribe as { remove: () => void }
unsubscribe = () => subscription.remove()
}
} else {
setPlaybackState(state)
}
return () => {
if (unsubscribe) unsubscribe()
}
}, [client, isCasting, state])
return playbackState

View File

@@ -1,24 +1,13 @@
import { BaseStackParamList } from '../types'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
import { NavigatorScreenParams } from '@react-navigation/native'
type HomeStackParamList = BaseStackParamList & {
HomeScreen: undefined
RecentArtists: {
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
MostPlayedArtists: {
artistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
RecentTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
MostPlayedTracks: {
tracksInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
RecentArtists: undefined
MostPlayedArtists: undefined
RecentTracks: undefined
MostPlayedTracks: undefined
}
export default HomeStackParamList

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { useCastState, CastState } from 'react-native-google-cast'
@@ -31,12 +32,15 @@ const usePlayerEngineStore = create<playerEngineStore>()(
export const useSelectPlayerEngine = () => {
const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData)
const castState = useCastState()
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
TrackPlayer.pause() // pause the track player to avoid conflicts
return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
useEffect(() => {
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
void TrackPlayer.pause() // pause the track player to avoid conflicts
return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
}, [castState, setPlayerEngineData])
}
export default usePlayerEngineStore

View File

@@ -1,11 +1,27 @@
import { Queue } from '@/src/player/types/queue-item'
import JellifyTrack from '@/src/types/JellifyTrack'
import { mmkvStateStorage } from '../../constants/storage'
import JellifyTrack, {
PersistedJellifyTrack,
toPersistedTrack,
fromPersistedTrack,
} from '../../types/JellifyTrack'
import { createVersionedMmkvStorage } from '../../constants/versioned-storage'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import {
createJSONStorage,
devtools,
persist,
PersistStorage,
StorageValue,
} from 'zustand/middleware'
import { RepeatMode } from 'react-native-track-player'
import { useShallow } from 'zustand/react/shallow'
/**
* Maximum number of tracks to persist in storage.
* This prevents storage overflow when users have very large queues.
*/
const MAX_PERSISTED_QUEUE_SIZE = 500
type PlayerQueueStore = {
shuffled: boolean
setShuffled: (shuffled: boolean) => void
@@ -29,6 +45,81 @@ type PlayerQueueStore = {
setCurrentIndex: (index: number | undefined) => void
}
/**
* Persisted state shape - uses slimmed track types to reduce storage size
*/
type PersistedPlayerQueueState = {
shuffled: boolean
repeatMode: RepeatMode
queueRef: Queue
unShuffledQueue: PersistedJellifyTrack[]
queue: PersistedJellifyTrack[]
currentTrack: PersistedJellifyTrack | undefined
currentIndex: number | undefined
}
/**
* Custom storage that serializes/deserializes tracks to their slim form
* This prevents the "RangeError: String length exceeds limit" error
*/
const queueStorage: PersistStorage<PlayerQueueStore> = {
getItem: (name) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
const str = storage.getItem(name) as string | null
if (!str) return null
try {
const parsed = JSON.parse(str) as StorageValue<PersistedPlayerQueueState>
const state = parsed.state
// Hydrate persisted tracks back to full JellifyTrack format
return {
...parsed,
state: {
...state,
queue: (state.queue ?? []).map(fromPersistedTrack),
unShuffledQueue: (state.unShuffledQueue ?? []).map(fromPersistedTrack),
currentTrack: state.currentTrack
? fromPersistedTrack(state.currentTrack)
: undefined,
} as unknown as PlayerQueueStore,
}
} catch (e) {
console.error('[Queue Storage] Failed to parse stored queue:', e)
return null
}
},
setItem: (name, value) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
const state = value.state
// Slim down tracks before persisting to prevent storage overflow
const persistedState: PersistedPlayerQueueState = {
shuffled: state.shuffled,
repeatMode: state.repeatMode,
queueRef: state.queueRef,
// Limit queue size to prevent storage overflow
queue: (state.queue ?? []).slice(0, MAX_PERSISTED_QUEUE_SIZE).map(toPersistedTrack),
unShuffledQueue: (state.unShuffledQueue ?? [])
.slice(0, MAX_PERSISTED_QUEUE_SIZE)
.map(toPersistedTrack),
currentTrack: state.currentTrack ? toPersistedTrack(state.currentTrack) : undefined,
currentIndex: state.currentIndex,
}
const toStore: StorageValue<PersistedPlayerQueueState> = {
...value,
state: persistedState,
}
storage.setItem(name, JSON.stringify(toStore))
},
removeItem: (name) => {
const storage = createVersionedMmkvStorage('player-queue-storage')
storage.removeItem(name)
},
}
export const usePlayerQueueStore = create<PlayerQueueStore>()(
devtools(
persist(
@@ -71,7 +162,7 @@ export const usePlayerQueueStore = create<PlayerQueueStore>()(
}),
{
name: 'player-queue-storage',
storage: createJSONStorage(() => mmkvStateStorage),
storage: queueStorage,
},
),
),

View File

@@ -41,4 +41,47 @@ interface JellifyTrack extends Track {
QueuingType?: QueuingType | undefined
}
/**
* A slimmed-down version of JellifyTrack for persistence.
* Excludes large fields like mediaSourceInfo and transient data
* to prevent storage overflow (RangeError: String length exceeds limit).
*
* When hydrating from storage, these fields will need to be rebuilt
* from the API or left undefined until playback is requested.
*/
export type PersistedJellifyTrack = Omit<JellifyTrack, 'mediaSourceInfo' | 'headers'> & {
/** Store only essential media source fields for persistence */
mediaSourceInfo?: Pick<MediaSourceInfo, 'Id' | 'Container' | 'Bitrate'> | undefined
}
/**
* Converts a full JellifyTrack to a PersistedJellifyTrack for storage
*/
export function toPersistedTrack(track: JellifyTrack): PersistedJellifyTrack {
const { mediaSourceInfo, headers, ...rest } = track as JellifyTrack & { headers?: unknown }
return {
...rest,
// Only persist essential media source fields
mediaSourceInfo: mediaSourceInfo
? {
Id: mediaSourceInfo.Id,
Container: mediaSourceInfo.Container,
Bitrate: mediaSourceInfo.Bitrate,
}
: undefined,
}
}
/**
* Converts a PersistedJellifyTrack back to a JellifyTrack
* Note: Some fields like full mediaSourceInfo and headers will be undefined
* and need to be rebuilt when playback is requested
*/
export function fromPersistedTrack(persisted: PersistedJellifyTrack): JellifyTrack {
// Cast is safe because PersistedJellifyTrack has all required fields
// except the omitted ones (mediaSourceInfo, headers) which are optional in JellifyTrack
return persisted as unknown as JellifyTrack
}
export default JellifyTrack