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) + } + } }) }