From 82b981f15cde3eeaeb66d289cd3805fa67cd3fbf Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 17 Feb 2025 20:28:05 +0100 Subject: [PATCH] fix: working state updates for active/non-active downloads --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 3 +- .../ios/HlsDownloaderModule.swift | 67 ++++++++++++----- providers/NativeDownloadProvider.tsx | 71 ++++++++++++------- 3 files changed, 96 insertions(+), 45 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index cd917563..e475c638 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -100,7 +100,8 @@ export default function Index() { const deleteFile = async (id: string) => { const downloadsDir = FileSystem.documentDirectory + "downloads/"; await FileSystem.deleteAsync(downloadsDir + id + ".json"); - await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] }); + await FileSystem.deleteAsync(downloadsDir + id); + refetchDownloadedFiles() }; return ( diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index 6b215297..ac240f3e 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -1,7 +1,7 @@ import AVFoundation import ExpoModulesCore +import UserNotifications -// Separate delegate class for managing download-specific state class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { weak var module: HlsDownloaderModule? var taskIdentifier: Int = 0 @@ -40,12 +40,10 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { } public class HlsDownloaderModule: Module { - // Main delegate handler for the download session private lazy var delegateHandler: HLSDownloadDelegate = { return HLSDownloadDelegate(module: self) }() - // Track active downloads with all necessary information var activeDownloads: [Int: ( task: AVAssetDownloadTask, @@ -54,7 +52,6 @@ public class HlsDownloaderModule: Module { startTime: Double )] = [:] - // Configure background download session private lazy var downloadSession: AVAssetDownloadURLSession = { let configuration = URLSessionConfiguration.background( withIdentifier: "com.example.hlsdownload") @@ -73,6 +70,20 @@ public class HlsDownloaderModule: Module { Events("onProgress", "onError", "onComplete") + // Function("requestNotificationPermission") { () -> Bool in + // var permissionGranted = false + // let semaphore = DispatchSemaphore(value: 0) + + // UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { + // granted, error in + // permissionGranted = granted + // semaphore.signal() + // } + + // _ = semaphore.wait(timeout: .now() + 5.0) + // return permissionGranted + // } + Function("getActiveDownloads") { () -> [[String: Any]] in return activeDownloads.map { (taskId, downloadInfo) in return [ @@ -89,14 +100,12 @@ public class HlsDownloaderModule: Module { (providedId: String, url: String, metadata: [String: Any]?) -> Void in let startTime = Date().timeIntervalSince1970 - // Check if download already exists let fm = FileManager.default let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true) let potentialExistingLocation = downloadsDir.appendingPathComponent( providedId, isDirectory: true) - // If download exists and is valid, return immediately if fm.fileExists(atPath: potentialExistingLocation.path) { if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path), files.contains(where: { $0.hasSuffix(".m3u8") }) @@ -116,7 +125,6 @@ public class HlsDownloaderModule: Module { } } - // Validate URL guard let assetURL = URL(string: url) else { self.sendEvent( "onError", @@ -130,7 +138,6 @@ public class HlsDownloaderModule: Module { return } - // Configure asset with necessary options let asset = AVURLAsset( url: assetURL, options: [ @@ -139,7 +146,6 @@ public class HlsDownloaderModule: Module { "AVURLAssetAllowsCellularAccessKey": true, ]) - // Load asset asynchronously asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) { var error: NSError? let status = asset.statusOfValue(forKey: "playable", error: &error) @@ -159,7 +165,6 @@ public class HlsDownloaderModule: Module { return } - // Create download task with quality options guard let task = self.downloadSession.makeAssetDownloadTask( asset: asset, @@ -185,16 +190,13 @@ public class HlsDownloaderModule: Module { return } - // Configure delegate for this download let delegate = HLSDownloadDelegate(module: self) delegate.providedId = providedId delegate.startTime = startTime delegate.taskIdentifier = task.taskIdentifier - // Store download information self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) - // Send initial progress event self.sendEvent( "onProgress", [ @@ -205,13 +207,11 @@ public class HlsDownloaderModule: Module { "startTime": startTime, ]) - // Start the download task.resume() } } } - // Additional methods and event handlers... Function("cancelDownload") { (providedId: String) -> Void in guard let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId } @@ -237,11 +237,30 @@ public class HlsDownloaderModule: Module { } } - // Helper methods func removeDownload(with id: Int) { activeDownloads.removeValue(forKey: id) } + private func sendDownloadCompletionNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error showing notification: \(error)") + } + } + } + func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL { let fm = FileManager.default let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] @@ -270,7 +289,6 @@ public class HlsDownloaderModule: Module { } } -// Extension for URL session delegate methods extension HlsDownloaderModule { func urlSession( _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, @@ -324,6 +342,21 @@ extension HlsDownloaderModule { do { try await rewriteM3U8Files(baseDir: newLocation.path) + // Safely access metadata for notification + let notificationBody: String + if let item = downloadInfo.metadata["item"] as? [String: Any], + let name = item["Name"] as? String + { + notificationBody = "\(name) has finished downloading." + } else { + notificationBody = "Download completed successfully." + } + + sendDownloadCompletionNotification( + title: "Download Complete", + body: notificationBody + ) + sendEvent( "onComplete", [ diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index 94db80a9..cbd0a5d7 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -20,9 +20,17 @@ import { import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useAtomValue } from "jotai"; -import { createContext, useContext, useEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; import { toast } from "sonner-native"; import { apiAtom, userAtom } from "./JellyfinProvider"; +import { useFocusEffect } from "expo-router"; +import { AppState, AppStateStatus } from "react-native"; type DownloadOptionsData = { selectedAudioStream: number; @@ -120,38 +128,47 @@ export const NativeDownloadProvider: React.FC<{ }); useEffect(() => { - const _getActiveDownloads = async () => { - const activeDownloads = await getActiveDownloads(); - setDownloads((prev) => { - const newDownloads = { ...prev }; - activeDownloads.forEach((download) => { - newDownloads[download.id] = { - id: download.id, - progress: download.progress, - state: download.state, - secondsDownloaded: download.secondsDownloaded, - secondsTotal: download.secondsTotal, - metadata: download.metadata, - startTime: download.startTime, - }; - }); - return newDownloads; - }); + const handleAppStateChange = (state: AppStateStatus) => { + if (state === "background" || state === "inactive") { + setDownloads({}); + } else if (state === "active") { + const _getActiveDownloads = async () => { + const activeDownloads = await getActiveDownloads(); + setDownloads((prev) => { + const newDownloads = { ...prev }; + activeDownloads.forEach((download) => { + newDownloads[download.id] = { + id: download.id, + progress: download.progress, + state: download.state, + secondsDownloaded: download.secondsDownloaded, + secondsTotal: download.secondsTotal, + metadata: download.metadata, + startTime: download.startTime, + }; + }); + return newDownloads; + }); + }; + _getActiveDownloads(); + refetchDownloadedFiles(); + } }; - _getActiveDownloads(); + const subscription = AppState.addEventListener( + "change", + handleAppStateChange + ); + return () => { + subscription.remove(); + }; + }, [getActiveDownloads]); + + useEffect(() => { const progressListener = addProgressListener((download) => { if (!download.metadata) throw new Error("No metadata found in download"); - console.log( - "[HLS] Download progress:", - download.metadata.item.Id, - download.progress, - download.state, - download.taskId - ); - setDownloads((prev) => ({ ...prev, [download.id]: {