diff --git a/bun.lock b/bun.lock
index 357a6a8b..457378e7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -56,7 +56,7 @@
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "0.7.2",
- "react-native-worklets-core": "^1.6.2",
+ "react-native-worklets-core": "1.6.3",
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "1.144.3",
@@ -1952,7 +1952,7 @@
"react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
- "react-native-worklets-core": ["react-native-worklets-core@1.6.2", "", { "dependencies": { "string-hash-64": "^1.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zw73JfL40ZL/OD2TOil1El4D9ZwS3l6AFPeFfUWXh+V2/dHN8i28jHX8QXlz5DYtAkR+Ju3U1h4yiaODi/igZw=="],
+ "react-native-worklets-core": ["react-native-worklets-core@1.6.3", "", { "dependencies": { "string-hash-64": "^1.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0f147bba..f534ab6d 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2323,7 +2323,7 @@ PODS:
- SocketRocket
- Yoga
- react-native-vector-icons-material-design-icons (12.4.0)
- - react-native-worklets-core (1.6.2):
+ - react-native-worklets-core (1.6.3):
- boost
- DoubleConversion
- fast_float
@@ -3704,7 +3704,7 @@ SPEC CHECKSUMS:
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-turbo-image: be4fdfeeced611d8d089d5ef879601d8bb293007
react-native-vector-icons-material-design-icons: 76cd460b3540b80527b4a80fb7f867f7deedb498
- react-native-worklets-core: 28a6e2121dcf62543b703e81bc4860e9a0150cee
+ react-native-worklets-core: aaaac7d17f7e576592369a54f30e96fe4875c983
React-NativeModulesApple: a2c3d2cbec893956a5b3e4060322db2984fff75b
React-networking: 3f98bd96893a294376e7e03730947a08d474c380
React-oscompat: 80166b66da22e7af7fad94474e9997bd52d4c8c6
diff --git a/package.json b/package.json
index 860565b3..e09bc334 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "0.7.2",
- "react-native-worklets-core": "^1.6.2",
+ "react-native-worklets-core": "1.6.3",
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "1.144.3",
diff --git a/patches/react-native-nitro-player+0.4.1-alpha.0.patch b/patches/react-native-nitro-player+0.4.1-alpha.0.patch
index db94f43b..9815badd 100644
--- a/patches/react-native-nitro-player+0.4.1-alpha.0.patch
+++ b/patches/react-native-nitro-player+0.4.1-alpha.0.patch
@@ -23,3 +23,62 @@ index 37568c5..4ab42ca 100644
return true
}
+diff --git a/node_modules/react-native-nitro-player/ios/download/DownloadFileManager.swift b/node_modules/react-native-nitro-player/ios/download/DownloadFileManager.swift
+index 2654fc6..6b6ce4a 100644
+--- a/node_modules/react-native-nitro-player/ios/download/DownloadFileManager.swift
++++ b/node_modules/react-native-nitro-player/ios/download/DownloadFileManager.swift
+@@ -56,20 +56,28 @@ final class DownloadFileManager {
+
+ func saveDownloadedFile(
+ from temporaryLocation: URL, trackId: String, storageLocation: StorageLocation,
+- originalURL: String? = nil
++ originalURL: String? = nil,
++ suggestedFilename: String? = nil
+ ) -> String? {
+ print("🎯 DownloadFileManager: saveDownloadedFile called for trackId=\(trackId)")
+ print(" From: \(temporaryLocation.path)")
+ print(" Original URL: \(originalURL ?? "nil")")
++ print(" Suggested Filename: \(suggestedFilename ?? "nil")")
+
+ let destinationDirectory =
+ storageLocation == .private ? privateDownloadsDirectory : publicDownloadsDirectory
+ print(" Destination directory: \(destinationDirectory.path)")
+
+- // Determine file extension from the original URL, not the temp file
+- // The temp file has .tmp extension which AVPlayer cannot play
++ // Determine file extension
+ var fileExtension = "mp3" // Default fallback
+- if let originalURL = originalURL, let url = URL(string: originalURL) {
++
++ if let suggestedFilename = suggestedFilename, !suggestedFilename.isEmpty {
++ let url = URL(fileURLWithPath: suggestedFilename)
++ let pathExtension = url.pathExtension.lowercased()
++ if !pathExtension.isEmpty {
++ fileExtension = pathExtension
++ }
++ } else if let originalURL = originalURL, let url = URL(string: originalURL) {
+ let pathExtension = url.pathExtension.lowercased()
+ if !pathExtension.isEmpty {
+ fileExtension = pathExtension
+diff --git a/node_modules/react-native-nitro-player/ios/download/DownloadManagerCore.swift b/node_modules/react-native-nitro-player/ios/download/DownloadManagerCore.swift
+index d813fe2..63a256d 100644
+--- a/node_modules/react-native-nitro-player/ios/download/DownloadManagerCore.swift
++++ b/node_modules/react-native-nitro-player/ios/download/DownloadManagerCore.swift
+@@ -681,11 +681,16 @@ extension DownloadManagerCore: URLSessionDownloadDelegate {
+ let (storageLocation, originalURL) = queue.sync {
+ (self.config.storageLocation ?? .private, self.trackMetadata[trackId]?.url)
+ }
++
++ // Get suggested filename from response
++ let suggestedFilename = downloadTask.response?.suggestedFilename
++
+ let destinationPath = DownloadFileManager.shared.saveDownloadedFile(
+ from: location,
+ trackId: trackId,
+ storageLocation: storageLocation,
+- originalURL: originalURL
++ originalURL: originalURL,
++ suggestedFilename: suggestedFilename
+ )
+
+ // Now handle the rest asynchronously
diff --git a/patches/react-native-worklets-core+1.6.2.patch b/patches/react-native-worklets-core+1.6.2.patch
deleted file mode 100644
index 33519706..00000000
--- a/patches/react-native-worklets-core+1.6.2.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/react-native-worklets-core/android/CMakeLists.txt b/node_modules/react-native-worklets-core/android/CMakeLists.txt
-index cc4cbfe..61e1b56 100644
---- a/node_modules/react-native-worklets-core/android/CMakeLists.txt
-+++ b/node_modules/react-native-worklets-core/android/CMakeLists.txt
-@@ -81,7 +81,7 @@ if(${JS_RUNTIME} STREQUAL "hermes")
-
- target_link_libraries(
- ${PACKAGE_NAME}
-- hermes-engine::libhermes
-+ hermes-engine::hermesvm
- )
-
- if(${HERMES_ENABLE_DEBUGGER})
diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx
index f9ae49d7..225b41bd 100644
--- a/src/components/Album/index.tsx
+++ b/src/components/Album/index.tsx
@@ -14,12 +14,13 @@ import { getApi } from '../../stores'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
-import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import AlbumTrackListFooter from './footer'
import AlbumTrackListHeader from './header'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { useStorageContext } from '../../providers/Storage'
-import useIsDownloaded from '../../hooks/downloads'
+import { useIsDownloaded } from '../../hooks/downloads'
+import useDownloadTracks from '../../hooks/downloads/mutations'
+import { useDownloadProgress } from 'react-native-nitro-player'
/**
* The screen for an Album's track list
@@ -45,11 +46,7 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
queryFn: () => fetchAlbumDiscs(api, album),
})
- const isDownloaded = useIsDownloaded(
- discs?.flatMap(({ data }) => data).map(({ Id }) => Id) ?? [],
- )
-
- const addToDownloadQueue = useAddToPendingDownloads()
+ const downloadTracks = useDownloadTracks()
const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({
title,
@@ -60,13 +57,13 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
const albumTrackList = discs?.flatMap((disc) => disc.data)
- const albumDownloadPending = useIsDownloading(albumTrackList ?? [])
+ const isDownloaded = useIsDownloaded(albumTrackList?.map(({ Id }) => Id) ?? [])
const { deleteDownloads } = useStorageContext()
const handleDeleteDownload = () => deleteDownloads(albumTrackList?.map(({ Id }) => Id!) ?? [])
- const handleDownload = () => addToDownloadQueue(albumTrackList ?? [])
+ const handleDownload = () => downloadTracks.mutate(albumTrackList ?? [])
useLayoutEffect(() => {
navigation.setOptions({
@@ -85,7 +82,7 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
onPress={handleDeleteDownload}
/>
- ) : albumDownloadPending ? (
+ ) : downloadTracks.isPending ? (
) : (
, 'navigate' | 'dispatch'>
@@ -230,14 +230,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
const { isPending, mutate: download } = useDownloadTracks()
- const progress = useDownloadProgress({ trackIds: items.map((item) => item.Id!) })
-
- const isDownloaded =
- items.filter((item) =>
- DownloadManager.getAllDownloadedTracks()
- .map((download) => download.trackId)
- .includes(item.Id!),
- ).length === items.length
+ const isDownloaded = useIsDownloaded(items.map((item) => item.Id))
const removeDownloads = useDeleteDownloads()
diff --git a/src/components/Global/components/Track/index.tsx b/src/components/Global/components/Track/index.tsx
index 446938e4..b57dcba4 100644
--- a/src/components/Global/components/Track/index.tsx
+++ b/src/components/Global/components/Track/index.tsx
@@ -19,7 +19,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../../api/mutations/fav
import { StackActions } from '@react-navigation/native'
import { useHideRunTimesSetting } from '../../../../stores/settings/app'
import TrackRowContent from './content'
-import useIsDownloaded from '../../../../hooks/downloads'
+import { useIsDownloaded } from '../../../../hooks/downloads'
export interface TrackProps {
track: BaseItemDto
diff --git a/src/components/Global/components/downloaded-icon.tsx b/src/components/Global/components/downloaded-icon.tsx
index 176a1f77..35215139 100644
--- a/src/components/Global/components/downloaded-icon.tsx
+++ b/src/components/Global/components/downloaded-icon.tsx
@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Icon from './icon'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
-import useIsDownloaded from '../../../hooks/downloads'
+import { useIsDownloaded } from '../../../hooks/downloads'
function DownloadedIcon({ item }: { item: BaseItemDto }) {
const isDownloaded = useIsDownloaded([item.Id])
diff --git a/src/components/Player/components/quality-badge.tsx b/src/components/Player/components/quality-badge.tsx
index fd62df73..7e19d160 100644
--- a/src/components/Player/components/quality-badge.tsx
+++ b/src/components/Player/components/quality-badge.tsx
@@ -4,7 +4,7 @@ import navigationRef from '../../../../navigation'
import { parseBitrateFromTranscodingUrl } from '../../../utils/parsing/url'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import { BUTTON_PRESS_STYLES } from '../../../configs/style.config'
-import { useDownloadProgress } from 'react-native-nitro-player'
+import { useIsDownloaded } from '../../../hooks/downloads'
interface QualityBadgeProps {
item: BaseItemDto
@@ -22,7 +22,7 @@ export default function QualityBadge({
? parseBitrateFromTranscodingUrl(transcodingUrl)
: mediaSourceInfo.Bitrate
- const isDownloaded = useDownloadProgress({ trackIds: [item.Id!] }).overallProgress === 1
+ const isDownloaded = useIsDownloaded([item.Id])
return bitrate && container ? (
{
- const { downloadedTracks } = useDownloadedTracks()
+const useDownloads = () => useQuery(ALL_DOWNLOADS_QUERY)
- const downloadedTrackIds = new Set(
- downloadedTracks.map((download) => download.originalTrack.id),
+export const useIsDownloaded = (trackIds: (string | null | undefined)[]) => {
+ const { data: downloadedTracks } = useQuery(ALL_DOWNLOADS_QUERY)
+
+ return trackIds.every((id) =>
+ downloadedTracks?.some((download) => download.originalTrack.id === id),
)
-
- return trackIds.every((id) => id != null && downloadedTrackIds.has(id))
}
-export default useIsDownloaded
+export default useDownloads
diff --git a/src/hooks/downloads/keys.ts b/src/hooks/downloads/keys.ts
new file mode 100644
index 00000000..4c9b2b6f
--- /dev/null
+++ b/src/hooks/downloads/keys.ts
@@ -0,0 +1,3 @@
+const ALL_DOWNLOADS_KEY = ['ALL_DOWNLOADS'] as const
+
+export default ALL_DOWNLOADS_KEY
diff --git a/src/hooks/downloads/mutations.ts b/src/hooks/downloads/mutations.ts
index ce1c0723..9c64b6b1 100644
--- a/src/hooks/downloads/mutations.ts
+++ b/src/hooks/downloads/mutations.ts
@@ -3,9 +3,10 @@ import { mapDtoToTrack } from '../../utils/mapping/item-to-track'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useMutation } from '@tanstack/react-query'
import { DownloadManager, PlayerQueue } from 'react-native-nitro-player'
+import { refetchDownloadsAfterDelay } from './utils'
-const useDownloadTracks = () =>
- useMutation({
+const useDownloadTracks = () => {
+ const download = useMutation({
mutationFn: async (items: BaseItemDto[]) => {
const { deviceProfile } = useDownloadingDeviceProfileStore.getState()
@@ -18,15 +19,31 @@ const useDownloadTracks = () =>
PlayerQueue.addTracksToPlaylist(playlistId, tracks)
await DownloadManager.downloadPlaylist(playlistId, tracks)
+
+ // Refetch downloads after a delay to allow the new download to be registered
+ refetchDownloadsAfterDelay()
},
})
-export const useDeleteDownloads = () =>
- useMutation({
+ return {
+ mutate: download.mutate,
+ isPending: download.isPending,
+ }
+}
+
+export const useDeleteDownloads = () => {
+ const deleteDownloads = useMutation({
mutationFn: async (items: BaseItemDto[]) => {
const trackIds = items.map((item) => item.Id)
await Promise.all(trackIds.map((id) => DownloadManager.deleteDownloadedTrack(id!)))
},
+ onSettled: refetchDownloadsAfterDelay,
})
+ return {
+ mutate: deleteDownloads.mutate,
+ isPending: deleteDownloads.isPending,
+ }
+}
+
export default useDownloadTracks
diff --git a/src/hooks/downloads/queries.ts b/src/hooks/downloads/queries.ts
new file mode 100644
index 00000000..e4dadab2
--- /dev/null
+++ b/src/hooks/downloads/queries.ts
@@ -0,0 +1,9 @@
+import { DownloadManager } from 'react-native-nitro-player'
+import ALL_DOWNLOADS_KEY from './keys'
+
+const ALL_DOWNLOADS_QUERY = {
+ queryKey: ALL_DOWNLOADS_KEY,
+ queryFn: () => DownloadManager.getAllDownloadedTracks(),
+}
+
+export default ALL_DOWNLOADS_QUERY
diff --git a/src/hooks/downloads/utils.ts b/src/hooks/downloads/utils.ts
new file mode 100644
index 00000000..3ae6ce5e
--- /dev/null
+++ b/src/hooks/downloads/utils.ts
@@ -0,0 +1,8 @@
+import { queryClient } from '../../constants/query-client'
+import ALL_DOWNLOADS_KEY from './keys'
+
+export function refetchDownloadsAfterDelay() {
+ setTimeout(() => {
+ queryClient.refetchQueries({ queryKey: ALL_DOWNLOADS_KEY })
+ }, 3000) // Refetch downloads after a delay to allow the new download to be registered
+}
diff --git a/src/providers/Player/utils/initialization.ts b/src/providers/Player/utils/initialization.ts
index 33a82429..a525ab94 100644
--- a/src/providers/Player/utils/initialization.ts
+++ b/src/providers/Player/utils/initialization.ts
@@ -8,6 +8,7 @@ import { setPlaybackPosition, usePlayerPlaybackStore } from '../../../stores/pla
import { useUsageSettingsStore } from '../../../stores/settings/usage'
import isPlaybackFinished from '../../../api/mutations/playback/utils'
import reportPlaybackCompleted from '../../../api/mutations/playback/functions/playback-completed'
+import { refetchDownloadsAfterDelay } from '../../../hooks/downloads/utils'
export default function Initialize() {
restoreFromStorage()
@@ -55,8 +56,30 @@ function registerEventHandlers() {
}
})
- TrackPlayer.onPlaybackProgressChange(async (position) => {
+ TrackPlayer.onPlaybackProgressChange(async (position, totalDuration) => {
setPlaybackPosition(position)
+
+ const currentTrack = usePlayerQueueStore.getState().currentTrack
+ const autoDownload = useUsageSettingsStore.getState().autoDownload
+
+ const isDownloadedOrDownloadPending =
+ (await DownloadManager.isTrackDownloaded(currentTrack?.id ?? '')) ||
+ (await DownloadManager.isDownloading(currentTrack?.id ?? ''))
+
+ if (
+ position / totalDuration > 0.3 &&
+ currentTrack &&
+ autoDownload &&
+ !isDownloadedOrDownloadPending
+ ) {
+ try {
+ await DownloadManager.downloadTrack(currentTrack)
+
+ refetchDownloadsAfterDelay()
+ } catch (error) {
+ console.warn('Error auto-downloading track:', error)
+ }
+ }
})
}