mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-19 04:19:44 -06:00
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 commit1c63b748b6. * 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 commitf9e0e82e57. * Reapply "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads" This reverts commit6710d3404c. * 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:
80
App.tsx
80
App.tsx
@@ -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)
|
||||
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
|
||||
74
src/constants/versioned-storage.ts
Normal file
74
src/constants/versioned-storage.ts
Normal 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')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/screens/Home/types.d.ts
vendored
19
src/screens/Home/types.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user