From 0851e5ab6e9fdc121bb44e750ae1d10fe67a654a Mon Sep 17 00:00:00 2001
From: Violet Caulfield
Date: Sat, 29 Nov 2025 19:43:31 -0600
Subject: [PATCH 01/16] align player timecodes *always*
---
src/components/Player/components/scrubber.tsx | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx
index 22859ceb..c34ded11 100644
--- a/src/components/Player/components/scrubber.tsx
+++ b/src/components/Player/components/scrubber.tsx
@@ -157,16 +157,11 @@ export default function Scrubber(): React.JSX.Element {
/>
-
+
{currentSeconds}
-
+
{nowPlaying?.mediaSourceInfo && displayAudioQualityBadge ? (
-
+
{totalSeconds}
From fee4ad3d943cf8b54d72ddfe658d92a8125893d9 Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Sat, 29 Nov 2025 20:55:16 -0600
Subject: [PATCH 02/16] hide miniplayer if full screen player is active (#735)
reduce cpu overhead by hiding the miniplayer if it's not being displayed
---
src/components/Global/components/icon.tsx | 8 ++++++++
src/components/Player/mini-player.tsx | 6 +-----
src/hooks/use-mini-player.ts | 10 ++++++++++
src/screens/Tabs/tab-bar.tsx | 15 +++++----------
4 files changed, 24 insertions(+), 15 deletions(-)
create mode 100644 src/hooks/use-mini-player.ts
diff --git a/src/components/Global/components/icon.tsx b/src/components/Global/components/icon.tsx
index 00c85fc3..1bbe6417 100644
--- a/src/components/Global/components/icon.tsx
+++ b/src/components/Global/components/icon.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import {
+ AnimationKeys,
ColorTokens,
getToken,
getTokens,
@@ -11,6 +12,7 @@ import {
YStack,
} from 'tamagui'
import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons'
+import { on } from 'events'
const smallSize = 28
@@ -42,8 +44,14 @@ export default function Icon({
const theme = useTheme()
const size = large ? largeSize : small ? smallSize : regularSize
+ const animation = onPress || onPressIn ? 'quick' : undefined
+
+ const pressStyle = animation ? { opacity: 0.6 } : undefined
+
return (
-
+
- {showMiniPlayer && (
- /* Hide miniplayer if the queue is empty */
-
- )}
+ {isMiniPlayerActive && isFocused && }
From 7bb6e727ceacc918d3fa6ce7a7c1ef2531ecbbb9 Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Sat, 29 Nov 2025 22:40:03 -0600
Subject: [PATCH 03/16] minify miniplayer (#736)
* minify miniplayer to save some vertical screen real estate
---
src/components/Player/components/buttons.tsx | 27 ++++++++-
src/components/Player/mini-player.tsx | 64 +++++++-------------
2 files changed, 47 insertions(+), 44 deletions(-)
diff --git a/src/components/Player/components/buttons.tsx b/src/components/Player/components/buttons.tsx
index ba48ffee..ee58274e 100644
--- a/src/components/Player/components/buttons.tsx
+++ b/src/components/Player/components/buttons.tsx
@@ -5,6 +5,7 @@ import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
import React, { useMemo } from 'react'
+import Icon from '../../Global/components/icon'
function PlayPauseButtonComponent({
size,
@@ -17,7 +18,7 @@ function PlayPauseButtonComponent({
const state = usePlaybackState()
- const largeIcon = useMemo(() => isUndefined(size) || size >= 20, [size])
+ const largeIcon = useMemo(() => isUndefined(size) || size >= 24, [size])
const button = useMemo(() => {
switch (state) {
@@ -67,4 +68,28 @@ function PlayPauseButtonComponent({
const PlayPauseButton = React.memo(PlayPauseButtonComponent)
+export function PlayPauseIcon(): React.JSX.Element {
+ const togglePlayback = useTogglePlayback()
+ const state = usePlaybackState()
+
+ const button = useMemo(() => {
+ switch (state) {
+ case State.Playing: {
+ return
+ }
+
+ case State.Buffering:
+ case State.Loading: {
+ return
+ }
+
+ default: {
+ return
+ }
+ }
+ }, [state, togglePlayback])
+
+ return button
+}
+
export default PlayPauseButton
diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx
index 661beba6..25b44471 100644
--- a/src/components/Player/mini-player.tsx
+++ b/src/components/Player/mini-player.tsx
@@ -1,11 +1,10 @@
import React, { useMemo, useCallback } from 'react'
-import { getToken, Progress, XStack, YStack } from 'tamagui'
+import { Progress, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
-import PlayPauseButton from './components/buttons'
+import { PlayPauseIcon } from './components/buttons'
import { TextTickerConfig } from './component.config'
-import { RunTimeSeconds } from '../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../player/config'
import { Progress as TrackPlayerProgress } from 'react-native-track-player'
import { useProgress } from '../../providers/Player/hooks/queries'
@@ -23,7 +22,7 @@ import { runOnJS } from 'react-native-worklets'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
-import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations'
+import { usePrevious, useSkip, useTogglePlayback } from '../../providers/Player/hooks/mutations'
import { useCurrentTrack } from '../../stores/player/queue'
export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
@@ -84,19 +83,28 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
[navigation],
)
+ const pressStyle = useMemo(
+ () => ({
+ opacity: 0.6,
+ }),
+ [],
+ )
+
return (
-
+
-
+
@@ -114,8 +122,6 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
marginLeft={'$2'}
flex={6}
>
-
-
-
+
@@ -153,43 +159,15 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
)
})
-function MiniPlayerRuntime({ duration }: { duration: number }): React.JSX.Element {
- return (
-
-
-
-
-
-
-
- /
-
-
-
-
- {Math.max(0, Math.round(duration))}
-
-
-
-
- )
-}
-
-function MiniPlayerRuntimePosition(): React.JSX.Element {
- const { position } = useProgress(UPDATE_INTERVAL)
-
- return {Math.max(0, Math.round(position))}
-}
-
function MiniPlayerProgress(): React.JSX.Element {
const progress = useProgress(UPDATE_INTERVAL)
return (
From ac7df341e05aba7d41d9b2369e46652992d3a33d Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Mon, 1 Dec 2025 09:36:27 -0600
Subject: [PATCH 04/16] Add Ko-fi username to FUNDING.yml
---
.github/FUNDING.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index c11d42ab..929abd4f 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -3,7 +3,7 @@
github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: anultravioletaurora # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
-ko_fi: # Replace with a single Ko-fi username
+ko_fi: jellify # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
From 4b5faacd280f07bb959fd906036883e0493d2b6f Mon Sep 17 00:00:00 2001
From: Ritesh Shukla
Date: Tue, 2 Dec 2025 00:54:05 +0530
Subject: [PATCH 05/16] React Compiler (#737)
* chore: r2
* compiler
* compiler
---
babel.config.js | 6 +-
bun.lock | 4 +-
package.json | 1 +
src/components/Context/index.tsx | 43 +++----
src/components/Player/components/buttons.tsx | 12 +-
src/components/Player/components/header.tsx | 21 ++--
src/components/Player/components/scrubber.tsx | 114 +++++++----------
src/components/Player/mini-player.tsx | 91 ++++++--------
src/components/Search/index.tsx | 10 +-
src/components/Search/suggestions.tsx | 5 +-
src/components/Tracks/component.tsx | 77 ++++++------
src/hooks/use-item-context.ts | 21 ++--
src/providers/Artist/index.tsx | 41 +++---
src/providers/Network/index.tsx | 14 +--
src/providers/Player/index.tsx | 108 ++++++++--------
src/providers/Storage/index.tsx | 119 +++++++-----------
16 files changed, 292 insertions(+), 395 deletions(-)
diff --git a/babel.config.js b/babel.config.js
index 8b0276c4..af461387 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,4 +1,8 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
- plugins: ['react-native-worklets/plugin', 'react-native-worklets-core/plugin'],
+ plugins: [
+ 'babel-plugin-react-compiler',
+ 'react-native-worklets/plugin',
+ 'react-native-worklets-core/plugin',
+ ],
}
diff --git a/bun.lock b/bun.lock
index 6a4a7697..01ec36f7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
@@ -83,6 +82,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
+ "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
@@ -1033,6 +1033,8 @@
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="],
+ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
+
"babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.32.0", "", { "dependencies": { "hermes-parser": "0.32.0" } }, "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg=="],
"babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="],
diff --git a/package.json b/package.json
index 83538529..db800bac 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
+ "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx
index 3410d1c9..63de2abe 100644
--- a/src/components/Context/index.tsx
+++ b/src/components/Context/index.tsx
@@ -15,7 +15,7 @@ import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { AddToQueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
-import { useCallback, useEffect, useMemo } from 'react'
+import { useEffect } from 'react'
import navigationRef from '../../../navigation'
import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation'
import { getItemName } from '../../utils/text'
@@ -98,12 +98,12 @@ export default function ItemContext({
: []
: []
- const itemTracks = useMemo(() => {
+ const itemTracks = (() => {
if (isTrack) return [item]
else if (isAlbum && discs) return discs.flatMap((data) => data.data)
else if (isPlaylist && tracks) return tracks
else return []
- }, [isTrack, isAlbum, discs, isPlaylist, tracks])
+ })()
useEffect(() => trigger('impactLight'), [item?.Id])
@@ -251,26 +251,20 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
- const downloadItems = useCallback(() => {
+ const downloadItems = () => {
if (!api) return
const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile))
addToDownloadQueue(tracks)
- }, [addToDownloadQueue, items])
+ }
- const removeDownloads = useCallback(
- () => useRemoveDownload(items.map(({ Id }) => Id)),
- [useRemoveDownload, items],
- )
+ const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id))
- const isPending = useMemo(
- () =>
- items.filter(
- (item) =>
- pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0,
- ).length > 0,
- [items, pendingDownloads],
- )
+ const isPending =
+ items.filter(
+ (item) =>
+ pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0,
+ ).length > 0
return isPending ? (
{
+ const goToAlbum = () => {
if (stackNavigation && album) stackNavigation.navigate('Album', { album })
else goToAlbumFromContextSheet(album)
- }, [album, stackNavigation, navigationRef])
+ }
return (
{
- if (stackNavigation) stackNavigation.navigate('Artist', { artist })
- else goToArtistFromContextSheet(artist)
- },
- [stackNavigation, navigationRef],
- )
+ const goToArtist = (artist: BaseItemDto) => {
+ if (stackNavigation) stackNavigation.navigate('Artist', { artist })
+ else goToArtistFromContextSheet(artist)
+ }
return artist ? (
isUndefined(size) || size >= 24, [size])
+ const largeIcon = isUndefined(size) || size >= 24
- const button = useMemo(() => {
+ const button = (() => {
switch (state) {
case State.Playing: {
return (
@@ -57,7 +57,7 @@ function PlayPauseButtonComponent({
)
}
}
- }, [state, size, largeIcon, togglePlayback])
+ })()
return (
@@ -72,7 +72,7 @@ export function PlayPauseIcon(): React.JSX.Element {
const togglePlayback = useTogglePlayback()
const state = usePlaybackState()
- const button = useMemo(() => {
+ const button = (() => {
switch (state) {
case State.Playing: {
return
@@ -87,7 +87,7 @@ export function PlayPauseIcon(): React.JSX.Element {
return
}
}
- }, [state, togglePlayback])
+ })()
return button
}
diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx
index 91d69903..d5a87d34 100644
--- a/src/components/Player/components/header.tsx
+++ b/src/components/Player/components/header.tsx
@@ -1,6 +1,6 @@
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
-import React, { useCallback, useMemo } from 'react'
+import React from 'react'
import ItemImage from '../../Global/components/image'
import Animated, {
useAnimatedStyle,
@@ -20,16 +20,11 @@ export default function PlayerHeader(): React.JSX.Element {
const theme = useTheme()
- // If the Queue is a BaseItemDto, display the name of it
- const playingFrom = useMemo(
- () =>
- !queueRef
- ? 'Untitled'
- : typeof queueRef === 'object'
- ? (queueRef.Name ?? 'Untitled')
- : queueRef,
- [queueRef],
- )
+ const playingFrom = !queueRef
+ ? 'Untitled'
+ : typeof queueRef === 'object'
+ ? (queueRef.Name ?? 'Untitled')
+ : queueRef
return (
@@ -75,10 +70,10 @@ function PlayerArtwork(): React.JSX.Element {
opacity: withTiming(nowPlaying ? 1 : 0),
}))
- const handleLayout = useCallback((event: LayoutChangeEvent) => {
+ const handleLayout = (event: LayoutChangeEvent) => {
artworkMaxHeight.set(event.nativeEvent.layout.height)
artworkMaxWidth.set(event.nativeEvent.layout.height)
- }, [])
+ }
return (
{
- return Math.round(duration * ProgressMultiplier)
- }, [duration])
+ const maxDuration = Math.round(duration * ProgressMultiplier)
- const calculatedPosition = useMemo(() => {
- return Math.round(position! * ProgressMultiplier)
- }, [position])
+ const calculatedPosition = Math.round(position! * ProgressMultiplier)
// Optimized position update logic with throttling
useEffect(() => {
@@ -77,70 +72,57 @@ export default function Scrubber(): React.JSX.Element {
}
}, [nowPlaying?.id])
- // Optimized seek handler with debouncing
- const handleSeek = useCallback(
- async (position: number) => {
- const seekTime = Math.max(0, position / ProgressMultiplier)
- lastSeekTimeRef.current = Date.now()
+ const handleSeek = async (position: number) => {
+ const seekTime = Math.max(0, position / ProgressMultiplier)
+ lastSeekTimeRef.current = Date.now()
- try {
- await seekTo(seekTime)
- } catch (error) {
- console.warn('handleSeek callback failed', error)
+ try {
+ await seekTo(seekTime)
+ } catch (error) {
+ console.warn('handleSeek callback failed', error)
+ isUserInteractingRef.current = false
+ setDisplayPosition(calculatedPosition)
+ } finally {
+ // Small delay to let the seek settle before allowing updates
+ setTimeout(() => {
isUserInteractingRef.current = false
- setDisplayPosition(calculatedPosition)
- } finally {
- // Small delay to let the seek settle before allowing updates
- setTimeout(() => {
- isUserInteractingRef.current = false
- }, 100)
- }
+ }, 100)
+ }
+ }
+
+ const currentSeconds = Math.max(0, Math.round(displayPosition / ProgressMultiplier))
+
+ const totalSeconds = Math.round(duration)
+
+ const sliderProps = {
+ maxWidth: width / 1.1,
+ onSlideStart: (event: unknown, value: number) => {
+ isUserInteractingRef.current = true
+ trigger('impactLight')
+
+ // Immediately update position for responsive UI
+ const clampedValue = Math.max(0, Math.min(value, maxDuration))
+ setDisplayPosition(clampedValue)
},
- [seekTo, setDisplayPosition],
- )
+ onSlideMove: (event: unknown, value: number) => {
+ // Throttled haptic feedback for better performance
+ trigger('clockTick')
- // Memoize time calculations to prevent unnecessary re-renders
- const currentSeconds = useMemo(() => {
- return Math.max(0, Math.round(displayPosition / ProgressMultiplier))
- }, [displayPosition])
+ // Update position with proper clamping
+ const clampedValue = Math.max(0, Math.min(value, maxDuration))
+ setDisplayPosition(clampedValue)
+ },
+ onSlideEnd: async (event: unknown, value: number) => {
+ trigger('notificationSuccess')
- const totalSeconds = useMemo(() => {
- return Math.round(duration)
- }, [duration])
+ // Clamp final value and update display
+ const clampedValue = Math.max(0, Math.min(value, maxDuration))
+ setDisplayPosition(clampedValue)
- // Memoize slider props to prevent recreation
- const sliderProps = useMemo(
- () => ({
- maxWidth: width / 1.1,
- onSlideStart: (event: unknown, value: number) => {
- isUserInteractingRef.current = true
- trigger('impactLight')
-
- // Immediately update position for responsive UI
- const clampedValue = Math.max(0, Math.min(value, maxDuration))
- setDisplayPosition(clampedValue)
- },
- onSlideMove: (event: unknown, value: number) => {
- // Throttled haptic feedback for better performance
- trigger('clockTick')
-
- // Update position with proper clamping
- const clampedValue = Math.max(0, Math.min(value, maxDuration))
- setDisplayPosition(clampedValue)
- },
- onSlideEnd: async (event: unknown, value: number) => {
- trigger('notificationSuccess')
-
- // Clamp final value and update display
- const clampedValue = Math.max(0, Math.min(value, maxDuration))
- setDisplayPosition(clampedValue)
-
- // Perform the seek operation
- await handleSeek(clampedValue)
- },
- }),
- [maxDuration, handleSeek, calculatedPosition, width],
- )
+ // Perform the seek operation
+ await handleSeek(clampedValue)
+ },
+ }
return (
diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx
index 25b44471..44b3e931 100644
--- a/src/components/Player/mini-player.tsx
+++ b/src/components/Player/mini-player.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useCallback } from 'react'
+import React from 'react'
import { Progress, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
@@ -35,60 +35,47 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
- const handleSwipe = useCallback(
- (direction: string) => {
- if (direction === 'Swiped Left') {
- // Inverted: Swipe left -> next
- skip(undefined)
- } else if (direction === 'Swiped Right') {
- // Inverted: Swipe right -> previous
- previous()
- } else if (direction === 'Swiped Up') {
- // Navigate to the big player
- navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
+ const handleSwipe = (direction: string) => {
+ if (direction === 'Swiped Left') {
+ // Inverted: Swipe left -> next
+ skip(undefined)
+ } else if (direction === 'Swiped Right') {
+ // Inverted: Swipe right -> previous
+ previous()
+ } else if (direction === 'Swiped Up') {
+ // Navigate to the big player
+ navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
+ }
+ }
+
+ const gesture = Gesture.Pan()
+ .onUpdate((event) => {
+ translateX.value = event.translationX
+ translateY.value = event.translationY
+ })
+ .onEnd((event) => {
+ const threshold = 100
+
+ if (event.translationX > threshold) {
+ runOnJS(handleSwipe)('Swiped Right')
+ translateX.value = withSpring(200)
+ } else if (event.translationX < -threshold) {
+ runOnJS(handleSwipe)('Swiped Left')
+ translateX.value = withSpring(-200)
+ } else if (event.translationY < -threshold) {
+ runOnJS(handleSwipe)('Swiped Up')
+ translateY.value = withSpring(-200)
+ } else {
+ translateX.value = withSpring(0)
+ translateY.value = withSpring(0)
}
- },
- [skip, previous, navigation],
- )
+ })
- const gesture = useMemo(
- () =>
- Gesture.Pan()
- .onUpdate((event) => {
- translateX.value = event.translationX
- translateY.value = event.translationY
- })
- .onEnd((event) => {
- const threshold = 100
+ const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
- if (event.translationX > threshold) {
- runOnJS(handleSwipe)('Swiped Right')
- translateX.value = withSpring(200)
- } else if (event.translationX < -threshold) {
- runOnJS(handleSwipe)('Swiped Left')
- translateX.value = withSpring(-200)
- } else if (event.translationY < -threshold) {
- runOnJS(handleSwipe)('Swiped Up')
- translateY.value = withSpring(-200)
- } else {
- translateX.value = withSpring(0)
- translateY.value = withSpring(0)
- }
- }),
- [translateX, translateY, handleSwipe],
- )
-
- const openPlayer = useCallback(
- () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }),
- [navigation],
- )
-
- const pressStyle = useMemo(
- () => ({
- opacity: 0.6,
- }),
- [],
- )
+ const pressStyle = {
+ opacity: 0.6,
+ }
return (
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index db05d45c..644cccc0 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react'
+import React, { useState } from 'react'
import Input from '../Global/helpers/input'
import ItemRow from '../Global/components/item-row'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -45,7 +45,7 @@ export default function Search({
queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId),
})
- const search = useCallback(() => {
+ const search = () => {
let timeout: ReturnType
return () => {
@@ -55,16 +55,16 @@ export default function Search({
refetchSuggestions()
}, 1000)
}
- }, [])
+ }
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search()
}
- const handleScrollBeginDrag = useCallback(() => {
+ const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
- }, [])
+ }
return (
>()
- const handleScrollBeginDrag = useCallback(() => {
+ const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
- }, [])
+ }
return (
(null)
- const stickyHeaderIndicies = useMemo(() => {
+ const stickyHeaderIndicies = (() => {
if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return []
return tracksInfiniteQuery.data
.map((track, index) => (typeof track === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
- }, [showAlphabeticalSelector, tracksInfiniteQuery.data])
+ })()
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
- // Memoize the expensive tracks processing to prevent memory leaks
- const tracksToDisplay = React.useMemo(
- () => tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? [],
- [tracksInfiniteQuery.data],
- )
+ const tracksToDisplay =
+ tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? []
- // Memoize key extraction for FlashList performance
- const keyExtractor = React.useCallback(
- (item: string | number | BaseItemDto) =>
- typeof item === 'object' ? item.Id! : item.toString(),
- [],
- )
+ const keyExtractor = (item: string | number | BaseItemDto) =>
+ typeof item === 'object' ? item.Id! : item.toString()
/**
* Memoize render item to prevent recreation
@@ -66,31 +59,35 @@ export default function Tracks({
* it factors in the list headings, meaning pressing a track may not
* play that exact track, since the index was offset by the headings
*/
- const renderItem = useCallback(
- ({ item: track, index }: { index: number; item: string | number | BaseItemDto }) =>
- typeof track === 'string' ? (
-
- ) : typeof track === 'number' ? null : typeof track === 'object' ? (
-
- ) : null,
- [tracksToDisplay, queue, navigation, queue],
- )
+ const renderItem = ({
+ item: track,
+ index,
+ }: {
+ index: number
+ item: string | number | BaseItemDto
+ }) =>
+ typeof track === 'string' ? (
+
+ ) : typeof track === 'number' ? null : typeof track === 'object' ? (
+
+ ) : null
- const ItemSeparatorComponent = useCallback(
- ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
- typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
-
- ),
- [],
- )
+ const ItemSeparatorComponent = ({
+ leadingItem,
+ trailingItem,
+ }: {
+ leadingItem: unknown
+ trailingItem: unknown
+ }) =>
+ typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null :
// Effect for handling the pending alphabet selector letter
useEffect(() => {
@@ -129,9 +126,9 @@ export default function Tracks({
}
}, [pendingLetterRef.current, tracksInfiniteQuery.data])
- const handleScrollBeginDrag = useCallback(() => {
+ const handleScrollBeginDrag = () => {
closeAllSwipeableRows()
- }, [])
+ }
return (
diff --git a/src/hooks/use-item-context.ts b/src/hooks/use-item-context.ts
index 71a1fe71..43870611 100644
--- a/src/hooks/use-item-context.ts
+++ b/src/hooks/use-item-context.ts
@@ -7,7 +7,7 @@ import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import fetchUserData from '../api/queries/user-data/utils'
-import { useCallback, useRef } from 'react'
+import { useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
import UserDataQueryKey from '../api/queries/user-data/keys'
import MediaInfoQueryKey from '../api/queries/media/keys'
@@ -23,20 +23,17 @@ export default function useItemContext(): (item: BaseItemDto) => void {
const prefetchedContext = useRef>(new Set())
- return useCallback(
- (item: BaseItemDto) => {
- const effectSig = `${item.Id}-${item.Type}`
+ return (item: BaseItemDto) => {
+ const effectSig = `${item.Id}-${item.Type}`
- // If we've already warmed the cache for this item, return
- if (prefetchedContext.current.has(effectSig)) return
+ // If we've already warmed the cache for this item, return
+ if (prefetchedContext.current.has(effectSig)) return
- // Mark this item's context as warmed, preventing reruns
- prefetchedContext.current.add(effectSig)
+ // Mark this item's context as warmed, preventing reruns
+ prefetchedContext.current.add(effectSig)
- warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
- },
- [api, user, streamingDeviceProfile, downloadingDeviceProfile],
- )
+ warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
+ }
}
function warmItemContext(
diff --git a/src/providers/Artist/index.tsx b/src/providers/Artist/index.tsx
index 5c610a84..0dea031c 100644
--- a/src/providers/Artist/index.tsx
+++ b/src/providers/Artist/index.tsx
@@ -2,7 +2,7 @@ import fetchSimilar from '../../api/queries/similar'
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query'
-import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react'
+import { createContext, ReactNode, useContext } from 'react'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { isUndefined } from 'lodash'
import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist'
@@ -65,38 +65,25 @@ export const ArtistProvider = ({
enabled: !isUndefined(artist.Id),
})
- const refresh = useCallback(() => {
+ const refresh = () => {
refetchAlbums()
refetchFeaturedOn()
refetchSimilar()
- }, [refetchAlbums, refetchFeaturedOn, refetchSimilar])
+ }
const scroll = useSharedValue(0)
- const value = useMemo(
- () => ({
- artist,
- albums,
- featuredOn,
- similarArtists,
- fetchingAlbums,
- fetchingFeaturedOn,
- fetchingSimilarArtists,
- refresh,
- scroll,
- }),
- [
- artist,
- albums,
- featuredOn,
- similarArtists,
- fetchingAlbums,
- fetchingFeaturedOn,
- fetchingSimilarArtists,
- refresh,
- scroll,
- ],
- )
+ const value = {
+ artist,
+ albums,
+ featuredOn,
+ similarArtists,
+ fetchingAlbums,
+ fetchingFeaturedOn,
+ fetchingSimilarArtists,
+ refresh,
+ scroll,
+ }
return {children}
}
diff --git a/src/providers/Network/index.tsx b/src/providers/Network/index.tsx
index bb932383..64a2fcd7 100644
--- a/src/providers/Network/index.tsx
+++ b/src/providers/Network/index.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react'
+import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { JellifyDownloadProgress } from '../../types/JellifyDownload'
import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
import JellifyTrack from '../../types/JellifyTrack'
@@ -97,17 +97,7 @@ export const NetworkContextProvider: ({
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const context = NetworkContextInitializer()
- // Memoize the context value to prevent unnecessary re-renders
- const value = useMemo(
- () => context,
- [
- context.downloadedTracks?.length,
- context.pendingDownloads.length,
- context.downloadingDownloads.length,
- context.completedDownloads.length,
- context.failedDownloads.length,
- ],
- )
+ const value = context
return {children}
}
diff --git a/src/providers/Player/index.tsx b/src/providers/Player/index.tsx
index f5cac90c..6154b38c 100644
--- a/src/providers/Player/index.tsx
+++ b/src/providers/Player/index.tsx
@@ -1,6 +1,6 @@
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
-import { createContext, useCallback, useEffect, useState } from 'react'
+import { createContext, useEffect, useState } from 'react'
import { handleActiveTrackChanged } from './functions'
import JellifyTrack from '../../types/JellifyTrack'
import { useAutoDownload } from '../../stores/settings/usage'
@@ -43,69 +43,61 @@ export const PlayerProvider: () => React.JSX.Element = () => {
usePerformanceMonitor('PlayerProvider', 3)
- const eventHandler = useCallback(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- async (event: any) => {
- switch (event.type) {
- case Event.PlaybackActiveTrackChanged: {
- // When we load a new queue, our index is updated before RNTP
- // Because of this, we only need to respond to this event
- // if the index from the event differs from what we have stored
- if (event.track && enableAudioNormalization) {
- const volume = calculateTrackVolume(event.track)
- await TrackPlayer.setVolume(volume)
- } else if (event.track) {
- try {
- await reportPlaybackStarted(api, event.track)
- } catch (error) {
- console.error('Unable to report playback started for track', error)
- }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const eventHandler = async (event: any) => {
+ switch (event.type) {
+ case Event.PlaybackActiveTrackChanged: {
+ // When we load a new queue, our index is updated before RNTP
+ // Because of this, we only need to respond to this event
+ // if the index from the event differs from what we have stored
+ if (event.track && enableAudioNormalization) {
+ const volume = calculateTrackVolume(event.track)
+ await TrackPlayer.setVolume(volume)
+ } else if (event.track) {
+ try {
+ await reportPlaybackStarted(api, event.track)
+ } catch (error) {
+ console.error('Unable to report playback started for track', error)
}
-
- await handleActiveTrackChanged()
-
- if (event.lastTrack) {
- try {
- if (
- isPlaybackFinished(
- event.lastPosition,
- event.lastTrack.duration ?? 1,
- )
- )
- await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
- else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
- } catch (error) {
- console.error('Unable to report playback stopped for lastTrack', error)
- }
- }
- break
- }
- case Event.PlaybackProgressUpdated: {
- const currentTrack = usePlayerQueueStore.getState().currentTrack
-
- if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
- await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true)
- }
-
- break
}
- case Event.PlaybackState: {
- const currentTrack = usePlayerQueueStore.getState().currentTrack
- switch (event.state) {
- case State.Playing:
- if (currentTrack) await reportPlaybackStarted(api, currentTrack)
- break
- default:
- if (currentTrack) await reportPlaybackStopped(api, currentTrack)
- break
+ await handleActiveTrackChanged()
+
+ if (event.lastTrack) {
+ try {
+ if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
+ await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
+ else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
+ } catch (error) {
+ console.error('Unable to report playback stopped for lastTrack', error)
}
- break
}
+ break
}
- },
- [api, autoDownload, enableAudioNormalization],
- )
+ case Event.PlaybackProgressUpdated: {
+ const currentTrack = usePlayerQueueStore.getState().currentTrack
+
+ if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
+ await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true)
+ }
+
+ break
+ }
+
+ case Event.PlaybackState: {
+ const currentTrack = usePlayerQueueStore.getState().currentTrack
+ switch (event.state) {
+ case State.Playing:
+ if (currentTrack) await reportPlaybackStarted(api, currentTrack)
+ break
+ default:
+ if (currentTrack) await reportPlaybackStopped(api, currentTrack)
+ break
+ }
+ break
+ }
+ }
+ }
useTrackPlayerEvents(PLAYER_EVENTS, eventHandler)
diff --git a/src/providers/Storage/index.tsx b/src/providers/Storage/index.tsx
index 54c7ac4d..d34a4918 100644
--- a/src/providers/Storage/index.tsx
+++ b/src/providers/Storage/index.tsx
@@ -1,11 +1,4 @@
-import React, {
- PropsWithChildren,
- createContext,
- useCallback,
- useContext,
- useMemo,
- useState,
-} from 'react'
+import React, { PropsWithChildren, createContext, useContext, useState } from 'react'
import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import {
@@ -80,12 +73,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
const [isDeleting, setIsDeleting] = useState(false)
const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false)
- const activeDownloadsCount = useMemo(
- () => Object.keys(activeDownloads ?? {}).length,
- [activeDownloads],
- )
+ const activeDownloadsCount = Object.keys(activeDownloads ?? {}).length
- const summary = useMemo(() => {
+ const summary: StorageSummary | undefined = (() => {
if (!downloads || !storageInfo) return undefined
const audioBytes = downloads.reduce(
@@ -110,9 +100,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
artworkBytes,
audioBytes,
}
- }, [downloads, storageInfo])
+ })()
- const suggestions = useMemo(() => {
+ const suggestions: CleanupSuggestion[] = (() => {
if (!downloads || downloads.length === 0) return []
const now = Date.now()
@@ -168,86 +158,69 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
})
return list
- }, [downloads])
+ })()
- const toggleSelection = useCallback((itemId: string) => {
+ const toggleSelection = (itemId: string) => {
setSelection((prev) => ({
...prev,
[itemId]: !prev[itemId],
}))
- }, [])
+ }
- const clearSelection = useCallback(() => setSelection({}), [])
+ const clearSelection = () => setSelection({})
- const deleteDownloads = useCallback(
- async (itemIds: string[]): Promise => {
- if (!itemIds.length) return undefined
- setIsDeleting(true)
- try {
- const result = await deleteDownloadsByIds(itemIds)
- await Promise.all([refetchDownloads(), refetchStorageInfo()])
- setSelection((prev) => {
- const updated = { ...prev }
- itemIds.forEach((id) => delete updated[id])
- return updated
- })
- return result
- } finally {
- setIsDeleting(false)
- }
- },
- [refetchDownloads, refetchStorageInfo],
- )
+ const deleteDownloads = async (
+ itemIds: string[],
+ ): Promise => {
+ if (!itemIds.length) return undefined
+ setIsDeleting(true)
+ try {
+ const result = await deleteDownloadsByIds(itemIds)
+ await Promise.all([refetchDownloads(), refetchStorageInfo()])
+ setSelection((prev) => {
+ const updated = { ...prev }
+ itemIds.forEach((id) => delete updated[id])
+ return updated
+ })
+ return result
+ } finally {
+ setIsDeleting(false)
+ }
+ }
- const deleteSelection = useCallback(async () => {
+ const deleteSelection = async () => {
const idsToDelete = Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id)
return deleteDownloads(idsToDelete)
- }, [selection, deleteDownloads])
+ }
- const refresh = useCallback(async () => {
+ const refresh = async () => {
setIsManuallyRefreshing(true)
try {
await Promise.all([refetchDownloads(), refetchStorageInfo()])
} finally {
setIsManuallyRefreshing(false)
}
- }, [refetchDownloads, refetchStorageInfo])
+ }
const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing
- const value = useMemo(
- () => ({
- downloads,
- summary,
- suggestions,
- selection,
- toggleSelection,
- clearSelection,
- deleteSelection,
- deleteDownloads,
- isDeleting,
- refresh,
- refreshing,
- activeDownloadsCount,
- activeDownloads,
- }),
- [
- downloads,
- summary,
- suggestions,
- selection,
- toggleSelection,
- clearSelection,
- deleteSelection,
- deleteDownloads,
- isDeleting,
- refresh,
- refreshing,
- activeDownloadsCount,
- ],
- )
+ const value: StorageContextValue = {
+ downloads,
+ summary,
+ suggestions,
+ selection,
+ toggleSelection,
+ clearSelection,
+ deleteSelection,
+ deleteDownloads,
+ isDeleting,
+ refresh,
+ refreshing,
+ activeDownloadsCount,
+ activeDownloads,
+ }
return {children}
}
From b418b76269512f72ed84cb3ada86542dac876c29 Mon Sep 17 00:00:00 2001
From: anultravioletaurora
Date: Mon, 1 Dec 2025 20:04:58 +0000
Subject: [PATCH 06/16] [skip actions] version bump
---
android/app/build.gradle | 4 ++--
ios/Jellify.xcodeproj/project.pbxproj | 12 ++++++------
package.json | 2 +-
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 7c0afa7f..bfbf4f9b 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 155
- versionName "0.21.3"
+ versionCode 156
+ versionName "0.22.0"
}
signingConfigs {
debug {
diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj
index cac3896a..98834729 100644
--- a/ios/Jellify.xcodeproj/project.pbxproj
+++ b/ios/Jellify.xcodeproj/project.pbxproj
@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 264;
+ CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.21.3;
+ MARKETING_VERSION = 0.22.0;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 264;
+ CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.21.3;
+ MARKETING_VERSION = 0.22.0;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -821,7 +821,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 264;
+ CURRENT_PROJECT_VERSION = 265;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -832,7 +832,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.21.3;
+ MARKETING_VERSION = 0.22.0;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
diff --git a/package.json b/package.json
index db800bac..2b147680 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jellify",
- "version": "0.21.3",
+ "version": "0.22.0",
"private": true,
"scripts": {
"init-android": "bun i",
From 0f048671e74f1c1c78eb624e207d625ae5003c17 Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Mon, 1 Dec 2025 15:18:31 -0600
Subject: [PATCH 07/16] remove unnecessary memoization
---
bun.lock | 1 +
src/components/Album/index.tsx | 22 ++----
src/components/Playlist/index.tsx | 124 +++++++++++++-----------------
3 files changed, 63 insertions(+), 84 deletions(-)
diff --git a/bun.lock b/bun.lock
index 01ec36f7..b27f4902 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx
index a044a3c9..3afcbdcd 100644
--- a/src/components/Album/index.tsx
+++ b/src/components/Album/index.tsx
@@ -9,7 +9,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import InstantMixButton from '../Global/components/instant-mix-button'
import ItemImage from '../Global/components/image'
-import React, { useCallback, useMemo } from 'react'
+import React, { useCallback } from 'react'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
@@ -50,22 +50,14 @@ export function Album(): React.JSX.Element {
addToDownloadQueue(jellifyTracks)
}
- const sections = useMemo(
- () =>
- (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
- title,
- data: Array.isArray(data) ? data : [],
- })),
- [discs],
- )
+ const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
+ title,
+ data: Array.isArray(data) ? data : [],
+ }))
const hasMultipleSections = sections.length > 1
- const albumTrackList = useMemo(() => discs?.flatMap((disc) => disc.data), [discs])
-
- const handleScrollBeginDrag = useCallback(() => {
- closeAllSwipeableRows()
- }, [])
+ const albumTrackList = discs?.flatMap((disc) => disc.data)
return (
: No tracks found}
)}
- onScrollBeginDrag={handleScrollBeginDrag}
+ onScrollBeginDrag={closeAllSwipeableRows}
/>
)
}
diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx
index 8f09723f..16d611fd 100644
--- a/src/components/Playlist/index.tsx
+++ b/src/components/Playlist/index.tsx
@@ -1,4 +1,4 @@
-import { ScrollView, Spinner, useTheme, XStack, YStack } from 'tamagui'
+import { ScrollView, Spinner, useTheme, XStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/components/icon'
import { PlaylistProps } from './interfaces'
@@ -7,7 +7,7 @@ import { StackActions, useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Sortable from 'react-native-sortables'
-import { useCallback, useLayoutEffect } from 'react'
+import { useLayoutEffect } from 'react'
import { useReducedHapticsSetting } from '../../stores/settings/app'
import { RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
@@ -110,79 +110,65 @@ export default function Playlist({
const rootNavigation = useNavigation>()
- const renderItem = useCallback(
- ({ item: track, index }: RenderItemInfo) => {
- const handlePress = async () => {
- await loadNewQueue({
- track,
- tracklist: playlistTracks ?? [],
- api,
- networkStatus,
- deviceProfile: streamingDeviceProfile,
- index,
- queue: playlist,
- queuingType: QueuingType.FromSelection,
- startPlayback: true,
- })
- }
+ const renderItem = ({ item: track, index }: RenderItemInfo) => {
+ const handlePress = async () => {
+ await loadNewQueue({
+ track,
+ tracklist: playlistTracks ?? [],
+ api,
+ networkStatus,
+ deviceProfile: streamingDeviceProfile,
+ index,
+ queue: playlist,
+ queuingType: QueuingType.FromSelection,
+ startPlayback: true,
+ })
+ }
- return (
-
- {editing && (
-
-
-
- )}
+ return (
+
+ {editing && (
+
+
+
+ )}
+ {
+ if (!editing)
+ rootNavigation.navigate('Context', {
+ item: track,
+ navigation,
+ })
+ }}
+ >
+
+
+
+ {editing && (
{
- if (!editing)
- rootNavigation.navigate('Context', {
- item: track,
- navigation,
- })
+ onTap={() => {
+ setPlaylistTracks(
+ (playlistTracks ?? []).filter(({ Id }) => Id !== track.Id),
+ )
}}
>
-
+
-
- {editing && (
- {
- setPlaylistTracks(
- (playlistTracks ?? []).filter(({ Id }) => Id !== track.Id),
- )
- }}
- >
-
-
- )}
-
- )
- },
- [
- navigation,
- playlist,
- playlistTracks,
- editing,
- setPlaylistTracks,
- loadNewQueue,
- api,
- networkStatus,
- streamingDeviceProfile,
- rootNavigation,
- ],
- )
+ )}
+
+ )
+ }
return (
Date: Mon, 1 Dec 2025 15:56:19 -0600
Subject: [PATCH 08/16] rendering fiixes to playlist and albums
bump react native sortables
---
bun.lock | 4 +-
package.json | 2 +-
src/components/Album/index.tsx | 28 ++--
src/components/Playlist/components/header.tsx | 46 ++----
src/components/Playlist/index.tsx | 92 +++++++++---
src/providers/Album/index.tsx | 47 ------
src/providers/Playlist/index.tsx | 138 ------------------
src/screens/Album/index.tsx | 7 +-
src/screens/Playlist/index.tsx | 15 +-
9 files changed, 114 insertions(+), 265 deletions(-)
delete mode 100644 src/providers/Album/index.tsx
delete mode 100644 src/providers/Playlist/index.tsx
diff --git a/bun.lock b/bun.lock
index b27f4902..d31158fd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,7 +51,7 @@
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
- "react-native-sortables": "^1.9.3",
+ "react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
@@ -1942,7 +1942,7 @@
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
- "react-native-sortables": ["react-native-sortables@1.9.3", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-VLhW9+3AVEaJNwwQSgN+n/Qe+YRB0C0mNWTjHhyzcZ+YjY4BmJao4bZxl5lD6EsfqZ1Ij6B2ZdxjNlSkUXrvow=="],
+ "react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="],
"react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],
diff --git a/package.json b/package.json
index 2b147680..53fc10ce 100644
--- a/package.json
+++ b/package.json
@@ -83,7 +83,7 @@
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
- "react-native-sortables": "^1.9.3",
+ "react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx
index 3afcbdcd..192f6504 100644
--- a/src/components/Album/index.tsx
+++ b/src/components/Album/index.tsx
@@ -17,7 +17,6 @@ import { useNetworkContext } from '../../providers/Network'
import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
-import { useAlbumContext } from '../../providers/Album'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types'
@@ -26,6 +25,9 @@ import { BaseStackParamList } from '../../screens/types'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi } from '../../stores'
+import { QueryKeys } from '../../enums/query-keys'
+import { fetchAlbumDiscs } from '../../api/queries/item'
+import { useQuery } from '@tanstack/react-query'
/**
* The screen for an Album's track list
@@ -35,12 +37,16 @@ import { useApi } from '../../stores'
*
* @returns A React component
*/
-export function Album(): React.JSX.Element {
+export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation = useNavigation>()
- const { album, discs, isPending } = useAlbumContext()
-
const api = useApi()
+
+ const { data: discs, isPending } = useQuery({
+ queryKey: [QueryKeys.ItemTracks, album.Id],
+ queryFn: () => fetchAlbumDiscs(api, album),
+ })
+
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
@@ -92,7 +98,7 @@ export function Album(): React.JSX.Element {
) : null
}}
- ListHeaderComponent={AlbumTrackListHeader}
+ ListHeaderComponent={() => }
renderItem={({ item: track, index }) => (
)}
- ListFooterComponent={AlbumTrackListFooter}
+ ListFooterComponent={() => }
ListEmptyComponent={() => (
{isPending ? : No tracks found}
@@ -120,7 +126,7 @@ export function Album(): React.JSX.Element {
* @param playAlbum The function to call to play the album
* @returns A React component
*/
-function AlbumTrackListHeader(): React.JSX.Element {
+function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
const api = useApi()
const { width } = useSafeAreaFrame()
@@ -130,7 +136,10 @@ function AlbumTrackListHeader(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue()
- const { album, discs } = useAlbumContext()
+ const { data: discs, isPending } = useQuery({
+ queryKey: [QueryKeys.ItemTracks, album.Id],
+ queryFn: () => fetchAlbumDiscs(api, album),
+ })
const navigation = useNavigation>()
@@ -235,8 +244,7 @@ function AlbumTrackListHeader(): React.JSX.Element {
)
}
-function AlbumTrackListFooter(): React.JSX.Element {
- const { album } = useAlbumContext()
+function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation =
useNavigation<
NativeStackNavigationProp<
diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx
index 036b00b6..bf9647d5 100644
--- a/src/components/Playlist/components/header.tsx
+++ b/src/components/Playlist/components/header.tsx
@@ -3,7 +3,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { H5, Spacer, XStack, YStack } from 'tamagui'
import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/components/icon'
-import { usePlaylistContext } from '../../../providers/Playlist'
import { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native'
@@ -19,15 +18,21 @@ import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input'
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'
+import { Dispatch, SetStateAction } from 'react'
export default function PlaylistTracklistHeader({
- canEdit,
+ playlist,
+ playlistTracks,
+ editing,
+ newName,
+ setNewName,
}: {
- canEdit?: boolean
+ playlist: BaseItemDto
+ playlistTracks: BaseItemDto[] | undefined
+ editing: boolean
+ newName: string
+ setNewName: Dispatch>
}): React.JSX.Element {
- const { playlist, playlistTracks, editing, setEditing, newName, setNewName } =
- usePlaylistContext()
-
return (
@@ -68,10 +73,8 @@ export default function PlaylistTracklistHeader({
) : (
@@ -83,16 +86,12 @@ export default function PlaylistTracklistHeader({
function PlaylistHeaderControls({
editing,
- setEditing,
playlist,
playlistTracks,
- canEdit,
}: {
editing: boolean
- setEditing: (editing: boolean) => void
playlist: BaseItemDto
playlistTracks: BaseItemDto[]
- canEdit: boolean | undefined
}): React.JSX.Element {
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
@@ -133,18 +132,7 @@ function PlaylistHeaderControls({
return (
- {editing && canEdit ? (
- {
- navigation.push('DeletePlaylist', { playlist })
- }}
- small
- />
- ) : (
-
- )}
+
@@ -155,16 +143,6 @@ function PlaylistHeaderControls({
playPlaylist(true)} small />
- {canEdit && (
-
- setEditing(!editing)}
- small
- />
-
- )}
{!isDownloading ? (
(false)
+
+ const [newName, setNewName] = useState(playlist.Name ?? '')
+
+ const [playlistTracks, setPlaylistTracks] = useState(undefined)
+
+ const trigger = useHapticFeedback()
+
+ const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
+
+ const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
+ mutationFn: ({
+ playlist,
+ tracks,
+ newName,
+ }: {
+ playlist: BaseItemDto
+ tracks: BaseItemDto[]
+ newName: string
+ }) => {
+ return updatePlaylist(
+ api,
+ playlist.Id!,
+ newName,
+ tracks.map((track) => track.Id!),
+ )
+ },
+ onSuccess: () => {
+ trigger('notificationSuccess')
+
+ // Refresh playlist component data
+ refetch()
+ },
+ onError: () => {
+ trigger('notificationError')
+ setNewName(playlist.Name ?? '')
+ setPlaylistTracks(tracks ?? [])
+ },
+ onSettled: () => {
+ setEditing(false)
+ },
+ })
+
+ const handleCancel = () => {
+ setEditing(false)
+ setNewName(playlist.Name ?? '')
+ setPlaylistTracks(tracks)
+ }
+
+ useEffect(() => {
+ if (!isPending && isSuccess) setPlaylistTracks(tracks)
+ }, [tracks, isPending, isSuccess])
+
+ useEffect(() => {
+ if (!editing) refetch()
+ }, [editing])
const loadNewQueue = useLoadNewQueue()
@@ -128,9 +176,11 @@ export default function Playlist({
return (
{editing && (
-
-
-
+
+
+
+
+
)}
}
>
-
+
fetchAlbumDiscs(api, album),
- })
-
- return {
- album,
- discs,
- isPending,
- }
-}
-
-const AlbumContext = createContext({
- album: {},
- discs: undefined,
- isPending: false,
-})
-
-export const AlbumProvider: ({
- album,
- children,
-}: {
- album: BaseItemDto
- children: ReactNode
-}) => React.JSX.Element = ({ album, children }) => {
- const context = AlbumContextInitializer(album)
-
- return {children}
-}
-
-export const useAlbumContext = () => useContext(AlbumContext)
diff --git a/src/providers/Playlist/index.tsx b/src/providers/Playlist/index.tsx
deleted file mode 100644
index 2701c725..00000000
--- a/src/providers/Playlist/index.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
-import { UseMutateFunction, useMutation } from '@tanstack/react-query'
-import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
-import { updatePlaylist } from '../../api/mutations/playlists'
-import { SharedValue, useSharedValue } from 'react-native-reanimated'
-import useHapticFeedback from '../../hooks/use-haptic-feedback'
-import { useApi } from '../../stores'
-import { usePlaylistTracks } from '../../api/queries/playlist'
-
-interface PlaylistContext {
- playlist: BaseItemDto
- playlistTracks: BaseItemDto[] | undefined
- refetch: () => void
- isPending: boolean
- editing: boolean
- setEditing: (editing: boolean) => void
- newName: string
- setNewName: (name: string) => void
- setPlaylistTracks: (tracks: BaseItemDto[]) => void
- useUpdatePlaylist: UseMutateFunction<
- void,
- Error,
- {
- playlist: BaseItemDto
- tracks: BaseItemDto[]
- newName: string
- },
- unknown
- >
- isUpdating?: boolean
- handleCancel: () => void
-}
-
-const PlaylistContextInitializer = (playlist: BaseItemDto) => {
- const api = useApi()
-
- const canEdit = playlist.CanDelete
- const [editing, setEditing] = useState(false)
-
- const [newName, setNewName] = useState(playlist.Name ?? '')
-
- const [playlistTracks, setPlaylistTracks] = useState(undefined)
-
- const trigger = useHapticFeedback()
-
- const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
-
- const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
- mutationFn: ({
- playlist,
- tracks,
- newName,
- }: {
- playlist: BaseItemDto
- tracks: BaseItemDto[]
- newName: string
- }) => {
- return updatePlaylist(
- api,
- playlist.Id!,
- newName,
- tracks.map((track) => track.Id!),
- )
- },
- onSuccess: () => {
- trigger('notificationSuccess')
-
- // Refresh playlist component data
- refetch()
- },
- onError: () => {
- trigger('notificationError')
- setNewName(playlist.Name ?? '')
- setPlaylistTracks(tracks ?? [])
- },
- onSettled: () => {
- setEditing(false)
- },
- })
-
- const handleCancel = () => {
- setEditing(false)
- setNewName(playlist.Name ?? '')
- setPlaylistTracks(tracks)
- }
-
- useEffect(() => {
- if (!isPending && isSuccess) setPlaylistTracks(tracks)
- }, [tracks, isPending, isSuccess])
-
- useEffect(() => {
- if (!editing) refetch()
- }, [editing])
-
- return {
- playlist,
- playlistTracks,
- refetch,
- isPending,
- editing,
- setEditing,
- newName,
- setNewName,
- setPlaylistTracks,
- useUpdatePlaylist,
- handleCancel,
- isUpdating,
- }
-}
-
-const PlaylistContext = createContext({
- playlist: {},
- playlistTracks: undefined,
- refetch: () => {},
- isPending: false,
- editing: false,
- setEditing: () => {},
- newName: '',
- setNewName: () => {},
- setPlaylistTracks: () => {},
- useUpdatePlaylist: () => {},
- handleCancel: () => {},
- isUpdating: false,
-})
-
-export const PlaylistProvider = ({
- playlist,
- children,
-}: {
- playlist: BaseItemDto
- children: ReactNode
-}) => {
- const context = PlaylistContextInitializer(playlist)
-
- return {children}
-}
-
-export const usePlaylistContext = () => useContext(PlaylistContext)
diff --git a/src/screens/Album/index.tsx b/src/screens/Album/index.tsx
index c3c205be..ba5fc779 100644
--- a/src/screens/Album/index.tsx
+++ b/src/screens/Album/index.tsx
@@ -1,13 +1,8 @@
import { Album } from '../../components/Album'
import { AlbumProps } from '../types'
-import { AlbumProvider } from '../../providers/Album'
export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element {
const { album } = route.params
- return (
-
-
-
- )
+ return
}
diff --git a/src/screens/Playlist/index.tsx b/src/screens/Playlist/index.tsx
index c036df8f..f1083a8e 100644
--- a/src/screens/Playlist/index.tsx
+++ b/src/screens/Playlist/index.tsx
@@ -1,9 +1,8 @@
-import { BaseStackParamList, RootStackParamList } from '../types'
+import { BaseStackParamList } from '../types'
import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React from 'react'
import Playlist from '../../components/Playlist/index'
-import { PlaylistProvider } from '../../providers/Playlist'
export function PlaylistScreen({
route,
@@ -13,12 +12,10 @@ export function PlaylistScreen({
navigation: NativeStackNavigationProp
}): React.JSX.Element {
return (
-
-
-
+
)
}
From 0f8a9e91c532040175eb6570b53c7bfdc3f48aa5 Mon Sep 17 00:00:00 2001
From: Violet Caulfield
Date: Tue, 2 Dec 2025 01:20:08 -0600
Subject: [PATCH 09/16] home screen animation and indicator improvements
---
.../Home/helpers/frequent-artists.tsx | 20 ++-
.../Home/helpers/frequent-tracks.tsx | 22 ++-
.../Home/helpers/recent-artists.tsx | 18 ++-
.../Home/helpers/recently-played.tsx | 126 +++++++++---------
src/components/Home/index.tsx | 11 +-
5 files changed, 119 insertions(+), 78 deletions(-)
diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx
index 811b3052..a8b1410e 100644
--- a/src/components/Home/helpers/frequent-artists.tsx
+++ b/src/components/Home/helpers/frequent-artists.tsx
@@ -2,7 +2,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useCallback } from 'react'
import { ItemCard } from '../../../components/Global/components/item-card'
-import { H5, View, XStack } from 'tamagui'
+import { H5, XStack } from 'tamagui'
import Icon from '../../Global/components/icon'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native'
@@ -11,6 +11,7 @@ import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { pickFirstGenre } from '../../../utils/genre-formatting'
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentArtists(): React.JSX.Element {
const navigation = useNavigation>()
@@ -42,8 +43,15 @@ export default function FrequentArtists(): React.JSX.Element {
[],
)
- return (
-
+ return frequentArtistsInfiniteQuery.data ? (
+
{
@@ -57,9 +65,11 @@ export default function FrequentArtists(): React.JSX.Element {
-
+
+ ) : (
+ <>>
)
}
diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx
index 680daa3d..851072a9 100644
--- a/src/components/Home/helpers/frequent-tracks.tsx
+++ b/src/components/Home/helpers/frequent-tracks.tsx
@@ -1,5 +1,5 @@
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
-import { H5, View, XStack } from 'tamagui'
+import { H5, XStack } from 'tamagui'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { ItemCard } from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type'
@@ -13,6 +13,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents'
import { useApi } from '../../../stores'
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const api = useApi()
@@ -30,8 +31,15 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue()
const { horizontalItems } = useDisplayContext()
- return (
-
+ return tracksInfiniteQuery.data ? (
+
{
@@ -46,8 +54,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
horizontalItems)
- ? tracksInfiniteQuery.data?.slice(0, horizontalItems)
+ tracksInfiniteQuery.data.length > horizontalItems
+ ? tracksInfiniteQuery.data.slice(0, horizontalItems)
: tracksInfiniteQuery.data
}
renderItem={({ item: track, index }) => (
@@ -81,6 +89,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
/>
)}
/>
-
+
+ ) : (
+ <>>
)
}
diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx
index 69dff96f..1d9fbbac 100644
--- a/src/components/Home/helpers/recent-artists.tsx
+++ b/src/components/Home/helpers/recent-artists.tsx
@@ -11,6 +11,7 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useRecentArtists } from '../../../api/queries/recents'
import { pickFirstGenre } from '../../../utils/genre-formatting'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function RecentArtists(): React.JSX.Element {
const recentArtistsInfiniteQuery = useRecentArtists()
@@ -50,17 +51,26 @@ export default function RecentArtists(): React.JSX.Element {
[navigation, rootNavigation],
)
- return (
-
+ return recentArtistsInfiniteQuery.data ? (
+
Recent Artists
-
+
+ ) : (
+ <>>
)
}
diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx
index f2ea49b3..7da08dda 100644
--- a/src/components/Home/helpers/recently-played.tsx
+++ b/src/components/Home/helpers/recently-played.tsx
@@ -1,5 +1,5 @@
-import React, { useMemo } from 'react'
-import { H5, View, XStack } from 'tamagui'
+import React from 'react'
+import { H5, XStack } from 'tamagui'
import { ItemCard } from '../../Global/components/item-card'
import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -13,8 +13,8 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useRecentlyPlayedTracks } from '../../../api/queries/recents'
-import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
export default function RecentlyPlayed(): React.JSX.Element {
const api = useApi()
@@ -23,8 +23,6 @@ export default function RecentlyPlayed(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile()
- const nowPlaying = useCurrentTrack()
-
const navigation = useNavigation>()
const rootNavigation = useNavigation>()
@@ -33,60 +31,68 @@ export default function RecentlyPlayed(): React.JSX.Element {
const tracksInfiniteQuery = useRecentlyPlayedTracks()
const { horizontalItems } = useDisplayContext()
- return useMemo(() => {
- return (
-
- {
- navigation.navigate('RecentTracks', {
- tracksInfiniteQuery,
- })
- }}
- >
- Play it again
-
-
- horizontalItems)
- ? tracksInfiniteQuery.data?.slice(0, horizontalItems)
- : tracksInfiniteQuery.data
- }
- renderItem={({ index, item: recentlyPlayedTrack }) => (
- {
- loadNewQueue({
- api,
- deviceProfile,
- networkStatus,
- track: recentlyPlayedTrack,
- index: index,
- tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack],
- queue: 'Recently Played',
- queuingType: QueuingType.FromSelection,
- startPlayback: true,
- })
- }}
- onLongPress={() => {
- rootNavigation.navigate('Context', {
- item: recentlyPlayedTrack,
- navigation,
- })
- }}
- marginHorizontal={'$1'}
- captionAlign='left'
- />
- )}
- />
-
- )
- }, [tracksInfiniteQuery.data, nowPlaying])
+ return tracksInfiniteQuery.data ? (
+
+ {
+ navigation.navigate('RecentTracks', {
+ tracksInfiniteQuery,
+ })
+ }}
+ >
+ Play it again
+
+
+
+ horizontalItems)
+ ? tracksInfiniteQuery.data.slice(0, horizontalItems)
+ : tracksInfiniteQuery.data
+ }
+ renderItem={({ index, item: recentlyPlayedTrack }) => (
+ {
+ loadNewQueue({
+ api,
+ deviceProfile,
+ networkStatus,
+ track: recentlyPlayedTrack,
+ index: index,
+ tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack],
+ queue: 'Recently Played',
+ queuingType: QueuingType.FromSelection,
+ startPlayback: true,
+ })
+ }}
+ onLongPress={() => {
+ rootNavigation.navigate('Context', {
+ item: recentlyPlayedTrack,
+ navigation,
+ })
+ }}
+ marginHorizontal={'$1'}
+ captionAlign='left'
+ />
+ )}
+ />
+
+ ) : (
+ <>>
+ )
}
diff --git a/src/components/Home/index.tsx b/src/components/Home/index.tsx
index 7e497e9b..5c127de9 100644
--- a/src/components/Home/index.tsx
+++ b/src/components/Home/index.tsx
@@ -7,6 +7,8 @@ import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { usePreventRemove } from '@react-navigation/native'
import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
+import { useIsRestoring } from '@tanstack/react-query'
+import { useRecentlyPlayedTracks } from '../../api/queries/recents'
const COMPONENT_NAME = 'Home'
@@ -17,18 +19,21 @@ export function Home(): React.JSX.Element {
usePerformanceMonitor(COMPONENT_NAME, 5)
- const { isPending: refreshing, mutate: refresh } = useHomeQueries()
+ const { isPending: refreshing, mutateAsync: refresh } = useHomeQueries()
+
+ const { isPending: loadingInitialData } = useRecentlyPlayedTracks()
+
+ const isRestoring = useIsRestoring()
return (
From 36069ba3ecf8e34f12c4fbb1d5cd30016938ef38 Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Tue, 2 Dec 2025 01:21:39 -0600
Subject: [PATCH 10/16] combine useEffect that sets the library selector and
playlist library (#739)
useRef instead of useState for playlist to prevent an additional rerender
Co-authored-by: Ritesh Shukla
---
.../Global/components/library-selector.tsx | 25 ++++++++++---------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/src/components/Global/components/library-selector.tsx b/src/components/Global/components/library-selector.tsx
index b1810b39..e93c4555 100644
--- a/src/components/Global/components/library-selector.tsx
+++ b/src/components/Global/components/library-selector.tsx
@@ -1,9 +1,9 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Spinner, ToggleGroup, XStack, YStack } from 'tamagui'
import { H2, Text } from '../helpers/text'
import Button from '../helpers/button'
import { SafeAreaView } from 'react-native-safe-area-context'
-import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
+import { BaseItemDto, CollectionType } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserViews } from '../../../api/queries/libraries'
import { useQuery } from '@tanstack/react-query'
@@ -57,7 +57,7 @@ export default function LibrarySelector({
const [selectedLibraryId, setSelectedLibraryId] = useState(
library?.musicLibraryId,
)
- const [playlistLibrary, setPlaylistLibrary] = useState(undefined)
+ const playlistLibrary = useRef(undefined)
const handleLibrarySelection = () => {
if (!selectedLibraryId || !libraries) return
@@ -65,23 +65,24 @@ export default function LibrarySelector({
const selectedLibrary = libraries.find((lib) => lib.Id === selectedLibraryId)
if (selectedLibrary) {
- onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary)
+ onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary.current)
}
}
const hasMultipleLibraries = musicLibraries.length > 1
- useEffect(() => {
- if (libraries) {
- setMusicLibraries(libraries.filter((library) => library.CollectionType === 'music'))
- }
- }, [libraries, isPending])
-
useEffect(() => {
if (!isPending && isSuccess && libraries) {
+ setMusicLibraries(
+ libraries.filter((library) => library.CollectionType === CollectionType.Music),
+ )
+
// Find the playlist library
- const foundPlaylistLibrary = libraries.find((lib) => lib.CollectionType === 'playlists')
- setPlaylistLibrary(foundPlaylistLibrary)
+ const foundPlaylistLibrary = libraries.find(
+ (lib) => lib.CollectionType === CollectionType.Playlists,
+ )
+
+ if (foundPlaylistLibrary) playlistLibrary.current = foundPlaylistLibrary
}
}, [isPending, isSuccess, libraries])
From a111f057ba860145314294dbf3721eccf97c672d Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Wed, 3 Dec 2025 05:27:11 -0600
Subject: [PATCH 11/16] Bugfix/downloads not working after react compiler
(#742)
* build out download store in zustand
* add download processor use effect to top level of authenticated app
---
bun.lock | 30 ++--
ios/Podfile.lock | 12 +-
package.json | 16 +-
src/components/Album/index.tsx | 16 +-
src/components/Context/index.tsx | 33 ++---
src/components/Playlist/components/header.tsx | 22 +--
src/components/Storage/index.tsx | 4 +-
src/components/jellify.tsx | 18 +--
src/configs/download.config.ts | 1 +
src/hooks/use-download-processor.ts | 64 ++++++++
src/providers/Network/index.tsx | 105 --------------
src/providers/Storage/index.tsx | 8 +-
.../Settings/storage-management/index.tsx | 111 ++++++--------
src/stores/network/downloads.ts | 137 ++++++++++++++++++
src/stores/{network.ts => network/index.ts} | 2 +-
15 files changed, 315 insertions(+), 264 deletions(-)
create mode 100644 src/configs/download.config.ts
create mode 100644 src/hooks/use-download-processor.ts
delete mode 100644 src/providers/Network/index.tsx
create mode 100644 src/stores/network/downloads.ts
rename src/stores/{network.ts => network/index.ts} (89%)
diff --git a/bun.lock b/bun.lock
index d31158fd..740f555c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,17 +11,17 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
- "@react-navigation/bottom-tabs": "7.8.6",
- "@react-navigation/material-top-tabs": "7.4.4",
- "@react-navigation/native": "7.1.21",
- "@react-navigation/native-stack": "7.8.0",
+ "@react-navigation/bottom-tabs": "7.8.10",
+ "@react-navigation/material-top-tabs": "7.4.7",
+ "@react-navigation/native": "7.1.23",
+ "@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0",
- "@testing-library/react-native": "^13.2.3",
+ "@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "1.12.2",
"bundle": "^2.1.0",
@@ -45,8 +45,8 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6",
- "react-native-nitro-modules": "^0.31.9",
- "react-native-nitro-ota": "^0.4.0",
+ "react-native-nitro-modules": "0.31.10",
+ "react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
@@ -566,17 +566,17 @@
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.82.1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q=="],
- "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.6", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-0wGtU+I1rCUjvAqKtzD2dwQaTICFf5J233vkg20cLrx8LNQPAgSsbnsDSM6S315OOoVLCIL1dcrNv7ExLBlWfw=="],
+ "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.10", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-NxKjtlRwkGU3O3hPxpS+Aq7mVNfgtLzBe4xpGjQNphLzklRbxa6Me//m2eKzogpitZhLR2xZb1A49HrLuWe2ww=="],
- "@react-navigation/core": ["@react-navigation/core@7.13.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-A0pFeZlKp+FJob2lVr7otDt3M4rsSJrnAfXWoWR9JVeFtfEXsH/C0s7xtpDCMRUO58kzSBoTF1GYzoMC5DLD4g=="],
+ "@react-navigation/core": ["@react-navigation/core@7.13.4", "", { "dependencies": { "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-JM9bkb7fr4P5YUOVEwoAZq3xPeSL9V6Nd1KKTyAwCgGUVhESbSRSy3Ri/PGu6ZcLb/t7/tM1NqP5tV1e1bAwUg=="],
- "@react-navigation/elements": ["@react-navigation/elements@2.8.3", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-0c5nSDPP3bUFujgkSVqqMShaAup3XIxNe1KTK9LSmwKgWEneyo6OPIjIdiEwPlZvJZKi7ag5hDjacQLGwO0LGA=="],
+ "@react-navigation/elements": ["@react-navigation/elements@2.9.0", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-423uE+q/esaiMbXVLckFOd9MbWG06/vCYOP2hwzEUj9ZwzUgSpsIPovcu78qa8UMuvKD8wkyouM01Wvav1y/KQ=="],
- "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-8OCT+tW4dlkEPhjmQWFEw867CKTL3och5N9TLt56lA+3pm55x1kljsVO6DF6BxF41iHrhIJIr09UrojVJDr5TQ=="],
+ "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.7", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-0fv+Ym9kOO7DLf8GRmkt9zNKPTbnYU62ATacv0zirNA+vBDT/fhlE67orUXsQa/nORXlUMvllCaKPf/oyD7UcQ=="],
- "@react-navigation/native": ["@react-navigation/native@7.1.21", "", { "dependencies": { "@react-navigation/core": "^7.13.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-mhpAewdivBL01ibErr91FUW9bvKhfAF6Xv/yr6UOJtDhv0jU6iUASUcA3i3T8VJCOB/vxmoke7VDp8M+wBFs/Q=="],
+ "@react-navigation/native": ["@react-navigation/native@7.1.23", "", { "dependencies": { "@react-navigation/core": "^7.13.4", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-V+drzVkoVA8VO83cJ59UYe7dfdnMFpGDAybp7d5O1ufxt321Z5tOtNDOzhMGzHUENqo9QWc4P/HuCUmz7KMy+A=="],
- "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.0", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-iRqQY+IYB610BJY/335/kdNDhXQ8L9nPUlIT+DSk88FA86+C+4/vek8wcKw8IrfwdorT4m+6TY0v7Qnrt+WLKQ=="],
+ "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-7kpYHoZZ81SPtDDG9ttZtI4nXR8GbVsLL1KnT/7RiLkFdqHXlriGpVhG5BKJRS1CYXrGEn40NogYW2+OBplglg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
@@ -1930,9 +1930,9 @@
"react-native-nitro-fetch": ["react-native-nitro-fetch@0.1.6", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.2", "react-native-worklets-core": "^1.6.0" }, "optionalPeers": ["react-native-worklets-core"] }, "sha512-DbE/vN5B67SJM8Q0myHOwSSc7ASqJPaKYXVsWdNGIPS+csr9gygCKILT4RQ+xZ92iJGKn4bfyq+rRsacRWBV9A=="],
- "react-native-nitro-modules": ["react-native-nitro-modules@0.31.9", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-w7NtHq4wP6LZgvDs7zbFU3B2uHpRx/bJlSTckw0By8NyEX39fURPGgHyi4a67q1O7I3iFJvbRNWUiiOBbNvHDg=="],
+ "react-native-nitro-modules": ["react-native-nitro-modules@0.31.10", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ=="],
- "react-native-nitro-ota": ["react-native-nitro-ota@0.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-/JAoM2m3WsvnO7dC51bf5jCghxO78yrP3vHyq3/itK+MqiwU8HPk8bGbXLhE+/GYRPS8DbUHGrzptzO2KOoutQ=="],
+ "react-native-nitro-ota": ["react-native-nitro-ota@0.7.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-DUa2/QhFJBhSbzrTHGrc+qm1pSuJctccUcHlHZXjPV4fCEpi+4Y17QqI9U4D9MUnnP77afKEZJKFy+0NQeSAdA=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index ed299344..7a809916 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -42,7 +42,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - NitroModules (0.31.9):
+ - NitroModules (0.31.10):
- boost
- DoubleConversion
- fast_float
@@ -71,7 +71,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - NitroOta (0.4.0):
+ - NitroOta (0.7.2):
- boost
- DoubleConversion
- fast_float
@@ -102,7 +102,7 @@ PODS:
- SocketRocket
- SSZipArchive
- Yoga
- - NitroOtaBundleManager (0.4.0):
+ - NitroOtaBundleManager (0.7.2):
- boost
- DoubleConversion
- fast_float
@@ -3449,9 +3449,9 @@ SPEC CHECKSUMS:
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
- NitroModules: 224bf833d249b0c7ce32831368f2887008579b13
- NitroOta: b4f7cdbe660e8f07f80f5eb9f169d70f698ea284
- NitroOtaBundleManager: 5e7c0f8c3f76cc06f9fe07a63879fe35496c27c7
+ NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23
+ NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679
+ NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
diff --git a/package.json b/package.json
index 53fc10ce..f947a874 100644
--- a/package.json
+++ b/package.json
@@ -43,19 +43,19 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
- "@react-navigation/bottom-tabs": "7.8.6",
- "@react-navigation/material-top-tabs": "7.4.4",
- "@react-navigation/native": "7.1.21",
- "@react-navigation/native-stack": "7.8.0",
+ "@react-navigation/bottom-tabs": "7.8.10",
+ "@react-navigation/material-top-tabs": "7.4.7",
+ "@react-navigation/native": "7.1.23",
+ "@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0",
- "@testing-library/react-native": "^13.2.3",
+ "@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",
@@ -77,8 +77,8 @@
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3",
"react-native-nitro-fetch": "^0.1.6",
- "react-native-nitro-modules": "^0.31.9",
- "react-native-nitro-ota": "^0.4.0",
+ "react-native-nitro-modules": "0.31.10",
+ "react-native-nitro-ota": "0.7.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx
index 192f6504..c607f4a8 100644
--- a/src/components/Album/index.tsx
+++ b/src/components/Album/index.tsx
@@ -12,8 +12,6 @@ import ItemImage from '../Global/components/image'
import React, { useCallback } from 'react'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../Global/components/icon'
-import { mapDtoToTrack } from '../../utils/mappings'
-import { useNetworkContext } from '../../providers/Network'
import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
@@ -22,12 +20,13 @@ import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types'
import DiscoverStackParamList from '../../screens/Discover/types'
import { BaseStackParamList } from '../../screens/types'
-import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
+import useStreamingDeviceProfile from '../../stores/device-profile'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi } from '../../stores'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
+import useAddToPendingDownloads, { usePendingDownloads } from '../../stores/network/downloads'
/**
* The screen for an Album's track list
@@ -47,14 +46,11 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
queryFn: () => fetchAlbumDiscs(api, album),
})
- const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
- const downloadingDeviceProfile = useDownloadingDeviceProfile()
+ const addToDownloadQueue = useAddToPendingDownloads()
- const downloadAlbum = (item: BaseItemDto[]) => {
- if (!api) return
- const jellifyTracks = item.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
- addToDownloadQueue(jellifyTracks)
- }
+ const pendingDownloads = usePendingDownloads()
+
+ const downloadAlbum = (item: BaseItemDto[]) => addToDownloadQueue(item)
const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
title,
diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx
index 63de2abe..82565562 100644
--- a/src/components/Context/index.tsx
+++ b/src/components/Context/index.tsx
@@ -3,7 +3,7 @@ import {
BaseItemKind,
MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models'
-import { ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui'
+import { ListItem, Spinner, View, YGroup } from 'tamagui'
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text'
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
@@ -25,14 +25,17 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network'
-import { useNetworkContext } from '../../providers/Network'
-import { mapDtoToTrack } from '../../utils/mappings'
-import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
+import useStreamingDeviceProfile from '../../stores/device-profile'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native'
import { useApi } from '../../stores'
+import useAddToPendingDownloads, {
+ useIsDownloading,
+ usePendingDownloads,
+} from '../../stores/network/downloads'
+import { networkStatusTypes } from '../Network/internetConnectionWatcher'
type StackNavigation = Pick, 'navigate' | 'dispatch'>
@@ -55,6 +58,8 @@ export default function ItemContext({
const trigger = useHapticFeedback()
+ const [networkStatus] = useNetworkStatus()
+
const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio
@@ -242,29 +247,15 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
- const api = useApi()
- const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
+ const addToDownloadQueue = useAddToPendingDownloads()
const useRemoveDownload = useDeleteDownloads()
- const deviceProfile = useDownloadingDeviceProfile()
-
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
- const downloadItems = () => {
- if (!api) return
-
- const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile))
- addToDownloadQueue(tracks)
- }
-
const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id))
- const isPending =
- items.filter(
- (item) =>
- pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0,
- ).length > 0
+ const isPending = useIsDownloading(items)
return isPending ? (
addToDownloadQueue(items)}
pressStyle={{ opacity: 0.5 }}
>
>()
- const downloadPlaylist = () => {
- if (!api) return
- const jellifyTracks = playlistTracks.map((item) =>
- mapDtoToTrack(api, item, downloadingDeviceProfile),
- )
- addToDownloadQueue(jellifyTracks)
- }
+ const downloadPlaylist = () => addToDownloadQueue(playlistTracks)
const playPlaylist = (shuffled: boolean = false) => {
if (!playlistTracks || playlistTracks.length === 0) return
diff --git a/src/components/Storage/index.tsx b/src/components/Storage/index.tsx
index ae7fd2eb..50fc1e53 100644
--- a/src/components/Storage/index.tsx
+++ b/src/components/Storage/index.tsx
@@ -4,9 +4,9 @@ import RNFS from 'react-native-fs'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils'
import Icon from '../Global/components/icon'
-import { useNetworkContext } from '../../providers/Network'
import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text'
+import { useDownloadProgress } from '@/src/stores/network/downloads'
// 🔹 Single Download Item with animated progress bar
function DownloadItem({
@@ -43,7 +43,7 @@ export default function StorageBar(): React.JSX.Element {
const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1)
- const { activeDownloads: activeDownloadsArray } = useNetworkContext()
+ const activeDownloadsArray = useDownloadProgress()
const usageShared = useSharedValue(0)
const percentUsed = used / total
diff --git a/src/components/jellify.tsx b/src/components/jellify.tsx
index a8dbc19b..e0f3094e 100644
--- a/src/components/jellify.tsx
+++ b/src/components/jellify.tsx
@@ -2,7 +2,6 @@ import _ from 'lodash'
import React, { useEffect } from 'react'
import Root from '../screens'
import { PlayerProvider } from '../providers/Player'
-import { NetworkContextProvider } from '../providers/Network'
import { DisplayProvider } from '../providers/Display/display-provider'
import {
createTelemetryDeck,
@@ -20,6 +19,7 @@ import { StorageProvider } from '../providers/Storage'
import { useSelectPlayerEngine } from '../stores/player/engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
import { GLITCHTIP_DSN } from '../configs/config'
+import useDownloadProcessor from '../hooks/use-download-processor'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
@@ -76,14 +76,14 @@ function App(): React.JSX.Element {
}
}, [sendMetrics])
+ useDownloadProcessor()
+
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
)
}
diff --git a/src/configs/download.config.ts b/src/configs/download.config.ts
new file mode 100644
index 00000000..11ad1c67
--- /dev/null
+++ b/src/configs/download.config.ts
@@ -0,0 +1 @@
+export const MAX_CONCURRENT_DOWNLOADS = 1
diff --git a/src/hooks/use-download-processor.ts b/src/hooks/use-download-processor.ts
new file mode 100644
index 00000000..66bf0779
--- /dev/null
+++ b/src/hooks/use-download-processor.ts
@@ -0,0 +1,64 @@
+import { useEffect } from 'react'
+import {
+ useAddToCompletedDownloads,
+ useAddToCurrentDownloads,
+ useAddToFailedDownloads,
+ useDownloadsStore,
+ useRemoveFromCurrentDownloads,
+ useRemoveFromPendingDownloads,
+} from '../stores/network/downloads'
+import { MAX_CONCURRENT_DOWNLOADS } from '../configs/download.config'
+import { useAllDownloadedTracks } from '../api/queries/download'
+import { saveAudio } from '../api/mutations/download/offlineModeUtils'
+
+const useDownloadProcessor = () => {
+ const { pendingDownloads, currentDownloads } = useDownloadsStore()
+
+ const { data: downloadedTracks } = useAllDownloadedTracks()
+
+ const addToCurrentDownloads = useAddToCurrentDownloads()
+
+ const removeFromCurrentDownloads = useRemoveFromCurrentDownloads()
+
+ const removeFromPendingDownloads = useRemoveFromPendingDownloads()
+
+ const addToCompletedDownloads = useAddToCompletedDownloads()
+
+ const addToFailedDownloads = useAddToFailedDownloads()
+
+ const { refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
+
+ return useEffect(() => {
+ if (pendingDownloads.length > 0 && currentDownloads.length < MAX_CONCURRENT_DOWNLOADS) {
+ const availableSlots = MAX_CONCURRENT_DOWNLOADS - currentDownloads.length
+ const filesToStart = pendingDownloads.slice(0, availableSlots)
+
+ console.debug('Downloading from queue')
+
+ filesToStart.forEach((file) => {
+ addToCurrentDownloads(file)
+ removeFromPendingDownloads(file)
+ if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) {
+ removeFromCurrentDownloads(file)
+ addToCompletedDownloads(file)
+ return
+ }
+
+ saveAudio(file, () => {}, false).then((success) => {
+ removeFromCurrentDownloads(file)
+
+ if (success) {
+ addToCompletedDownloads(file)
+ } else {
+ addToFailedDownloads(file)
+ }
+ })
+ })
+ }
+ if (pendingDownloads.length === 0 && currentDownloads.length === 0) {
+ refetchDownloadedTracks()
+ }
+ }, [pendingDownloads.length, currentDownloads.length])
+}
+
+export default useDownloadProcessor
diff --git a/src/providers/Network/index.tsx b/src/providers/Network/index.tsx
deleted file mode 100644
index 64a2fcd7..00000000
--- a/src/providers/Network/index.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'
-import { JellifyDownloadProgress } from '../../types/JellifyDownload'
-import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
-import JellifyTrack from '../../types/JellifyTrack'
-import { useAllDownloadedTracks } from '../../api/queries/download'
-import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
-
-interface NetworkContext {
- activeDownloads: JellifyDownloadProgress | undefined
- pendingDownloads: JellifyTrack[]
- downloadingDownloads: JellifyTrack[]
- completedDownloads: JellifyTrack[]
- failedDownloads: JellifyTrack[]
- addToDownloadQueue: (items: JellifyTrack[]) => boolean
-}
-
-const MAX_CONCURRENT_DOWNLOADS = 1
-
-const COMPONENT_NAME = 'NetworkProvider'
-
-const NetworkContextInitializer = () => {
- usePerformanceMonitor(COMPONENT_NAME, 5)
- const [downloadProgress, setDownloadProgress] = useState({})
-
- // Mutiple Downloads
- const [pending, setPending] = useState([])
- const [downloading, setDownloading] = useState([])
- const [completed, setCompleted] = useState([])
- const [failed, setFailed] = useState([])
-
- const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
-
- useEffect(() => {
- if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) {
- const availableSlots = MAX_CONCURRENT_DOWNLOADS - downloading.length
- const filesToStart = pending.slice(0, availableSlots)
-
- filesToStart.forEach((file) => {
- setDownloading((prev) => [...prev, file])
- setPending((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
- if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) {
- setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
- setCompleted((prev) => [...prev, file])
- return
- }
-
- saveAudio(file, setDownloadProgress, false).then((success) => {
- setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
- setDownloadProgress((prev) => {
- const next = { ...prev }
- delete next[file.url]
- if (file.artwork) delete next[file.artwork]
- return next
- })
- if (success) {
- setCompleted((prev) => [...prev, file])
- } else {
- setFailed((prev) => [...prev, file])
- }
- })
- })
- }
- if (pending.length === 0 && downloading.length === 0) {
- refetchDownloadedTracks()
- }
- }, [pending, downloading])
-
- const addToDownloadQueue = (items: JellifyTrack[]) => {
- setPending((prev) => [...prev, ...items])
- return true
- }
-
- return {
- activeDownloads: downloadProgress,
- downloadedTracks,
- pendingDownloads: pending,
- downloadingDownloads: downloading,
- completedDownloads: completed,
- failedDownloads: failed,
- addToDownloadQueue,
- }
-}
-
-const NetworkContext = createContext({
- activeDownloads: {},
- pendingDownloads: [],
- downloadingDownloads: [],
- completedDownloads: [],
- failedDownloads: [],
- addToDownloadQueue: () => true,
-})
-
-export const NetworkContextProvider: ({
- children,
-}: {
- children: ReactNode
-}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
- const context = NetworkContextInitializer()
-
- const value = context
-
- return {children}
-}
-
-export const useNetworkContext = () => useContext(NetworkContext)
diff --git a/src/providers/Storage/index.tsx b/src/providers/Storage/index.tsx
index d34a4918..58af2c57 100644
--- a/src/providers/Storage/index.tsx
+++ b/src/providers/Storage/index.tsx
@@ -1,11 +1,11 @@
-import React, { PropsWithChildren, createContext, useContext, useState } from 'react'
+import React, { PropsWithChildren, createContext, use, useContext, useState } from 'react'
import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import {
DeleteDownloadsResult,
deleteDownloadsByIds,
} from '../../api/mutations/download/offlineModeUtils'
-import { useNetworkContext } from '../Network'
+import { useDownloadProgress } from '../../stores/network/downloads'
export type StorageSummary = {
totalSpace: number
@@ -67,7 +67,7 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
refetch: refetchStorageInfo,
isFetching: isFetchingStorage,
} = useStorageInUse()
- const { activeDownloads } = useNetworkContext()
+ const activeDownloads = useDownloadProgress()
const [selection, setSelection] = useState({})
const [isDeleting, setIsDeleting] = useState(false)
@@ -226,7 +226,7 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
}
export const useStorageContext = () => {
- const context = useContext(StorageContext)
+ const context = use(StorageContext)
if (!context) throw new Error('StorageContext must be used within a StorageProvider')
return context
}
diff --git a/src/screens/Settings/storage-management/index.tsx b/src/screens/Settings/storage-management/index.tsx
index b3dfe0d3..d5893b86 100644
--- a/src/screens/Settings/storage-management/index.tsx
+++ b/src/screens/Settings/storage-management/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useState } from 'react'
+import React, { useState } from 'react'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -47,62 +47,44 @@ export default function StorageManagementScreen(): React.JSX.Element {
const navigation = useNavigation>()
const showDeletionToast = useDeletionToast()
- useFocusEffect(
- useCallback(() => {
- void refresh()
- }, [refresh]),
- )
+ const sortedDownloads = !downloads
+ ? []
+ : [...downloads].sort(
+ (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
+ )
- const sortedDownloads = useMemo(() => {
- if (!downloads) return []
- return [...downloads].sort(
- (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
- )
- }, [downloads])
+ const selectedIds = Object.entries(selection)
+ .filter(([, isSelected]) => isSelected)
+ .map(([id]) => id)
- const selectedIds = useMemo(
- () =>
- Object.entries(selection)
- .filter(([, isSelected]) => isSelected)
- .map(([id]) => id),
- [selection],
- )
+ const selectedBytes =
+ !selectedIds.length || !downloads
+ ? 0
+ : downloads.reduce((total, download) => {
+ return new Set(selectedIds).has(download.item.Id as string)
+ ? total + getDownloadSize(download)
+ : total
+ }, 0)
- const selectedBytes = useMemo(() => {
- if (!selectedIds.length || !downloads) return 0
- const selectedSet = new Set(selectedIds)
- return downloads.reduce((total, download) => {
- return selectedSet.has(download.item.Id as string)
- ? total + getDownloadSize(download)
- : total
- }, 0)
- }, [downloads, selectedIds])
-
- const handleApplySuggestion = useCallback(
- async (suggestion: CleanupSuggestion) => {
- if (!suggestion.itemIds.length) return
- setApplyingSuggestionId(suggestion.id)
- try {
- const result = await deleteDownloads(suggestion.itemIds)
- if (result?.deletedCount)
- showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
- } finally {
- setApplyingSuggestionId(null)
- }
- },
- [deleteDownloads, showDeletionToast],
- )
-
- const handleDeleteSingle = useCallback(
- async (download: JellifyDownload) => {
- const result = await deleteDownloads([download.item.Id as string])
+ const handleApplySuggestion = async (suggestion: CleanupSuggestion) => {
+ if (!suggestion.itemIds.length) return
+ setApplyingSuggestionId(suggestion.id)
+ try {
+ const result = await deleteDownloads(suggestion.itemIds)
if (result?.deletedCount)
- showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
- },
- [deleteDownloads, showDeletionToast],
- )
+ showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
+ } finally {
+ setApplyingSuggestionId(null)
+ }
+ }
- const handleDeleteAll = useCallback(() => {
+ const handleDeleteSingle = async (download: JellifyDownload) => {
+ const result = await deleteDownloads([download.item.Id as string])
+ if (result?.deletedCount)
+ showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
+ }
+
+ const handleDeleteAll = () =>
Alert.alert(
'Delete all downloads?',
'This will remove all downloaded music from your device. This action cannot be undone.',
@@ -124,9 +106,8 @@ export default function StorageManagementScreen(): React.JSX.Element {
},
],
)
- }, [downloads, deleteDownloads, showDeletionToast])
- const handleDeleteSelection = useCallback(() => {
+ const handleDeleteSelection = () =>
Alert.alert(
'Delete selected items?',
`Are you sure you want to delete ${selectedIds.length} items?`,
@@ -148,20 +129,16 @@ export default function StorageManagementScreen(): React.JSX.Element {
},
],
)
- }, [selectedIds, deleteDownloads, showDeletionToast, clearSelection])
- const renderDownloadItem: ListRenderItem = useCallback(
- ({ item }) => (
- toggleSelection(item.item.Id as string)}
- onDelete={() => {
- void handleDeleteSingle(item)
- }}
- />
- ),
- [selection, toggleSelection, handleDeleteSingle],
+ const renderDownloadItem: ListRenderItem = ({ item }) => (
+ toggleSelection(item.item.Id as string)}
+ onDelete={() => {
+ void handleDeleteSingle(item)
+ }}
+ />
)
const topPadding = 16
diff --git a/src/stores/network/downloads.ts b/src/stores/network/downloads.ts
new file mode 100644
index 00000000..90634af4
--- /dev/null
+++ b/src/stores/network/downloads.ts
@@ -0,0 +1,137 @@
+import { mmkvStateStorage } from '../../constants/storage'
+import { JellifyDownloadProgress } from '@/src/types/JellifyDownload'
+import JellifyTrack from '@/src/types/JellifyTrack'
+import { mapDtoToTrack } from '../../utils/mappings'
+import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
+import { create } from 'zustand'
+import { createJSONStorage, devtools, persist } from 'zustand/middleware'
+import { useApi } from '..'
+import { useDownloadingDeviceProfile } from '../device-profile'
+
+type DownloadsStore = {
+ downloadProgress: JellifyDownloadProgress
+ setDownloadProgress: (progress: JellifyDownloadProgress) => void
+ pendingDownloads: JellifyTrack[]
+ setPendingDownloads: (items: JellifyTrack[]) => void
+ currentDownloads: JellifyTrack[]
+ setCurrentDownloads: (items: JellifyTrack[]) => void
+ completedDownloads: JellifyTrack[]
+ setCompletedDownloads: (items: JellifyTrack[]) => void
+ failedDownloads: JellifyTrack[]
+ setFailedDownloads: (items: JellifyTrack[]) => void
+}
+
+export const useDownloadsStore = create()(
+ devtools(
+ persist(
+ (set) => ({
+ downloadProgress: {},
+ setDownloadProgress: (progress) =>
+ set({
+ downloadProgress: progress,
+ }),
+ pendingDownloads: [],
+ setPendingDownloads: (items) =>
+ set({
+ pendingDownloads: items,
+ }),
+ currentDownloads: [],
+ setCurrentDownloads: (items) => set({ currentDownloads: items }),
+ completedDownloads: [],
+ setCompletedDownloads: (items) => set({ completedDownloads: items }),
+ failedDownloads: [],
+ setFailedDownloads: (items) => set({ failedDownloads: items }),
+ }),
+ {
+ name: 'downloads-store',
+ storage: createJSONStorage(() => mmkvStateStorage),
+ },
+ ),
+ ),
+)
+
+export const useDownloadProgress = () => useDownloadsStore((state) => state.downloadProgress)
+
+export const usePendingDownloads = () => useDownloadsStore((state) => state.pendingDownloads)
+
+export const useCurrentDownloads = () => useDownloadsStore((state) => state.currentDownloads)
+
+export const useFailedDownloads = () => useDownloadsStore((state) => state.failedDownloads)
+
+export const useIsDownloading = (items: BaseItemDto[]) => {
+ const pendingDownloads = usePendingDownloads()
+ const currentDownloads = useCurrentDownloads()
+
+ const downloadQueue = new Set([
+ ...pendingDownloads.map((download) => download.item.Id),
+ ...currentDownloads.map((download) => download.item.Id),
+ ])
+
+ const itemIds = items.map((item) => item.Id)
+
+ return itemIds.filter((id) => downloadQueue.has(id)).length === items.length
+}
+
+export const useAddToCompletedDownloads = () => {
+ const currentDownloads = useCurrentDownloads()
+ const setCompletedDownloads = useDownloadsStore((state) => state.setCompletedDownloads)
+
+ return (item: JellifyTrack) => setCompletedDownloads([...currentDownloads, item])
+}
+
+export const useAddToCurrentDownloads = () => {
+ const currentDownloads = useCurrentDownloads()
+ const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
+
+ return (item: JellifyTrack) => setCurrentDownloads([...currentDownloads, item])
+}
+
+export const useRemoveFromCurrentDownloads = () => {
+ const currentDownloads = useCurrentDownloads()
+
+ const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads)
+
+ return (item: JellifyTrack) =>
+ setCurrentDownloads(
+ currentDownloads.filter((download) => download.item.Id !== item.item.Id),
+ )
+}
+
+export const useRemoveFromPendingDownloads = () => {
+ const pendingDownloads = usePendingDownloads()
+
+ const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
+
+ return (item: JellifyTrack) =>
+ setPendingDownloads(
+ pendingDownloads.filter((download) => download.item.Id !== item.item.Id),
+ )
+}
+
+export const useAddToFailedDownloads = () => (item: JellifyTrack) => {
+ const failedDownloads = useFailedDownloads()
+
+ const setFailedDownloads = useDownloadsStore((state) => state.setFailedDownloads)
+
+ return setFailedDownloads([...failedDownloads, item])
+}
+
+const useAddToPendingDownloads = () => {
+ const api = useApi()
+
+ const downloadingDeviceProfile = useDownloadingDeviceProfile()
+
+ const pendingDownloads = usePendingDownloads()
+
+ const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads)
+
+ return (items: BaseItemDto[]) => {
+ const downloads = api
+ ? items.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile))
+ : []
+
+ return setPendingDownloads([...pendingDownloads, ...downloads])
+ }
+}
+
+export default useAddToPendingDownloads
diff --git a/src/stores/network.ts b/src/stores/network/index.ts
similarity index 89%
rename from src/stores/network.ts
rename to src/stores/network/index.ts
index 4a995269..a72118ad 100644
--- a/src/stores/network.ts
+++ b/src/stores/network/index.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
-import { networkStatusTypes } from '../components/Network/internetConnectionWatcher'
+import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
type NetworkStore = {
networkStatus: networkStatusTypes | null
From 93c465feccf47f4e0edcb01179f9bf8fb70ac963 Mon Sep 17 00:00:00 2001
From: anultravioletaurora
Date: Wed, 3 Dec 2025 12:14:10 +0000
Subject: [PATCH 12/16] [skip actions] version bump
---
android/app/build.gradle | 4 ++--
ios/Jellify.xcodeproj/project.pbxproj | 12 ++++++------
package.json | 2 +-
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index bfbf4f9b..c0f489b3 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 156
- versionName "0.22.0"
+ versionCode 157
+ versionName "0.22.1"
}
signingConfigs {
debug {
diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj
index 98834729..95712e53 100644
--- a/ios/Jellify.xcodeproj/project.pbxproj
+++ b/ios/Jellify.xcodeproj/project.pbxproj
@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 265;
+ CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.22.0;
+ MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 265;
+ CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.22.0;
+ MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -821,7 +821,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 265;
+ CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -832,7 +832,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.22.0;
+ MARKETING_VERSION = 0.22.1;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
diff --git a/package.json b/package.json
index f947a874..817addb1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jellify",
- "version": "0.22.0",
+ "version": "0.22.1",
"private": true,
"scripts": {
"init-android": "bun i",
From f2761e3d8850696d2b59c033d2e01cc7df90ebef Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Wed, 3 Dec 2025 13:50:15 -0600
Subject: [PATCH 13/16] add store links to readme
---
README.md | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index e8bb8537..fea07e2f 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://github.com/anultravioletaurora/Jellify/releases)
-[](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
+[](https://github.com/anultravioletaurora/Jellify/releases) [](https://apps.apple.com/us/app/jellify/id6736884612) [](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
+
[](https://github.com/sponsors/anultravioletaurora) [](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink)
@@ -65,6 +65,10 @@ These projects are **not** required to use _Jellify_, but are recommended by us
### Android
+[](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
+
+#### Direct .APK Download
+
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly.
Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository.
@@ -73,6 +77,8 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the
### iOS
+[](https://apps.apple.com/us/app/jellify/id6736884612)
+
#### The TestFlight Way
Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there
From 238dd0340a693203b3caaaa30c405150cba7f96c Mon Sep 17 00:00:00 2001
From: skalthoff <32023561+skalthoff@users.noreply.github.com>
Date: Wed, 3 Dec 2025 18:07:30 -0800
Subject: [PATCH 14/16] 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 1c63b748b66a193ff99bb30b9b574801ef6717a8.
* 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 f9e0e82e579e091fa3b8dc286bef08ccc8907d68.
* Reapply "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads"
This reverts commit 6710d3404ce9a3366fd0be3fde7f70e32bbf276a.
* 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
---
App.tsx | 80 +++++++-------
bun.lock | 5 +-
.../mutations/download/offlineModeUtils.ts | 52 +++++++--
src/api/queries/image/utils/index.ts | 23 ++++
src/api/queries/media/index.ts | 2 +
src/components/Artist/TabBar.tsx | 7 +-
src/components/Artist/header.tsx | 1 +
.../Global/components/horizontal-list.tsx | 9 +-
src/components/Global/components/image.tsx | 15 ++-
.../Global/components/item-card.tsx | 2 +-
src/components/Global/components/item-row.tsx | 28 +++--
src/components/Global/components/track.tsx | 102 ++++++++++++------
.../Home/helpers/frequent-artists.tsx | 4 +-
.../Home/helpers/frequent-tracks.tsx | 4 +-
.../Home/helpers/recent-artists.tsx | 6 +-
.../Home/helpers/recently-played.tsx | 4 +-
src/components/Player/components/header.tsx | 6 +-
src/components/Player/components/lyrics.tsx | 28 +++--
src/components/Player/mini-player.tsx | 7 +-
src/components/Playlists/component.tsx | 38 +++++--
src/constants/versioned-storage.ts | 74 +++++++++++++
src/hooks/use-performance-monitor.ts | 11 ++
src/player/config.ts | 12 +++
src/player/types/queue-item.ts | 15 +--
src/providers/Player/hooks/queries.ts | 25 ++++-
src/screens/Home/types.d.ts | 19 +---
src/stores/player/engine.ts | 16 +--
src/stores/player/queue.ts | 99 ++++++++++++++++-
src/types/JellifyTrack.ts | 43 ++++++++
29 files changed, 562 insertions(+), 175 deletions(-)
create mode 100644 src/constants/versioned-storage.ts
diff --git a/App.tsx b/App.tsx
index e576beba..367c3326 100644
--- a/App.tsx
+++ b/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(false)
+ const playerInitializedRef = useRef(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)
diff --git a/bun.lock b/bun.lock
index 740f555c..6886cf7e 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/src/api/mutations/download/offlineModeUtils.ts b/src/api/mutations/download/offlineModeUtils.ts
index 9756099c..97e886dd 100644
--- a/src/api/mutations/download/offlineModeUtils.ts
+++ b/src/api/mutations/download/offlineModeUtils.ts
@@ -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 {
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
diff --git a/src/api/queries/image/utils/index.ts b/src/api/queries/image/utils/index.ts
index f9e6014d..3132112d 100644
--- a/src/api/queries/image/utils/index.ts
+++ b/src/api/queries/image/utils/index.ts
@@ -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
diff --git a/src/api/queries/media/index.ts b/src/api/queries/media/index.ts
index 6121d5fa..adceddd7 100644
--- a/src/api/queries/media/index.ts
+++ b/src/api/queries/media/index.ts
@@ -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,
})
diff --git a/src/components/Artist/TabBar.tsx b/src/components/Artist/TabBar.tsx
index b2ae418a..f4612f12 100644
--- a/src/components/Artist/TabBar.tsx
+++ b/src/components/Artist/TabBar.tsx
@@ -77,11 +77,12 @@ export default function ArtistTabBar({
>
-
+ />{' '}
{sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'}
diff --git a/src/components/Artist/header.tsx b/src/components/Artist/header.tsx
index 4add85de..a542dbf5 100644
--- a/src/components/Artist/header.tsx
+++ b/src/components/Artist/header.tsx
@@ -76,6 +76,7 @@ export default function ArtistHeader(): React.JSX.Element {
height={'$20'}
type={ImageType.Backdrop}
cornered
+ imageOptions={{ maxWidth: width * 2, maxHeight: 640 }}
/>
diff --git a/src/components/Global/components/horizontal-list.tsx b/src/components/Global/components/horizontal-list.tsx
index 1530169d..b98d36f8 100644
--- a/src/components/Global/components/horizontal-list.tsx
+++ b/src/components/Global/components/horizontal-list.tsx
@@ -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 {}
+type HorizontalCardListProps = Omit, 'estimatedItemSize'> & {
+ estimatedItemSize?: number
+}
/**
* Displays a Horizontal FlatList of 20 ItemCards
@@ -13,14 +15,17 @@ interface HorizontalCardListProps extends FlashListProps {}
export default function HorizontalCardList({
data,
renderItem,
+ estimatedItemSize = 150,
...props
}: HorizontalCardListProps): React.JSX.Element {
return (
-
horizontal
data={data}
renderItem={renderItem}
removeClippedSubviews
+ // @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
+ estimatedItemSize={estimatedItemSize}
style={{
overflow: 'hidden',
}}
diff --git a/src/components/Global/components/image.tsx b/src/components/Global/components/image.tsx
index b0109c70..7361f087 100644
--- a/src/components/Global/components/image.tsx
+++ b/src/components/Global/components/image.tsx
@@ -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 ? (
{
if (item.Type === 'Audio') warmContext(item)
- }, [item.Id, warmContext])
+ }, [item.Id, item.Type, warmContext])
const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress])
diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx
index c9de8d2b..24aa63d6 100644
--- a/src/components/Global/components/item-row.tsx
+++ b/src/components/Global/components/item-row.tsx
@@ -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, '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(
)
},
- (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(
diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx
index 95ab108d..980a1c90 100644
--- a/src/components/Global/components/track.tsx
+++ b/src/components/Global/components/track.tsx
@@ -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()
+
+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 ? (
diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx
index a8b1410e..6d096c05 100644
--- a/src/components/Home/helpers/frequent-artists.tsx
+++ b/src/components/Home/helpers/frequent-artists.tsx
@@ -55,9 +55,7 @@ export default function FrequentArtists(): React.JSX.Element {
{
- navigation.navigate('MostPlayedArtists', {
- artistsInfiniteQuery: frequentArtistsInfiniteQuery,
- })
+ navigation.navigate('MostPlayedArtists')
}}
>
Most Played
diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx
index 851072a9..feafa02c 100644
--- a/src/components/Home/helpers/frequent-tracks.tsx
+++ b/src/components/Home/helpers/frequent-tracks.tsx
@@ -43,9 +43,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
{
- navigation.navigate('MostPlayedTracks', {
- tracksInfiniteQuery,
- })
+ navigation.navigate('MostPlayedTracks')
}}
>
On Repeat
diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx
index 1d9fbbac..7336e209 100644
--- a/src/components/Home/helpers/recent-artists.tsx
+++ b/src/components/Home/helpers/recent-artists.tsx
@@ -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 }) => (
diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx
index 7da08dda..37368a02 100644
--- a/src/components/Home/helpers/recently-played.tsx
+++ b/src/components/Home/helpers/recently-played.tsx
@@ -44,9 +44,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
{
- navigation.navigate('RecentTracks', {
- tracksInfiniteQuery,
- })
+ navigation.navigate('RecentTracks')
}}
>
Play it again
diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx
index d5a87d34..e066ac0a 100644
--- a/src/components/Player/components/header.tsx
+++ b/src/components/Player/components/header.tsx
@@ -93,7 +93,11 @@ function PlayerArtwork(): React.JSX.Element {
...animatedStyle,
}}
>
-
+
)}
diff --git a/src/components/Player/components/lyrics.tsx b/src/components/Player/components/lyrics.tsx
index 2f91cde3..063a1a07 100644
--- a/src/components/Player/components/lyrics.tsx
+++ b/src/components/Player/components/lyrics.tsx
@@ -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(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(() => {
diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx
index 44b3e931..0d3d65fd 100644
--- a/src/components/Player/mini-player.tsx
+++ b/src/components/Player/mini-player.tsx
@@ -99,7 +99,12 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-album-image`}
>
-
+
diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx
index 2610e2f9..9ac69660 100644
--- a/src/components/Playlists/component.tsx
+++ b/src/components/Playlists/component.tsx
@@ -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
+}
+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>()
+ // 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 }) => (
+
+ ),
+ [navigation],
+ )
+
+ // Memoized end reached handler
+ const handleEndReached = useCallback(() => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ }, [hasNextPage, fetchNextPage])
+
return (
}
- ItemSeparatorComponent={() => }
- renderItem={({ index, item: playlist }) => (
-
- )}
- onEndReached={() => {
- if (hasNextPage) {
- fetchNextPage()
- }
- }}
+ ItemSeparatorComponent={ListSeparator}
+ renderItem={renderItem}
+ onEndReached={handleEndReached}
removeClippedSubviews
/>
)
diff --git a/src/constants/versioned-storage.ts b/src/constants/versioned-storage.ts
new file mode 100644
index 00000000..918d4d9a
--- /dev/null
+++ b/src/constants/versioned-storage.ts
@@ -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 = {
+ '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')
+}
diff --git a/src/hooks/use-performance-monitor.ts b/src/hooks/use-performance-monitor.ts
index f39f6e26..86680e06 100644
--- a/src/hooks/use-performance-monitor.ts
+++ b/src/hooks/use-performance-monitor.ts
@@ -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([])
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
diff --git a/src/player/config.ts b/src/player/config.ts
index 92dd8fe1..55f50fae 100644
--- a/src/player/config.ts
+++ b/src/player/config.ts
@@ -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
+ }
+ : {}
diff --git a/src/player/types/queue-item.ts b/src/player/types/queue-item.ts
index 739440c5..cf4eb3dc 100644
--- a/src/player/types/queue-item.ts
+++ b/src/player/types/queue-item.ts
@@ -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
diff --git a/src/providers/Player/hooks/queries.ts b/src/providers/Player/hooks/queries.ts
index 9727ca19..f08dbdc7 100644
--- a/src/providers/Player/hooks/queries.ts
+++ b/src/providers/Player/hooks/queries.ts
@@ -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)
- 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
diff --git a/src/screens/Home/types.d.ts b/src/screens/Home/types.d.ts
index 4fa3dd99..ebc76964 100644
--- a/src/screens/Home/types.d.ts
+++ b/src/screens/Home/types.d.ts
@@ -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
- }
- MostPlayedArtists: {
- artistsInfiniteQuery: UseInfiniteQueryResult
- }
- RecentTracks: {
- tracksInfiniteQuery: UseInfiniteQueryResult
- }
- MostPlayedTracks: {
- tracksInfiniteQuery: UseInfiniteQueryResult
- }
+ RecentArtists: undefined
+ MostPlayedArtists: undefined
+ RecentTracks: undefined
+ MostPlayedTracks: undefined
}
export default HomeStackParamList
diff --git a/src/stores/player/engine.ts b/src/stores/player/engine.ts
index c15d0f91..2da9c97a 100644
--- a/src/stores/player/engine.ts
+++ b/src/stores/player/engine.ts
@@ -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()(
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
diff --git a/src/stores/player/queue.ts b/src/stores/player/queue.ts
index af7bad26..68f64756 100644
--- a/src/stores/player/queue.ts
+++ b/src/stores/player/queue.ts
@@ -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 = {
+ 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
+ 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 = {
+ ...value,
+ state: persistedState,
+ }
+
+ storage.setItem(name, JSON.stringify(toStore))
+ },
+ removeItem: (name) => {
+ const storage = createVersionedMmkvStorage('player-queue-storage')
+ storage.removeItem(name)
+ },
+}
+
export const usePlayerQueueStore = create()(
devtools(
persist(
@@ -71,7 +162,7 @@ export const usePlayerQueueStore = create()(
}),
{
name: 'player-queue-storage',
- storage: createJSONStorage(() => mmkvStateStorage),
+ storage: queueStorage,
},
),
),
diff --git a/src/types/JellifyTrack.ts b/src/types/JellifyTrack.ts
index 8b27edab..98450946 100644
--- a/src/types/JellifyTrack.ts
+++ b/src/types/JellifyTrack.ts
@@ -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 & {
+ /** Store only essential media source fields for persistence */
+ mediaSourceInfo?: Pick | 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
From cadec335b073fda37c61bc62a7f84657d9c7082d Mon Sep 17 00:00:00 2001
From: skalthoff <32023561+skalthoff@users.noreply.github.com>
Date: Wed, 3 Dec 2025 18:33:47 -0800
Subject: [PATCH 15/16] fix swipeable row conflicts and closure on scroll for
Albums, Artists, Library, and Playlists components (#747)
---
src/components/Albums/component.tsx | 2 ++
src/components/Artists/component.tsx | 2 ++
src/components/Library/component.tsx | 1 +
src/components/Playlists/component.tsx | 1 +
4 files changed, 6 insertions(+)
diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx
index 42624e4e..02d04583 100644
--- a/src/components/Albums/component.tsx
+++ b/src/components/Albums/component.tsx
@@ -13,6 +13,7 @@ import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetic
import { isString } from 'lodash'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
+import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -144,6 +145,7 @@ export default function Albums({
ItemSeparatorComponent={ItemSeparatorComponent}
refreshControl={refreshControl}
stickyHeaderIndices={stickyHeaderIndices}
+ onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
/>
diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx
index 89c0533f..33d0eb0d 100644
--- a/src/components/Artists/component.tsx
+++ b/src/components/Artists/component.tsx
@@ -13,6 +13,7 @@ import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
+import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult<
@@ -155,6 +156,7 @@ export default function Artists({
if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
artistsInfiniteQuery.fetchNextPage()
}}
+ onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
/>
diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx
index 5225756e..d7c2d639 100644
--- a/src/components/Library/component.tsx
+++ b/src/components/Library/component.tsx
@@ -21,6 +21,7 @@ export default function LibraryScreen({
}
screenOptions={{
+ swipeEnabled: false, // Disable tab swiping to prevent conflicts with SwipeableRow gestures
tabBarIndicatorStyle: {
borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'),
diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx
index 9ac69660..8a8f1d8c 100644
--- a/src/components/Playlists/component.tsx
+++ b/src/components/Playlists/component.tsx
@@ -8,6 +8,7 @@ import { FetchNextPageOptions } from '@tanstack/react-query'
import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '@/src/screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
+import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
// Extracted as stable component to prevent recreation on each render
function ListSeparatorComponent(): React.JSX.Element {
From af5c02c71a669219b0fb8d97f7ba482e89ba6b01 Mon Sep 17 00:00:00 2001
From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
Date: Wed, 3 Dec 2025 20:42:46 -0600
Subject: [PATCH 16/16] remove memoization since we're using the compiler
(#746)
* remove memoization since we're using the compiler
* remove memoization on the track and item rows
* remove memoization from artists list
* remove additional memoization
---
src/components/Albums/component.tsx | 79 ++-
src/components/Artists/component.tsx | 68 +--
.../components/alphabetical-selector.tsx | 132 +++--
src/components/Global/components/item-row.tsx | 524 ++++++++----------
src/components/Global/components/track.tsx | 509 +++++++----------
5 files changed, 576 insertions(+), 736 deletions(-)
diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx
index 02d04583..d12e4e7b 100644
--- a/src/components/Albums/component.tsx
+++ b/src/components/Albums/component.tsx
@@ -1,6 +1,6 @@
-import { ActivityIndicator, RefreshControl } from 'react-native'
+import { RefreshControl } from 'react-native'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
-import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
+import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
@@ -39,55 +39,52 @@ export default function Albums({
const pendingLetterRef = useRef(null)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
- const stickyHeaderIndices = React.useMemo(() => {
- if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return []
-
- return albumsInfiniteQuery.data
- .map((album, index) => (typeof album === 'string' ? index : 0))
- .filter((value, index, indices) => indices.indexOf(value) === index)
- }, [showAlphabeticalSelector, albumsInfiniteQuery.data])
+ const stickyHeaderIndices =
+ !showAlphabeticalSelector || !albumsInfiniteQuery.data
+ ? []
+ : albumsInfiniteQuery.data
+ .map((album, index) => (typeof album === 'string' ? index : 0))
+ .filter((value, index, indices) => indices.indexOf(value) === index)
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
- const refreshControl = useMemo(
- () => (
-
- ),
- [albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch],
+ const refreshControl = (
+
)
- const ItemSeparatorComponent = useCallback(
- ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
- typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
-
- ),
- [],
- )
+ const ItemSeparatorComponent = ({
+ leadingItem,
+ trailingItem,
+ }: {
+ leadingItem: unknown
+ trailingItem: unknown
+ }) =>
+ typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null :
- const keyExtractor = useCallback(
- (item: BaseItemDto | string | number) =>
- typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
- [],
- )
+ const keyExtractor = (item: BaseItemDto | string | number) =>
+ typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
- const renderItem = useCallback(
- ({ index, item: album }: { index: number; item: BaseItemDto | string | number }) =>
- typeof album === 'string' ? (
-
- ) : typeof album === 'number' ? null : typeof album === 'object' ? (
-
- ) : null,
- [navigation],
- )
+ const renderItem = ({
+ index,
+ item: album,
+ }: {
+ index: number
+ item: BaseItemDto | string | number
+ }) =>
+ typeof album === 'string' ? (
+
+ ) : typeof album === 'number' ? null : typeof album === 'object' ? (
+
+ ) : null
- const onEndReached = useCallback(() => {
+ const onEndReached = () => {
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
- }, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage])
+ }
// Effect for handling the pending alphabet selector letter
useEffect(() => {
diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx
index 33d0eb0d..369a606a 100644
--- a/src/components/Artists/component.tsx
+++ b/src/components/Artists/component.tsx
@@ -1,5 +1,5 @@
-import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
-import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui'
+import React, { RefObject, useEffect, useRef } from 'react'
+import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row'
@@ -50,41 +50,41 @@ export default function Artists({
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
- const stickyHeaderIndices = useMemo(() => {
- if (!showAlphabeticalSelector || !artists) return []
+ const stickyHeaderIndices =
+ !showAlphabeticalSelector || !artists
+ ? []
+ : artists
+ .map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
+ .filter((value, index, indices) => indices.indexOf(value) === index)
- return artists
- .map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
- .filter((value, index, indices) => indices.indexOf(value) === index)
- }, [showAlphabeticalSelector, artists])
+ const ItemSeparatorComponent = ({
+ leadingItem,
+ trailingItem,
+ }: {
+ leadingItem: unknown
+ trailingItem: unknown
+ }) =>
+ typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null :
- const ItemSeparatorComponent = useCallback(
- ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
- typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
-
- ),
- [],
- )
+ const KeyExtractor = (item: BaseItemDto | string | number, index: number) =>
+ typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
- const KeyExtractor = useCallback(
- (item: BaseItemDto | string | number, index: number) =>
- typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
- [],
- )
-
- const renderItem = useCallback(
- ({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) =>
- typeof artist === 'string' ? (
- // Don't render the letter if we don't have any artists that start with it
- // If the index is the last index, or the next index is not an object, then don't render the letter
- index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
-
- )
- ) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
-
- ) : null,
- [navigation],
- )
+ const renderItem = ({
+ index,
+ item: artist,
+ }: {
+ index: number
+ item: BaseItemDto | number | string
+ }) =>
+ typeof artist === 'string' ? (
+ // Don't render the letter if we don't have any artists that start with it
+ // If the index is the last index, or the next index is not an object, then don't render the letter
+ index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
+
+ )
+ ) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
+
+ ) : null
// Effect for handling the pending alphabet selector letter
useEffect(() => {
diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx
index 5dbcfa6d..b81de253 100644
--- a/src/components/Global/components/alphabetical-selector.tsx
+++ b/src/components/Global/components/alphabetical-selector.tsx
@@ -1,4 +1,4 @@
-import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'
+import React, { RefObject, useEffect, useRef, useState } from 'react'
import { LayoutChangeEvent, Platform, View as RNView } from 'react-native'
import { getToken, Spinner, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -61,78 +61,70 @@ export default function AZScroller({
})
}
- const panGesture = useMemo(
- () =>
- Gesture.Pan()
- .runOnJS(true)
- .onBegin((e) => {
- const relativeY = e.absoluteY - alphabetSelectorTopY.current
- setOverlayPositionY(relativeY - letterHeight.current * 1.5)
- const index = Math.floor(relativeY / letterHeight.current)
- if (alphabet[index]) {
- const letter = alphabet[index]
- selectedLetter.value = letter
- setOverlayLetter(letter)
- scheduleOnRN(showOverlay)
- }
- })
- .onUpdate((e) => {
- const relativeY = e.absoluteY - alphabetSelectorTopY.current
- setOverlayPositionY(relativeY - letterHeight.current * 1.5)
- const index = Math.floor(relativeY / letterHeight.current)
- if (alphabet[index]) {
- const letter = alphabet[index]
- selectedLetter.value = letter
- setOverlayLetter(letter)
- scheduleOnRN(showOverlay)
- }
- })
- .onEnd(() => {
- if (selectedLetter.value) {
- scheduleOnRN(async () => {
- setOperationPending(true)
- onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
- scheduleOnRN(hideOverlay)
- setOperationPending(false)
- })
- })
- } else {
+ const panGesture = Gesture.Pan()
+ .runOnJS(true)
+ .onBegin((e) => {
+ const relativeY = e.absoluteY - alphabetSelectorTopY.current
+ setOverlayPositionY(relativeY - letterHeight.current * 1.5)
+ const index = Math.floor(relativeY / letterHeight.current)
+ if (alphabet[index]) {
+ const letter = alphabet[index]
+ selectedLetter.value = letter
+ setOverlayLetter(letter)
+ scheduleOnRN(showOverlay)
+ }
+ })
+ .onUpdate((e) => {
+ const relativeY = e.absoluteY - alphabetSelectorTopY.current
+ setOverlayPositionY(relativeY - letterHeight.current * 1.5)
+ const index = Math.floor(relativeY / letterHeight.current)
+ if (alphabet[index]) {
+ const letter = alphabet[index]
+ selectedLetter.value = letter
+ setOverlayLetter(letter)
+ scheduleOnRN(showOverlay)
+ }
+ })
+ .onEnd(() => {
+ if (selectedLetter.value) {
+ scheduleOnRN(async () => {
+ setOperationPending(true)
+ onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
- }
- }),
- [onLetterSelect],
- )
+ setOperationPending(false)
+ })
+ })
+ } else {
+ scheduleOnRN(hideOverlay)
+ }
+ })
- const tapGesture = useMemo(
- () =>
- Gesture.Tap()
- .runOnJS(true)
- .onBegin((e) => {
- const relativeY = e.absoluteY - alphabetSelectorTopY.current
- setOverlayPositionY(relativeY - letterHeight.current * 1.5)
- const index = Math.floor(relativeY / letterHeight.current)
- if (alphabet[index]) {
- const letter = alphabet[index]
- selectedLetter.value = letter
- setOverlayLetter(letter)
- scheduleOnRN(showOverlay)
- }
- })
- .onEnd(() => {
- if (selectedLetter.value) {
- scheduleOnRN(async () => {
- setOperationPending(true)
- onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
- scheduleOnRN(hideOverlay)
- setOperationPending(false)
- })
- })
- } else {
+ const tapGesture = Gesture.Tap()
+ .runOnJS(true)
+ .onBegin((e) => {
+ const relativeY = e.absoluteY - alphabetSelectorTopY.current
+ setOverlayPositionY(relativeY - letterHeight.current * 1.5)
+ const index = Math.floor(relativeY / letterHeight.current)
+ if (alphabet[index]) {
+ const letter = alphabet[index]
+ selectedLetter.value = letter
+ setOverlayLetter(letter)
+ scheduleOnRN(showOverlay)
+ }
+ })
+ .onEnd(() => {
+ if (selectedLetter.value) {
+ scheduleOnRN(async () => {
+ setOperationPending(true)
+ onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
- }
- }),
- [onLetterSelect],
- )
+ setOperationPending(false)
+ })
+ })
+ } else {
+ scheduleOnRN(hideOverlay)
+ }
+ })
const gesture = Gesture.Simultaneous(panGesture, tapGesture)
diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx
index 24aa63d6..81473e31 100644
--- a/src/components/Global/components/item-row.tsx
+++ b/src/components/Global/components/item-row.tsx
@@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
-import React, { memo, useCallback, useMemo, useState } from 'react'
+import React from 'react'
import { LayoutChangeEvent } from 'react-native'
import Animated, {
SharedValue,
@@ -51,322 +51,264 @@ interface ItemRowProps {
* @param navigation - The navigation object.
* @returns
*/
-const ItemRow = memo(
- function ItemRow({
- item,
- circular,
- navigation,
- onPress,
- queueName,
- }: ItemRowProps): React.JSX.Element {
- const artworkAreaWidth = useSharedValue(0)
+function ItemRow({
+ item,
+ circular,
+ navigation,
+ onPress,
+ queueName,
+}: ItemRowProps): React.JSX.Element {
+ const artworkAreaWidth = useSharedValue(0)
- const api = useApi()
+ const api = useApi()
- const [networkStatus] = useNetworkStatus()
+ const [networkStatus] = useNetworkStatus()
- const deviceProfile = useStreamingDeviceProfile()
+ const deviceProfile = useStreamingDeviceProfile()
- const loadNewQueue = useLoadNewQueue()
- const addToQueue = useAddToQueue()
- const { mutate: addFavorite } = useAddFavorite()
- const { mutate: removeFavorite } = useRemoveFavorite()
- const [hideRunTimes] = useHideRunTimesSetting()
+ const loadNewQueue = useLoadNewQueue()
+ const addToQueue = useAddToQueue()
+ const { mutate: addFavorite } = useAddFavorite()
+ const { mutate: removeFavorite } = useRemoveFavorite()
+ const [hideRunTimes] = useHideRunTimesSetting()
- const warmContext = useItemContext()
- const { data: isFavorite } = useIsFavorite(item)
+ const warmContext = useItemContext()
+ const { data: isFavorite } = useIsFavorite(item)
- const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id])
+ const onPressIn = () => warmContext(item)
- const onLongPress = useCallback(
- () =>
- navigationRef.navigate('Context', {
- item,
- navigation,
- }),
- [navigationRef, navigation, item.Id],
- )
+ const onLongPress = () =>
+ navigationRef.navigate('Context', {
+ item,
+ navigation,
+ })
- const onPressCallback = useCallback(async () => {
- if (onPress) await onPress()
- else
- switch (item.Type) {
- case 'Audio': {
- loadNewQueue({
- api,
- networkStatus,
- deviceProfile,
- track: item,
- tracklist: [item],
- index: 0,
- queue: queueName ?? 'Search',
- queuingType: QueuingType.FromSelection,
- startPlayback: true,
- })
- break
- }
- case 'MusicArtist': {
- navigation?.navigate('Artist', { artist: item })
- break
- }
-
- case 'MusicAlbum': {
- navigation?.navigate('Album', { album: item })
- break
- }
-
- case 'Playlist': {
- navigation?.navigate('Playlist', { playlist: item, canEdit: true })
- break
- }
- default: {
- break
- }
- }
- }, [onPress, loadNewQueue, item.Id, navigation, queueName])
-
- const renderRunTime = useMemo(
- () => item.Type === BaseItemKind.Audio && !hideRunTimes,
- [item.Type, hideRunTimes],
- )
-
- const isAudio = useMemo(() => item.Type === 'Audio', [item.Type])
-
- const playlistTrackCount = useMemo(
- () => (item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined),
- [item.Type, item.SongCount, item.ChildCount],
- )
-
- const leftSettings = useSwipeSettingsStore((s) => s.left)
- const rightSettings = useSwipeSettingsStore((s) => s.right)
-
- const swipeHandlers = useCallback(
- () => ({
- addToQueue: async () =>
- await addToQueue({
+ const onPressCallback = async () => {
+ if (onPress) await onPress()
+ else
+ switch (item.Type) {
+ case 'Audio': {
+ loadNewQueue({
api,
- deviceProfile,
networkStatus,
- tracks: [item],
- queuingType: QueuingType.DirectlyQueued,
- }),
- toggleFavorite: () =>
- isFavorite ? removeFavorite({ item }) : addFavorite({ item }),
- addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
- }),
- [
- addToQueue,
+ deviceProfile,
+ track: item,
+ tracklist: [item],
+ index: 0,
+ queue: queueName ?? 'Search',
+ queuingType: QueuingType.FromSelection,
+ startPlayback: true,
+ })
+ break
+ }
+ case 'MusicArtist': {
+ navigation?.navigate('Artist', { artist: item })
+ break
+ }
+
+ case 'MusicAlbum': {
+ navigation?.navigate('Album', { album: item })
+ break
+ }
+
+ case 'Playlist': {
+ navigation?.navigate('Playlist', { playlist: item, canEdit: true })
+ break
+ }
+ default: {
+ break
+ }
+ }
+ }
+
+ const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes
+
+ const isAudio = item.Type === 'Audio'
+
+ const playlistTrackCount =
+ item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined
+
+ const leftSettings = useSwipeSettingsStore((s) => s.left)
+ const rightSettings = useSwipeSettingsStore((s) => s.right)
+
+ const swipeHandlers = () => ({
+ addToQueue: async () =>
+ await addToQueue({
api,
deviceProfile,
networkStatus,
- item,
- addFavorite,
- removeFavorite,
- isFavorite,
- ],
- )
+ tracks: [item],
+ queuingType: QueuingType.DirectlyQueued,
+ }),
+ toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
+ addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
+ })
- const swipeConfig = useMemo(
- () =>
- isAudio
- ? buildSwipeConfig({
- left: leftSettings,
- right: rightSettings,
- handlers: swipeHandlers(),
- })
- : {},
- [isAudio, leftSettings, rightSettings, swipeHandlers],
- )
+ const swipeConfig = isAudio
+ ? buildSwipeConfig({
+ left: leftSettings,
+ right: rightSettings,
+ handlers: swipeHandlers(),
+ })
+ : {}
- const handleArtworkLayout = useCallback(
- (event: LayoutChangeEvent) => {
- const { width } = event.nativeEvent.layout
- artworkAreaWidth.value = width
- },
- [artworkAreaWidth],
- )
+ const handleArtworkLayout = (event: LayoutChangeEvent) => {
+ const { width } = event.nativeEvent.layout
+ artworkAreaWidth.value = width
+ }
- const pressStyle = useMemo(() => ({ opacity: 0.5 }), [])
+ const pressStyle = {
+ opacity: 0.5,
+ }
- return (
-
+
-
-
-
-
-
-
-
- {renderRunTime ? (
- {item.RunTimeTicks}
- ) : item.Type === 'Playlist' ? (
-
- {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
-
- ) : null}
-
-
- {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
-
- ) : null}
-
-
-
- )
- },
- (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(
- function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
- const route = useRoute>()
-
- const shouldRenderArtistName =
- item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
-
- const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
-
- const shouldRenderGenres =
- item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
-
- return (
-
-
- {item.Name ?? ''}
-
-
- {shouldRenderArtistName && (
-
- {item.AlbumArtist ?? 'Untitled Artist'}
-
- )}
-
- {shouldRenderProductionYear && (
-
-
- {item.ProductionYear?.toString() ?? 'Unknown Year'}
-
-
- •
+
+
+
+
+
+ {renderRunTime ? (
{item.RunTimeTicks}
-
- )}
+ ) : item.Type === 'Playlist' ? (
+
+ {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
+
+ ) : null}
+
- {shouldRenderGenres && item.Genres && (
+ {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
+
+ ) : null}
+
+
+
+ )
+}
+
+function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
+ const route = useRoute>()
+
+ const shouldRenderArtistName =
+ item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
+
+ const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
+
+ const shouldRenderGenres = item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
+
+ return (
+
+
+ {item.Name ?? ''}
+
+
+ {shouldRenderArtistName && (
+
+ {item.AlbumArtist ?? 'Untitled Artist'}
+
+ )}
+
+ {shouldRenderProductionYear && (
+
- {item.Genres?.join(', ') ?? ''}
+ {item.ProductionYear?.toString() ?? 'Unknown Year'}
- )}
-
- )
- },
- (prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id,
-)
+
+ •
+
+ {item.RunTimeTicks}
+
+ )}
+
+ {shouldRenderGenres && item.Genres && (
+
+ {item.Genres?.join(', ') ?? ''}
+
+ )}
+
+ )
+}
// Artwork wrapper that fades out when the quick-action menu is open
-const HideableArtwork = memo(
- function HideableArtwork({
- item,
- circular,
- onLayout,
- }: {
- item: BaseItemDto
- circular?: boolean
- onLayout?: (event: LayoutChangeEvent) => void
- }): React.JSX.Element {
- const { tx } = useSwipeableRowContext()
- // Hide artwork as soon as swiping starts (any non-zero tx)
- const style = useAnimatedStyle(() => ({
- opacity: tx.value === 0 ? withTiming(1) : 0,
- }))
- return (
-
-
-
-
-
- )
- },
- (prevProps, nextProps) =>
- prevProps.item.Id === nextProps.item.Id &&
- prevProps.circular === nextProps.circular &&
- !!prevProps.onLayout === !!nextProps.onLayout,
-)
+function HideableArtwork({
+ item,
+ circular,
+ onLayout,
+}: {
+ item: BaseItemDto
+ circular?: boolean
+ onLayout?: (event: LayoutChangeEvent) => void
+}): React.JSX.Element {
+ const { tx } = useSwipeableRowContext()
+ // Hide artwork as soon as swiping starts (any non-zero tx)
+ const style = useAnimatedStyle(() => ({
+ opacity: tx.value === 0 ? withTiming(1) : 0,
+ }))
+ return (
+
+
+
+
+
+ )
+}
-const SlidingTextArea = memo(
- function SlidingTextArea({
- leftGapWidth,
- children,
- }: {
- leftGapWidth: SharedValue
- children: React.ReactNode
- }): React.JSX.Element {
- const { tx, rightWidth } = useSwipeableRowContext()
- const tokenValue = getToken('$2', 'space')
- const spacingValue =
- typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
- const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
- const style = useAnimatedStyle(() => {
- const t = tx.value
- let offset = 0
- if (t > 0 && leftGapWidth.get() > 0) {
- offset = -Math.min(t, leftGapWidth.get())
- } else if (t < 0) {
- const rightSpace = Math.max(0, rightWidth)
- const compensate = Math.min(-t, rightSpace)
- const progress = rightSpace > 0 ? compensate / rightSpace : 1
- offset = compensate * 0.7 + quickActionBuffer * progress
- }
- return { transform: [{ translateX: offset }] }
- })
- const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
- return (
-
- {children}
-
- )
- },
- (prevProps, nextProps) =>
- prevProps.leftGapWidth === nextProps.leftGapWidth &&
- prevProps.children?.valueOf() === nextProps.children?.valueOf(),
-)
+function SlidingTextArea({
+ leftGapWidth,
+ children,
+}: {
+ leftGapWidth: SharedValue
+ children: React.ReactNode
+}): React.JSX.Element {
+ const { tx, rightWidth } = useSwipeableRowContext()
+ const tokenValue = getToken('$2', 'space')
+ const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
+ const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
+ const style = useAnimatedStyle(() => {
+ const t = tx.value
+ let offset = 0
+ if (t > 0 && leftGapWidth.get() > 0) {
+ offset = -Math.min(t, leftGapWidth.get())
+ } else if (t < 0) {
+ const rightSpace = Math.max(0, rightWidth)
+ const compensate = Math.min(-t, rightSpace)
+ const progress = rightSpace > 0 ? compensate / rightSpace : 1
+ offset = compensate * 0.7 + quickActionBuffer * progress
+ }
+ return { transform: [{ translateX: offset }] }
+ })
+ const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
+ return (
+
+ {children}
+
+ )
+}
export default ItemRow
diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx
index 980a1c90..27f9d44e 100644
--- a/src/components/Global/components/track.tsx
+++ b/src/components/Global/components/track.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useCallback, useState, memo } from 'react'
+import React, { useState } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
@@ -28,10 +28,7 @@ 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'
+import useStreamedMediaInfo from '../../../api/queries/media'
export interface TrackProps {
track: BaseItemDto
@@ -48,329 +45,243 @@ export interface TrackProps {
editing?: boolean | undefined
}
-const queueItemsCache = new WeakMap()
+export default function Track({
+ track,
+ navigation,
+ tracklist,
+ index,
+ queue,
+ showArtwork,
+ onPress,
+ onLongPress,
+ testID,
+ isNested,
+ invertedColors,
+ editing,
+}: TrackProps): React.JSX.Element {
+ const theme = useTheme()
+ const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
-const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => {
- if (!queue?.length) return []
+ const api = useApi()
- const cached = queueItemsCache.get(queue)
- if (cached) return cached
+ const deviceProfile = useStreamingDeviceProfile()
- const mapped = queue.map((entry) => entry.item)
- queueItemsCache.set(queue, mapped)
- return mapped
-}
+ const [hideRunTimes] = useHideRunTimesSetting()
-const Track = memo(
- function Track({
- track,
- navigation,
- tracklist,
- index,
- queue,
- showArtwork,
- onPress,
- onLongPress,
- testID,
- isNested,
- invertedColors,
- editing,
- }: TrackProps): React.JSX.Element {
- const theme = useTheme()
- const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
+ const nowPlaying = useCurrentTrack()
+ const playQueue = usePlayQueue()
+ const loadNewQueue = useLoadNewQueue()
+ const addToQueue = useAddToQueue()
+ const [networkStatus] = useNetworkStatus()
- const api = useApi()
+ const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
- const deviceProfile = useStreamingDeviceProfile()
+ const offlineAudio = useDownloadedTrack(track.Id)
- const [hideRunTimes] = useHideRunTimesSetting()
+ const { mutate: addFavorite } = useAddFavorite()
+ const { mutate: removeFavorite } = useRemoveFavorite()
+ const { data: isFavoriteTrack } = useIsFavorite(track)
+ const leftSettings = useSwipeSettingsStore((s) => s.left)
+ const rightSettings = useSwipeSettingsStore((s) => s.right)
- const nowPlaying = useCurrentTrack()
- const playQueue = usePlayQueue()
- const loadNewQueue = useLoadNewQueue()
- const addToQueue = useAddToQueue()
- const [networkStatus] = useNetworkStatus()
+ // Memoize expensive computations
+ const isPlaying = nowPlaying?.item.Id === track.Id
- const offlineAudio = useDownloadedTrack(track.Id)
+ const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
- const { mutate: addFavorite } = useAddFavorite()
- const { mutate: removeFavorite } = useRemoveFavorite()
- const { data: isFavoriteTrack } = useIsFavorite(track)
- const leftSettings = useSwipeSettingsStore((s) => s.left)
- const rightSettings = useSwipeSettingsStore((s) => s.right)
+ // Memoize tracklist for queue loading
+ const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? []
- // Memoize expensive computations
- const isPlaying = useMemo(
- () => nowPlaying?.item.Id === track.Id,
- [nowPlaying?.item.Id, track.Id],
- )
-
- const isOffline = useMemo(
- () => networkStatus === networkStatusTypes.DISCONNECTED,
- [networkStatus],
- )
-
- // Memoize tracklist for queue loading
- const memoizedTracklist = useMemo(
- () => tracklist ?? getQueueItems(playQueue),
- [tracklist, playQueue],
- )
-
- // Memoize handlers to prevent recreation
- const handlePress = useCallback(async () => {
- if (onPress) {
- await onPress()
- } else {
- loadNewQueue({
- api,
- deviceProfile,
- networkStatus,
- track,
- index,
- tracklist: memoizedTracklist,
- queue,
- queuingType: QueuingType.FromSelection,
- startPlayback: true,
- })
- }
- }, [
- 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()
- return
- }
-
- void openContextSheet()
- }, [onLongPress, openContextSheet])
-
- const handleIconPress = useCallback(() => {
- void openContextSheet()
- }, [openContextSheet])
-
- // Memoize artists text
- const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
-
- // Memoize track name
- const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
-
- // Memoize index number
- const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber])
-
- // Memoize show artists condition
- const shouldShowArtists = useMemo(
- () => showArtwork || (track.Artists && track.Artists.length > 1),
- [showArtwork, track.Artists],
- )
-
- const swipeHandlers = useMemo(
- () => ({
- 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 }))
- },
- }),
- [
- addToQueue,
+ // Memoize handlers to prevent recreation
+ const handlePress = async () => {
+ if (onPress) {
+ await onPress()
+ } else {
+ loadNewQueue({
api,
deviceProfile,
networkStatus,
track,
- addFavorite,
- removeFavorite,
- isFavoriteTrack,
- navigationRef,
- ],
- )
+ index,
+ tracklist: memoizedTracklist,
+ queue,
+ queuingType: QueuingType.FromSelection,
+ startPlayback: true,
+ })
+ }
+ }
- const swipeConfig = useMemo(
- () =>
- buildSwipeConfig({
- left: leftSettings,
- right: rightSettings,
- handlers: swipeHandlers,
- }),
- [leftSettings, rightSettings, swipeHandlers],
- )
+ const handleLongPress = () => {
+ if (onLongPress) {
+ onLongPress()
+ } else {
+ navigationRef.navigate('Context', {
+ item: track,
+ navigation,
+ streamingMediaSourceInfo: mediaInfo?.MediaSources
+ ? mediaInfo!.MediaSources![0]
+ : undefined,
+ downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
+ })
+ }
+ }
- const textColor = useMemo(
- () => (isPlaying ? theme.primary.val : theme.color.val),
- [isPlaying],
- )
+ const handleIconPress = () => {
+ navigationRef.navigate('Context', {
+ item: track,
+ navigation,
+ streamingMediaSourceInfo: mediaInfo?.MediaSources
+ ? mediaInfo!.MediaSources![0]
+ : undefined,
+ downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
+ })
+ }
- const runtimeComponent = useMemo(
- () =>
- hideRunTimes ? (
- <>>
- ) : (
-
- {track.RunTimeTicks}
-
- ),
- [hideRunTimes, track.RunTimeTicks],
- )
+ // Memoize text color to prevent recalculation
+ const textColor = isPlaying
+ ? theme.primary.val
+ : isOffline
+ ? offlineAudio
+ ? theme.color
+ : theme.neutral.val
+ : theme.color
- return (
-
- 1)
+
+ const swipeHandlers = {
+ 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 }))
+ },
+ }
+
+ const swipeConfig = buildSwipeConfig({
+ left: leftSettings,
+ right: rightSettings,
+ handlers: swipeHandlers,
+ })
+
+ const runtimeComponent = hideRunTimes ? (
+ <>>
+ ) : (
+
+ {track.RunTimeTicks}
+
+ )
+
+ return (
+
+
+
setArtworkAreaWidth(e.nativeEvent.layout.width)}
>
- setArtworkAreaWidth(e.nativeEvent.layout.width)}
- >
- {showArtwork ? (
-
-
-
- ) : (
-
- {indexNumber}
-
- )}
-
+ {showArtwork ? (
+
+
+
+ ) : (
+
+ {indexNumber}
+
+ )}
+
-
-
+
+
+
+ {trackName}
+
+
+ {shouldShowArtists && (
- {trackName}
+ {artistsText}
-
- {shouldShowArtists && (
-
- {artistsText}
-
- )}
-
-
-
-
-
-
- {runtimeComponent}
- {!editing && (
-
)}
-
+
+
+
+
+
+
+ {runtimeComponent}
+ {!editing && }
-
-
- )
- },
- (prevProps, nextProps) =>
- prevProps.track.Id === nextProps.track.Id &&
- prevProps.index === nextProps.index &&
- prevProps.showArtwork === nextProps.showArtwork &&
- prevProps.isNested === nextProps.isNested &&
- prevProps.invertedColors === nextProps.invertedColors &&
- prevProps.testID === nextProps.testID &&
- prevProps.editing === nextProps.editing &&
- prevProps.queue === nextProps.queue &&
- prevProps.tracklist === nextProps.tracklist &&
- !!prevProps.onPress === !!nextProps.onPress &&
- !!prevProps.onLongPress === !!nextProps.onLongPress,
-)
+
+
+
+ )
+}
function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext()
@@ -402,7 +313,5 @@ function SlidingTextArea({
}
return { transform: [{ translateX: offset }] }
})
- return {children}
+ return {children}
}
-
-export default Track