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
This commit is contained in:
Violet Caulfield
2025-12-03 05:27:11 -06:00
committed by GitHub
parent 36069ba3ec
commit a111f057ba
15 changed files with 315 additions and 264 deletions
+15 -15
View File
@@ -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=="],
+6 -6
View File
@@ -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
+8 -8
View File
@@ -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",
+6 -10
View File
@@ -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,
+12 -21
View File
@@ -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<NativeStackNavigationProp<BaseStackParamList>, '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 ? (
<ListItem
@@ -287,7 +278,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={downloadItems}
onPress={() => addToDownloadQueue(items)}
pressStyle={{ opacity: 0.5 }}
>
<Icon
+6 -16
View File
@@ -3,22 +3,19 @@ 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 { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { useNetworkStatus } from '../../../stores/network'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { QueuingType } from '../../../enums/queuing-type'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile, {
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import useStreamingDeviceProfile from '../../../stores/device-profile'
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'
import useAddToPendingDownloads, { usePendingDownloads } from '../../../stores/network/downloads'
export default function PlaylistTracklistHeader({
playlist,
@@ -85,7 +82,6 @@ export default function PlaylistTracklistHeader({
}
function PlaylistHeaderControls({
editing,
playlist,
playlistTracks,
}: {
@@ -93,9 +89,9 @@ function PlaylistHeaderControls({
playlist: BaseItemDto
playlistTracks: BaseItemDto[]
}): React.JSX.Element {
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const addToDownloadQueue = useAddToPendingDownloads()
const pendingDownloads = usePendingDownloads()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const loadNewQueue = useLoadNewQueue()
const isDownloading = pendingDownloads.length != 0
const api = useApi()
@@ -104,13 +100,7 @@ function PlaylistHeaderControls({
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
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
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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 (
<NetworkContextProvider>
<StorageProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</StorageProvider>
</NetworkContextProvider>
<StorageProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</StorageProvider>
)
}
+1
View File
@@ -0,0 +1 @@
export const MAX_CONCURRENT_DOWNLOADS = 1
+64
View File
@@ -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
-105
View File
@@ -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<JellifyDownloadProgress>({})
// Mutiple Downloads
const [pending, setPending] = useState<JellifyTrack[]>([])
const [downloading, setDownloading] = useState<JellifyTrack[]>([])
const [completed, setCompleted] = useState<JellifyTrack[]>([])
const [failed, setFailed] = useState<JellifyTrack[]>([])
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<NetworkContext>({
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 <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>
}
export const useNetworkContext = () => useContext(NetworkContext)
+4 -4
View File
@@ -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<StorageSelectionState>({})
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
}
@@ -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<NativeStackNavigationProp<SettingsStackParamList>>()
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<JellifyDownload> = useCallback(
({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
),
[selection, toggleSelection, handleDeleteSingle],
const renderDownloadItem: ListRenderItem<JellifyDownload> = ({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
)
const topPadding = 16
+137
View File
@@ -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<DownloadsStore>()(
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
@@ -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