Slimify JellifyTrack type (#644)

* Slimify JellifyTrack type

Reduces the overall size of a JellifyTrack object in memory with the goal of lowing the overhead on serialization both to native and to zustand

* simplify addToQueue

fix issue where using the swipeable row would cause a crash
This commit is contained in:
Violet Caulfield
2025-11-05 18:50:47 -06:00
committed by GitHub
parent eb14e9a17e
commit 783a5144a0
15 changed files with 78 additions and 107 deletions
-18
View File
@@ -2,8 +2,6 @@ import TrackPlayer from 'react-native-track-player'
import { playLaterInQueue } from '../../src/providers/Player/functions/queue'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '@/src/types/JellifyDownload'
import { TrackType } from 'react-native-track-player'
describe('Add to Queue - playLaterInQueue', () => {
it('adds track to the end of the queue', async () => {
@@ -20,28 +18,12 @@ describe('Add to Queue - playLaterInQueue', () => {
const api: Partial<Api> = { basePath: '' }
const deviceProfile: Partial<DeviceProfile> = { Name: 'test' }
const downloaded: JellifyDownload = {
// Minimal viable JellifyTrack fields
url: '/downloads/t1.mp3',
duration: 180,
type: TrackType.Default,
item: track,
sessionId: null,
sourceType: 'download',
artwork: '/downloads/t1.jpg',
// JellifyDownload fields
savedAt: new Date().toISOString(),
isAutoDownloaded: false,
path: '/downloads/t1.mp3',
}
await playLaterInQueue({
api: api as Api,
deviceProfile: deviceProfile as DeviceProfile,
networkStatus: null,
tracks: [track],
queuingType: undefined,
downloadedTracks: [downloaded],
})
expect(TrackPlayer.add).toHaveBeenCalledTimes(1)
+1 -1
View File
@@ -40,7 +40,7 @@ export const useDownloadAudioItem: () => [
)
return Promise.resolve(false)
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile)
const track = mapDtoToTrack(api, item, deviceProfile)
return saveAudio(track, setDownloadProgress, autoCached)
},
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function saveAudioItem(
if (downloadedTracks?.filter((download) => download.item.Id === item.Id).length ?? 0 > 0)
return Promise.resolve(false)
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile)
const track = mapDtoToTrack(api, item, deviceProfile)
// TODO: fix download progresses
return saveAudio(track, () => {}, autoCached)
+2 -2
View File
@@ -59,8 +59,8 @@ const useTracks: () => [
return (downloadedTracks ?? [])
.map(({ item }) => item)
.sort((a, b) => {
if ((a.Name ?? '') < (b.Name ?? '')) return -1
else if ((a.Name ?? '') === (b.Name ?? '')) return 0
if ((a.SortName ?? '') < (b.SortName ?? '')) return -1
else if ((a.SortName ?? '') === (b.SortName ?? '')) return 0
else return 1
})
.filter((track) => {
+1 -3
View File
@@ -46,9 +46,7 @@ export function Album(): React.JSX.Element {
const downloadAlbum = (item: BaseItemDto[]) => {
if (!api) return
const jellifyTracks = item.map((item) =>
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
)
const jellifyTracks = item.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
addToDownloadQueue(jellifyTracks)
}
+4 -4
View File
@@ -193,7 +193,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
const deviceProfile = useStreamingDeviceProfile()
const { mutate: addToQueue } = useAddToQueue()
const addToQueue = useAddToQueue()
const mutation: AddToQueueMutation = {
api,
@@ -210,8 +210,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
flex={1}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => {
addToQueue({
onPress={async () => {
await addToQueue({
...mutation,
queuingType: QueuingType.PlayingNext,
})
@@ -258,7 +258,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
const downloadItems = useCallback(() => {
if (!api) return
const tracks = items.map((item) => mapDtoToTrack(api, item, [], deviceProfile))
const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile))
addToDownloadQueue(tracks)
}, [addToDownloadQueue, items])
@@ -8,7 +8,6 @@ import Animated, {
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
import Icon from './icon'
import { Text } from '../helpers/text'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
@@ -18,6 +17,7 @@ import {
registerSwipeableRow,
unregisterSwipeableRow,
} from './swipeable-row-registry'
import { runOnJS, runOnUISync, scheduleOnRN, scheduleOnUI } from 'react-native-worklets'
export type SwipeAction = {
label: string
@@ -55,8 +55,8 @@ export default function SwipeableRow({
}: Props) {
const triggerHaptic = useHapticFeedback()
const tx = useSharedValue(0)
const [menuOpen, setMenuOpen] = useState(false)
const [dragging, setDragging] = useState(false)
const menuOpen = useSharedValue(false)
const dragging = useSharedValue(false)
const idRef = useRef<string | undefined>(undefined)
const menuOpenRef = useRef(false)
const defaultMaxLeft = 120
@@ -86,8 +86,9 @@ export default function SwipeableRow({
}
const syncClosedState = useCallback(() => {
'worklet'
menuOpenRef.current = false
setMenuOpen(false)
menuOpen.set(false)
notifySwipeableRowClosed(idRef.current!)
}, [])
@@ -98,7 +99,7 @@ export default function SwipeableRow({
const openMenu = useCallback(() => {
menuOpenRef.current = true
setMenuOpen(true)
menuOpen.set(true)
notifySwipeableRowOpened(idRef.current!)
}, [])
@@ -110,10 +111,11 @@ export default function SwipeableRow({
}, [close])
useEffect(() => {
menuOpenRef.current = menuOpen
menuOpenRef.current = menuOpen.value
}, [menuOpen])
const schedule = (fn?: () => void) => {
'worklet'
if (!fn) return
// Defer JS work so the UI bounce plays smoothly
setTimeout(() => fn(), 0)
@@ -121,11 +123,12 @@ export default function SwipeableRow({
const gesture = useMemo(() => {
return Gesture.Pan()
.runOnJS(true)
.activeOffsetX([-10, 10])
.failOffsetY([-10, 10])
.onBegin(() => {
if (disabled) return
runOnJS(setDragging)(true)
dragging.set(true)
})
.onUpdate((e) => {
if (disabled) return
@@ -137,21 +140,21 @@ export default function SwipeableRow({
if (tx.value > threshold) {
// Right swipe: show left quick actions if provided; otherwise trigger leftAction
if (leftActions && leftActions.length > 0) {
runOnJS(triggerHaptic)('impactLight')
triggerHaptic('impactLight')
// Snap open to expose quick actions, do not auto-trigger
tx.value = withTiming(maxLeft, {
duration: 140,
easing: Easing.out(Easing.cubic),
})
runOnJS(openMenu)()
openMenu()
return
} else if (leftAction) {
runOnJS(triggerHaptic)('impactLight')
triggerHaptic('impactLight')
tx.value = withTiming(
maxLeft,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
runOnJS(schedule)(leftAction.onTrigger)
scheduleOnRN(leftAction.onTrigger)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
@@ -164,21 +167,21 @@ export default function SwipeableRow({
// Left swipe (quick actions)
if (tx.value < -Math.min(threshold, Math.abs(maxRight) / 2)) {
if (rightActions && rightActions.length > 0) {
runOnJS(triggerHaptic)('impactLight')
triggerHaptic('impactLight')
// Snap open to expose quick actions, do not auto-trigger
tx.value = withTiming(maxRight, {
duration: 140,
easing: Easing.out(Easing.cubic),
})
runOnJS(openMenu)()
openMenu()
return
} else if (rightAction) {
runOnJS(triggerHaptic)('impactLight')
triggerHaptic('impactLight')
tx.value = withTiming(
maxRight,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
runOnJS(schedule)(rightAction.onTrigger)
scheduleOnRN(rightAction.onTrigger)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
@@ -189,11 +192,11 @@ export default function SwipeableRow({
}
}
tx.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.cubic) })
runOnJS(syncClosedState)()
syncClosedState()
})
.onFinalize(() => {
if (disabled) return
runOnJS(setDragging)(false)
dragging.set(false)
})
}, [
disabled,
@@ -54,7 +54,7 @@ export default function ItemRow({
const deviceProfile = useStreamingDeviceProfile()
const loadNewQueue = useLoadNewQueue()
const { mutate: addToQueue } = useAddToQueue()
const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
@@ -119,8 +119,8 @@ export default function ItemRow({
const swipeHandlers = useCallback(
() => ({
addToQueue: () =>
addToQueue({
addToQueue: async () =>
await addToQueue({
api,
deviceProfile,
networkStatus,
+8 -4
View File
@@ -69,7 +69,7 @@ export default function Track({
const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const { mutate: addToQueue } = useAddToQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
@@ -172,19 +172,23 @@ export default function Track({
const swipeHandlers = useMemo(
() => ({
addToQueue: () =>
addToQueue({
addToQueue: async () => {
console.info('Running add to queue swipe action')
await addToQueue({
api,
deviceProfile,
networkStatus,
tracks: [track],
queuingType: QueuingType.DirectlyQueued,
}),
})
},
toggleFavorite: () => {
console.info(`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`)
if (isFavoriteTrack) removeFavorite({ item: track })
else addFavorite({ item: track })
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
},
}),
@@ -142,7 +142,7 @@ function PlaylistHeaderControls({
const downloadPlaylist = () => {
if (!api) return
const jellifyTracks = playlistTracks.map((item) =>
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
mapDtoToTrack(api, item, downloadingDeviceProfile),
)
addToDownloadQueue(jellifyTracks)
}
+5 -34
View File
@@ -43,13 +43,7 @@ export async function loadQueue({
// Convert to JellifyTracks first
let queue = availableAudioItems.map((item) =>
mapDtoToTrack(
api!,
item,
downloadedTracks ?? [],
deviceProfile!,
QueuingType.FromSelection,
),
mapDtoToTrack(api!, item, deviceProfile!, QueuingType.FromSelection),
)
// Store the original unshuffled queue
@@ -93,9 +87,6 @@ export async function loadQueue({
}
}
type PlayNextOperation = AddToQueueMutation & {
downloadedTracks: JellifyDownload[] | undefined
}
/**
* Inserts a track at the next index in the queue
*
@@ -103,14 +94,9 @@ type PlayNextOperation = AddToQueueMutation & {
*
* @param item The track to play next
*/
export const playNextInQueue = async ({
api,
downloadedTracks,
deviceProfile,
tracks,
}: PlayNextOperation) => {
export const playNextInQueue = async ({ api, deviceProfile, tracks }: AddToQueueMutation) => {
const tracksToPlayNext = tracks.map((item) =>
mapDtoToTrack(api!, item, downloadedTracks ?? [], deviceProfile!, QueuingType.PlayingNext),
mapDtoToTrack(api!, item, deviceProfile!, QueuingType.PlayingNext),
)
const currentIndex = await TrackPlayer.getActiveTrackIndex()
@@ -146,26 +132,11 @@ export const playNextInQueue = async ({
])
}
type QueueOperation = AddToQueueMutation & {
downloadedTracks: JellifyDownload[] | undefined
}
export const playLaterInQueue = async ({
api,
deviceProfile,
downloadedTracks,
tracks,
}: QueueOperation) => {
export const playLaterInQueue = async ({ api, deviceProfile, tracks }: AddToQueueMutation) => {
console.debug(`Adding ${tracks.length} to queue`)
const newTracks = tracks.map((item) =>
mapDtoToTrack(
api!,
item,
downloadedTracks ?? [],
deviceProfile!,
QueuingType.DirectlyQueued,
),
mapDtoToTrack(api!, item, deviceProfile!, QueuingType.DirectlyQueued),
)
// Then update RNTP
+16 -16
View File
@@ -153,41 +153,41 @@ const useSeekBy = () => {
export const useAddToQueue = () => {
const trigger = useHapticFeedback()
return useMutation({
mutationFn: (variables: AddToQueueMutation) =>
variables.queuingType === QueuingType.PlayingNext
? playNextInQueue({ ...variables, downloadedTracks: getAudioCache() })
: playLaterInQueue({ ...variables, downloadedTracks: getAudioCache() }),
onSuccess: (_: void, { queuingType }: AddToQueueMutation) => {
return useCallback(async (variables: AddToQueueMutation) => {
try {
if (variables.queuingType === QueuingType.PlayingNext) playNextInQueue({ ...variables })
else playLaterInQueue({ ...variables })
trigger('notificationSuccess')
console.debug(
`${queuingType === QueuingType.PlayingNext ? 'Played next' : 'Added to queue'}`,
`${variables.queuingType === QueuingType.PlayingNext ? 'Played next' : 'Added to queue'}`,
)
Toast.show({
text1: queuingType === QueuingType.PlayingNext ? 'Playing next' : 'Added to queue',
text1:
variables.queuingType === QueuingType.PlayingNext
? 'Playing next'
: 'Added to queue',
type: 'success',
})
},
onError: async (error: Error, { queuingType }: AddToQueueMutation) => {
} catch (error) {
trigger('notificationError')
console.error(
`Failed to ${queuingType === QueuingType.PlayingNext ? 'play next' : 'add to queue'}`,
`Failed to ${variables.queuingType === QueuingType.PlayingNext ? 'play next' : 'add to queue'}`,
error,
)
Toast.show({
text1:
queuingType === QueuingType.PlayingNext
variables.queuingType === QueuingType.PlayingNext
? 'Failed to play next'
: 'Failed to add to queue',
type: 'error',
})
},
onSettled: async () => {
} finally {
const newQueue = await TrackPlayer.getQueue()
usePlayerQueueStore.getState().setQueue(newQueue as JellifyTrack[])
},
})
}
}, [])
}
export const useLoadNewQueue = () => {
+12 -1
View File
@@ -4,6 +4,17 @@ import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client
export type SourceType = 'stream' | 'download'
export type BaseItemDtoSlimified = Pick<
BaseItemDto,
| 'Id'
| 'SortName'
| 'AlbumId'
| 'ArtistItems'
| 'ImageBlurHashes'
| 'NormalizationGain'
| 'RunTimeTicks'
>
interface JellifyTrack extends Track {
title?: string | undefined
album?: string | undefined
@@ -17,7 +28,7 @@ interface JellifyTrack extends Track {
isLiveStream?: boolean | undefined
sourceType: SourceType
item: BaseItemDto
item: BaseItemDtoSlimified
sessionId: string | null | undefined
mediaSourceInfo?: MediaSourceInfo
+2 -1
View File
@@ -1,7 +1,8 @@
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDtoSlimified } from '../types/JellifyTrack'
export function getBlurhashFromDto(
{ ImageBlurHashes }: BaseItemDto,
{ ImageBlurHashes }: BaseItemDto | BaseItemDtoSlimified,
type: ImageType = ImageType.Primary,
) {
if (!ImageBlurHashes || !ImageBlurHashes[type]) return ''
+2 -1
View File
@@ -21,6 +21,7 @@ import { convertRunTimeTicksToSeconds } from './runtimeticks'
import { DownloadQuality } from '../stores/settings/usage'
import MediaInfoQueryKey from '../api/queries/media/keys'
import StreamingQuality from '../enums/audio-quality'
import { getAudioCache } from '../api/mutations/download/offlineModeUtils'
/**
* Gets quality-specific parameters for transcoding
@@ -77,10 +78,10 @@ type TrackMediaInfo = Pick<
export function mapDtoToTrack(
api: Api,
item: BaseItemDto,
downloadedTracks: JellifyDownload[],
deviceProfile: DeviceProfile,
queuingType?: QueuingType,
): JellifyTrack {
const downloadedTracks = getAudioCache()
const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id)
const mediaInfo = queryClient.getQueryData(