diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj index fe240a1..7a8290c 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -6,19 +6,70 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 39C4270E2FD189220033EE46 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39C4270D2FD189220033EE46 /* WidgetKit.framework */; }; + 39C427102FD189220033EE46 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39C4270F2FD189220033EE46 /* SwiftUI.framework */; }; + 39C4271D2FD189230033EE46 /* DownloadWidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 39C4271B2FD189230033EE46 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 39614D032FB4D44400DBEF5E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 39C4270A2FD189220033EE46; + remoteInfo = DownloadWidgetExtensionExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 39C427222FD189230033EE46 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 39C4271D2FD189230033EE46 /* DownloadWidgetExtensionExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActivityAttributes.swift; sourceTree = ""; }; 39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadWidgetBundle.swift; sourceTree = ""; }; 39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = ""; }; + 39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DownloadWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 39C4270D2FD189220033EE46 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 39C4270F2FD189220033EE46 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 39C4271E2FD189230033EE46 /* Exceptions for "DownloadWidgetExtension" folder in "DownloadWidgetExtensionExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Audiobookshelf swift"; sourceTree = ""; }; + 39C427112FD189220033EE46 /* DownloadWidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 39C4271E2FD189230033EE46 /* Exceptions for "DownloadWidgetExtension" folder in "DownloadWidgetExtensionExtension" target */, + ); + path = DownloadWidgetExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -29,6 +80,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 39C427082FD189220033EE46 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 39C427102FD189220033EE46 /* SwiftUI.framework in Frameworks */, + 39C4270E2FD189220033EE46 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -36,6 +96,8 @@ isa = PBXGroup; children = ( 39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */, + 39C427112FD189220033EE46 /* DownloadWidgetExtension */, + 39C4270C2FD189220033EE46 /* Frameworks */, 39614D0C2FB4D44500DBEF5E /* Products */, 39C427032FD180A90033EE46 /* DownloadWidgetExtension */, ); @@ -45,6 +107,7 @@ isa = PBXGroup; children = ( 39614D0B2FB4D44500DBEF5E /* ABS Client.app */, + 39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */, ); name = Products; sourceTree = ""; @@ -59,6 +122,15 @@ path = DownloadWidgetExtension; sourceTree = ""; }; + 39C4270C2FD189220033EE46 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 39C4270D2FD189220033EE46 /* WidgetKit.framework */, + 39C4270F2FD189220033EE46 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -69,10 +141,12 @@ 39614D072FB4D44400DBEF5E /* Sources */, 39614D082FB4D44400DBEF5E /* Frameworks */, 39614D092FB4D44400DBEF5E /* Resources */, + 39C427222FD189230033EE46 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 39C4271C2FD189230033EE46 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */, @@ -84,6 +158,28 @@ productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */; productType = "com.apple.product-type.application"; }; + 39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 39C4271F2FD189230033EE46 /* Build configuration list for PBXNativeTarget "DownloadWidgetExtensionExtension" */; + buildPhases = ( + 39C427072FD189220033EE46 /* Sources */, + 39C427082FD189220033EE46 /* Frameworks */, + 39C427092FD189220033EE46 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 39C427112FD189220033EE46 /* DownloadWidgetExtension */, + ); + name = DownloadWidgetExtensionExtension; + packageProductDependencies = ( + ); + productName = DownloadWidgetExtensionExtension; + productReference = 39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -91,12 +187,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2640; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2640; TargetAttributes = { 39614D0A2FB4D44400DBEF5E = { CreatedOnToolsVersion = 26.4.1; }; + 39C4270A2FD189220033EE46 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = 39614D062FB4D44400DBEF5E /* Build configuration list for PBXProject "Audiobookshelf swift" */; @@ -115,6 +214,7 @@ projectRoot = ""; targets = ( 39614D0A2FB4D44400DBEF5E /* Audiobookshelf swift */, + 39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */, ); }; /* End PBXProject section */ @@ -127,6 +227,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 39C427092FD189220033EE46 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -137,8 +244,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 39C427072FD189220033EE46 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 39C4271C2FD189230033EE46 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */; + targetProxy = 39C4271B2FD189230033EE46 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 39614D142FB4D44500DBEF5E /* Debug */ = { isa = XCBuildConfiguration; @@ -192,9 +314,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -252,9 +374,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = auto; @@ -273,7 +395,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; "ENABLE_APP_SANDBOX[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -287,7 +409,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -301,7 +423,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 2.5; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; @@ -327,7 +449,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; "ENABLE_APP_SANDBOX[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -341,7 +463,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -355,7 +477,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 2.5; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; @@ -372,6 +494,72 @@ }; name = Release; }; + 39C427202FD189230033EE46 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DownloadWidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DownloadWidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 39C427212FD189230033EE46 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DownloadWidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DownloadWidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -393,6 +581,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 39C4271F2FD189230033EE46 /* Build configuration list for PBXNativeTarget "DownloadWidgetExtensionExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 39C427202FD189230033EE46 /* Debug */, + 39C427212FD189230033EE46 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 39614D032FB4D44400DBEF5E /* Project object */; diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme b/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme new file mode 100644 index 0000000..deb5d2b --- /dev/null +++ b/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index 52a804c..92aae86 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,24 @@ orderHint 0 + DownloadWidgetExtensionExtension.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 39614D0A2FB4D44400DBEF5E + + primary + + + 39C4270A2FD189220033EE46 + + primary + + diff --git a/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/audiobook.svg b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/audiobook.svg new file mode 100644 index 0000000..d817c9a --- /dev/null +++ b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/audiobook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/headset-.svg b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/headset-.svg new file mode 100644 index 0000000..13428a6 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/headset-.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/open-book-3.svg b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/open-book-3.svg new file mode 100644 index 0000000..23913f9 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/AppIcon.icon/Assets/open-book-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ABS Client/Audiobookshelf swift/AppIcon.icon/icon.json b/ABS Client/Audiobookshelf swift/AppIcon.icon/icon.json new file mode 100644 index 0000000..295748f --- /dev/null +++ b/ABS Client/Audiobookshelf swift/AppIcon.icon/icon.json @@ -0,0 +1,102 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "fill" : { + "linear-gradient" : [ + "display-p3:0.10645,0.27174,0.19292,1.00000", + "display-p3:0.00006,0.13678,0.07057,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "blur-material" : 0.5, + "hidden" : false, + "layers" : [ + { + "fill" : { + "solid" : "display-p3:0.30163,0.65906,0.39475,1.00000" + }, + "hidden" : false, + "image-name" : "audiobook.svg", + "name" : "audiobook", + "position" : { + "scale" : 1.45, + "translation-in-points" : [ + -0.6150821872817005, + -21.261194425549093 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.77 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "hidden" : true, + "layers" : [ + { + "blend-mode" : "screen", + "fill" : { + "solid" : "display-p3:0.00169,0.78170,0.17447,1.00000" + }, + "glass" : true, + "image-name" : "headset-.svg", + "name" : "headset-", + "opacity" : 0.33, + "position" : { + "scale" : 1.75, + "translation-in-points" : [ + -0.614357187281712, + -63.2819525338457 + ] + } + }, + { + "blend-mode" : "screen", + "fill" : { + "solid" : "display-p3:0.31727,0.82379,0.38520,1.00000" + }, + "glass" : true, + "image-name" : "open-book-3.svg", + "name" : "open-book-3", + "position" : { + "scale" : 0.75, + "translation-in-points" : [ + -0.6139821872816356, + 146.59439922330398 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift index 4ccd0df..2b78b02 100644 --- a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift +++ b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift @@ -1,11 +1,19 @@ import SwiftUI #if os(iOS) import AVFAudio +import UIKit #endif @main struct Audiobookshelf_swiftApp: App { @State private var appState = AppState() + #if os(iOS) + /// Bridges the UIKit-only `handleEventsForBackgroundURLSession` callback + /// (used by iOS to wake the app when a background download completes) + /// into the SwiftUI lifecycle. Without this, in-flight downloads can't + /// notify the app of completion after a swipe-away. + @UIApplicationDelegateAdaptor(BackgroundDownloadAppDelegate.self) private var appDelegate + #endif init() { configureImageCache() @@ -54,3 +62,20 @@ struct Audiobookshelf_swiftApp: App { } #endif } + +#if os(iOS) +/// Receives the system's background URLSession completion callback and routes +/// the handler to whichever `DownloadManager` is currently active. We can't +/// reach the SwiftUI-owned `AppState` from here directly, so the handler is +/// stashed on `DownloadManager.pendingBackgroundCompletion` and consumed when +/// `urlSessionDidFinishEvents(forBackgroundURLSession:)` fires. +final class BackgroundDownloadAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + DownloadManager.pendingBackgroundCompletion = completionHandler + } +} +#endif diff --git a/ABS Client/Audiobookshelf swift/Services/AppState.swift b/ABS Client/Audiobookshelf swift/Services/AppState.swift index e1252a1..d5842b9 100644 --- a/ABS Client/Audiobookshelf swift/Services/AppState.swift +++ b/ABS Client/Audiobookshelf swift/Services/AppState.swift @@ -66,6 +66,9 @@ final class AppState { } func bootstrap() async { + #if os(iOS) + await NotificationManager.shared.requestPermissionIfNeeded() + #endif auth.restoreSession() network.start { [weak self] online in guard let self else { return } diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift index 9e1623f..27351cd 100644 --- a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift @@ -1,5 +1,8 @@ import Foundation import Observation +#if canImport(UIKit) +import UIKit +#endif struct DownloadedTrack: Codable, Hashable { let ino: String @@ -66,29 +69,111 @@ struct DownloadedItem: Codable, Hashable { @Observable @MainActor -final class DownloadManager: @unchecked Sendable { +final class DownloadManager: NSObject, @unchecked Sendable { private let client: ABSClient /// Keyed by downloadKey (itemId or "itemId|episodeId"). private(set) var states: [String: DownloadState] = [:] private(set) var downloadedItems: [String: DownloadedItem] = [:] /// Items currently being downloaded (removed on completion or cancellation). + /// Persisted so the list survives an app kill while a background download + /// is still in flight. private(set) var pendingItems: [String: LibraryItem] = [:] - /// Bytes received for the currently in-flight track per downloadKey. - /// Reset between tracks; cleared when the download finishes/cancels. + /// Per-download in-flight bytes for the currently active track(s). Background + /// session may have multiple parallel tasks; we sum the writes per key. private(set) var inFlightBytes: [String: Int64] = [:] - /// Current track index (0-based) per active download. Set at the start of - /// each track iteration, cleared via defer when the download exits. - private var currentTrackIndex: [String: Int] = [:] - /// Total number of tracks per active download. + /// Completed tracks per active download, keyed by track index. Persisted so + /// we can stitch the final DownloadedItem together if the app was killed. + private var completedTracks: [String: [Int: DownloadedTrack]] = [:] + /// Expected total track count per active download (persisted). private var totalTrackCount: [String: Int] = [:] + /// In-flight byte counts per track (downloadKey -> trackIndex -> bytesWritten). + /// Background sessions may run several tasks for the same download in + /// parallel; we keep per-track counts so the sum is accurate even when + /// multiple tracks are mid-flight. + private var perTrackBytes: [String: [Int: Int64]] = [:] + /// In-flight fractions per track (downloadKey -> trackIndex -> 0…1). + /// Drives a smooth progress ring even for single-track audiobooks where + /// `completedTracks` stays at 0 for the entire download. + private var perTrackFractions: [String: [Int: Double]] = [:] + /// Global FIFO queue of waiting tasks across ALL downloads. When the user + /// has parallel mode off, only `maxConcurrentDownloads` tasks run at any + /// time globally — so starting Book 2 while Book 1 is in flight leaves + /// Book 2 entirely queued until Book 1 finishes. + private var globalQueue: [QueuedUnit] = [] + /// Number of URLSession tasks we've handed off to the OS and that haven't + /// reported completion yet. Compared against `maxConcurrentDownloads` to + /// decide whether to drain the queue. + private var activeTaskCount: Int = 0 + /// Per-track URLRequest cache — used so we can rebuild a download task + /// even after the queue has shifted. + private var pendingRequests: [String: [Int: URLRequest]] = [:] + + private var maxConcurrentDownloads: Int { + let parallel = UserDefaults.standard.object(forKey: "downloadsParallel") as? Bool ?? true + return parallel ? Int.max : 1 + } private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } - private var activeTasks: [String: Task] = [:] + private var pendingFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-pending.json") } + + #if os(iOS) + private let activityManager = DownloadActivityManager() + /// Set when a download fails while the app is in the foreground. + /// The UI observes this to show an in-app alert; cleared on dismissal. + var downloadFailureAlertTitle: String? = nil + #endif + + /// Stashed by the AppDelegate when iOS wakes the app to handle a completed + /// background download. Called from `urlSessionDidFinishEvents`. + nonisolated(unsafe) static var pendingBackgroundCompletion: (() -> Void)? + + /// Last MainActor-progress-update timestamp per URLSession task identifier + /// (in mach nanoseconds). Used to throttle `didWriteData` callbacks — + /// accessed only from URLSession's serial delegate queue so it's safe. + nonisolated(unsafe) static var lastProgressNs: [Int: UInt64] = [:] + + /// Background URLSession used for all download tasks. On iOS this keeps + /// downloads alive when the app is suspended or terminated; on macOS it + /// behaves like a normal session. Set in `init` after `super.init()` + /// because `self` is needed as the delegate; @ObservationIgnored because + /// the @Observable macro can't synthesize accessors for IUO properties. + @ObservationIgnored + private var downloadSession: URLSession! + + private func makeDownloadSession() -> URLSession { + let identifier = "com.local.ABS-Client.bgDownloads" + #if os(iOS) + let config = URLSessionConfiguration.background(withIdentifier: identifier) + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + // Tells iOS this is interactive (vs. opportunistic bulk transfer) so + // it scheduling gives us closer-to-foreground throughput. On `.background` + // sessions this is mostly advisory but still respected for prioritization. + config.networkServiceType = .responsiveData + // Allow up to 6 parallel HTTPS connections to the ABS host — matches the + // default for foreground sessions and helps multi-track books download + // faster instead of being serialized to 1 connection. + config.httpMaximumConnectionsPerHost = 6 + #else + let config = URLSessionConfiguration.default + #endif + config.allowsCellularAccess = true + config.waitsForConnectivity = true + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 60 * 60 * 24 // 24h — big audiobooks + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + } init(client: ABSClient) { self.client = client + super.init() + // Created here (not as a property initializer) so the URLSession can + // pass `self` as its delegate and reconnect to any tasks left running + // by a previous app launch. + self.downloadSession = makeDownloadSession() try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true) loadIndex() + loadPending() } func state(for downloadKey: String) -> DownloadState { @@ -115,29 +200,6 @@ final class DownloadManager: @unchecked Sendable { return 0 } - /// Called from the URL session delegate (any queue) to update in-flight bytes - /// for a currently downloading track. - nonisolated func _updateInFlightBytes(_ bytes: Int64, for key: String) { - Task { @MainActor [self] in - self.inFlightBytes[key] = bytes - } - } - - /// Called from the URL session delegate with the fraction (0…1) of the - /// currently downloading track. Combines with the completed-track count to - /// drive a smooth overall progress ring, even for single-track downloads. - nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) { - Task { @MainActor [self] in - guard let idx = self.currentTrackIndex[downloadKey], - let total = self.totalTrackCount[downloadKey], - total > 0 else { return } - let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total) - // Clamp below 1.0 so `performDownload` is the only place that - // transitions to `.downloaded` after the final track is persisted. - self.states[downloadKey] = .downloading(progress: min(0.999, overall)) - } - } - private func fileSize(at url: URL) -> Int64 { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 } if let n = attrs[.size] as? NSNumber { return n.int64Value } @@ -153,11 +215,17 @@ final class DownloadManager: @unchecked Sendable { /// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile). func startDownload(item: LibraryItem) { let key = item.downloadKey - guard activeTasks[key] == nil else { return } + // Already downloaded or in progress — no-op + if downloadedItems[key] != nil { return } + if case .downloading = states[key] ?? .notDownloaded { return } + states[key] = .downloading(progress: 0) pendingItems[key] = item + #if os(iOS) + activityManager.startActivity(downloadKey: key, itemTitle: item.title) + #endif - let task = Task { @MainActor [weak self] in + Task { @MainActor [weak self] in guard let self else { return } var workItem = item @@ -167,28 +235,120 @@ final class DownloadManager: @unchecked Sendable { } catch { self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)") self.pendingItems.removeValue(forKey: key) - self.activeTasks[key] = nil + #if os(iOS) + self.handleDownloadFailure(itemTitle: item.title, downloadKey: key) + #endif return } } if workItem.audioFiles.isEmpty { self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.") self.pendingItems.removeValue(forKey: key) - self.activeTasks[key] = nil + #if os(iOS) + self.handleDownloadFailure(itemTitle: workItem.title, downloadKey: key) + #endif return } - await self.performDownload(workItem: workItem, downloadKey: key) - self.activeTasks[key] = nil + self.enqueueDownload(workItem: workItem, downloadKey: key) } - activeTasks[key] = task + } + + /// Builds the URLRequest + TaskMeta pairs for every audio file of `workItem` + /// and appends them to the global FIFO queue. Then `drainQueue` starts as + /// many as the current concurrency limit allows — in sequential mode that's + /// 1, so Book 2's tracks wait until Book 1 fully finishes. + private func enqueueDownload(workItem: LibraryItem, downloadKey: String) { + let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) + try? FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) + + pendingItems[downloadKey] = workItem + totalTrackCount[downloadKey] = workItem.audioFiles.count + completedTracks[downloadKey] = [:] + persistPending() + + let allowCellular = UserDefaults.standard.object(forKey: "downloadsOverMobile") as? Bool ?? true + + var requestMap: [Int: URLRequest] = [:] + var units: [QueuedUnit] = [] + + for (idx, file) in workItem.audioFiles.enumerated() { + guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue } + var request = URLRequest(url: url) + for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } + request.allowsCellularAccess = allowCellular + + let ext = file.ext.isEmpty ? "mp3" : file.ext + let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)" + + let meta = TaskMeta( + downloadKey: downloadKey, + trackIndex: idx, + itemId: workItem.id, + episodeId: workItem.episodeId, + destName: destName, + ino: file.ino, + filename: file.filename, + durationSeconds: file.durationSeconds + ) + requestMap[idx] = request + units.append(QueuedUnit(meta: meta, request: request)) + } + + pendingRequests[downloadKey] = requestMap + globalQueue.append(contentsOf: units) + drainQueue() + } + + /// Starts as many queued tasks as the current concurrency limit allows. + /// Reads the user setting fresh each call so a toggle takes effect on the + /// next slot that opens up. + private func drainQueue() { + let limit = maxConcurrentDownloads + while activeTaskCount < limit, !globalQueue.isEmpty { + let next = globalQueue.removeFirst() + activeTaskCount += 1 + startTask(meta: next.meta, request: next.request) + } + } + + private func startTask(meta: TaskMeta, request: URLRequest) { + let task = downloadSession.downloadTask(with: request) + task.taskDescription = meta.encoded() + task.resume() + } + + /// Called when a task is cancelled (via our cancel/delete or the OS) and + /// no track-completion or track-failure handler will fire for it. Keeps + /// `activeTaskCount` accurate so the next queued task can start. + fileprivate func handleTaskAborted() { + activeTaskCount = max(0, activeTaskCount - 1) + drainQueue() } func cancel(downloadKey: String) { - activeTasks[downloadKey]?.cancel() - activeTasks[downloadKey] = nil states[downloadKey] = .notDownloaded pendingItems.removeValue(forKey: downloadKey) inFlightBytes.removeValue(forKey: downloadKey) + perTrackBytes.removeValue(forKey: downloadKey) + perTrackFractions.removeValue(forKey: downloadKey) + completedTracks.removeValue(forKey: downloadKey) + totalTrackCount.removeValue(forKey: downloadKey) + pendingRequests.removeValue(forKey: downloadKey) + // Drop any queued (not-yet-started) tasks for this download. + globalQueue.removeAll { $0.meta.downloadKey == downloadKey } + persistPending() + #if os(iOS) + activityManager.endActivity(downloadKey: downloadKey) + #endif + // Cancel all in-flight URLSession tasks for this download (async). + downloadSession.getAllTasks { tasks in + for task in tasks { + if let meta = TaskMeta.decode(task.taskDescription), + meta.downloadKey == downloadKey { + task.cancel() + } + } + } } func delete(downloadKey: String) { @@ -234,142 +394,164 @@ final class DownloadManager: @unchecked Sendable { return "\(itemId)/\(fileName)" } - private func performDownload(workItem: LibraryItem, downloadKey: String) async { - defer { - pendingItems.removeValue(forKey: downloadKey) - inFlightBytes.removeValue(forKey: downloadKey) - currentTrackIndex.removeValue(forKey: downloadKey) - totalTrackCount.removeValue(forKey: downloadKey) + #if os(iOS) + /// Ends the Live Activity and routes the failure to either an in-app alert + /// (foreground) or a local notification (background). + private func handleDownloadFailure(itemTitle: String, downloadKey: String) { + activityManager.endActivity(downloadKey: downloadKey) + if UIApplication.shared.applicationState == .active { + // Only set if no alert is already pending — prevents a second + // concurrent failure from overwriting an unread message. + if downloadFailureAlertTitle == nil { + downloadFailureAlertTitle = itemTitle + } + } else { + Task { await NotificationManager.shared.sendDownloadFailedNotification(itemTitle: itemTitle) } } - let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) - do { - try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) - } catch { - states[downloadKey] = .failed(message: error.localizedDescription) + } + #endif + + // MARK: - Background-session bookkeeping + + /// Sums per-task in-flight byte counts (the background session may have + /// several tasks for the same download) and recomputes the overall progress + /// ring. The progress includes already-completed tracks PLUS the fraction + /// of each in-flight track — without this, single-track audiobooks stay + /// at 0 % until the whole file is on disk. + fileprivate func applyTaskProgress(meta: TaskMeta, totalBytesWritten: Int64, expectedBytes: Int64) { + perTrackBytes[meta.downloadKey, default: [:]][meta.trackIndex] = totalBytesWritten + if expectedBytes > 0 { + let fraction = min(1, max(0, Double(totalBytesWritten) / Double(expectedBytes))) + perTrackFractions[meta.downloadKey, default: [:]][meta.trackIndex] = fraction + } + let trackSum = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0 + inFlightBytes[meta.downloadKey] = trackSum + + if let total = totalTrackCount[meta.downloadKey], total > 0 { + let completed = Double(completedTracks[meta.downloadKey]?.count ?? 0) + let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0 + let progress = min(0.999, (completed + inflightSum) / Double(total)) + states[meta.downloadKey] = .downloading(progress: progress) + #if os(iOS) + activityManager.updateActivity( + downloadKey: meta.downloadKey, + progress: progress, + bytesDownloaded: downloadedBytes(for: meta.downloadKey) + ) + #endif + } + } + + /// Called when a single track completes successfully (its file has already + /// been moved to the final destination by the delegate). Records the track, + /// decrements the global active-task counter, and asks the queue to drain — + /// in sequential mode that frees the single slot for the next queued track. + fileprivate func handleTrackCompleted(meta: TaskMeta) { + activeTaskCount = max(0, activeTaskCount - 1) + + // Track is now on disk — clear its in-flight byte/fraction counters so + // we don't double-count (folderSize and completedTracks already include + // this track). + perTrackBytes[meta.downloadKey]?[meta.trackIndex] = 0 + perTrackFractions[meta.downloadKey]?[meta.trackIndex] = nil + let inFlight = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0 + inFlightBytes[meta.downloadKey] = inFlight + + let track = DownloadedTrack( + ino: meta.ino, + filename: meta.filename, + localPath: relativePath(itemId: meta.itemId, episodeId: meta.episodeId, fileName: meta.destName), + durationSeconds: meta.durationSeconds + ) + completedTracks[meta.downloadKey, default: [:]][meta.trackIndex] = track + persistPending() + + guard let total = totalTrackCount[meta.downloadKey], + let workItem = pendingItems[meta.downloadKey] else { + drainQueue() return } + let completed = completedTracks[meta.downloadKey] ?? [:] - var tracks: [DownloadedTrack] = [] - let total = max(workItem.audioFiles.count, 1) - totalTrackCount[downloadKey] = total - - for (idx, file) in workItem.audioFiles.enumerated() { - if Task.isCancelled { - states[downloadKey] = .notDownloaded - return - } - guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue } - var request = URLRequest(url: url) - for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } - currentTrackIndex[downloadKey] = idx - - let tempURL: URL - do { - tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey) - } catch is CancellationError { - states[downloadKey] = .notDownloaded - return - } catch { - states[downloadKey] = .failed(message: error.localizedDescription) - return - } - - let ext = file.ext.isEmpty ? "mp3" : file.ext - let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)" - let dest = itemDir.appendingPathComponent(destName) - do { - try? FileManager.default.removeItem(at: dest) - try FileManager.default.moveItem(at: tempURL, to: dest) - } catch { - states[downloadKey] = .failed(message: error.localizedDescription) - return - } - tracks.append(DownloadedTrack( - ino: file.ino, - filename: file.filename, - localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName), - durationSeconds: file.durationSeconds - )) - states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total)) - // Track is on disk now (folderSize picks it up). Clear the in-flight - // counter so the next track's bytes don't double-count. - inFlightBytes[downloadKey] = 0 + if completed.count == total { + finalizeDownload(workItem: workItem, downloadKey: meta.downloadKey, completed: completed) + } else { + let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0 + let progress = min(0.999, (Double(completed.count) + inflightSum) / Double(total)) + states[meta.downloadKey] = .downloading(progress: progress) + #if os(iOS) + activityManager.updateActivity( + downloadKey: meta.downloadKey, + progress: progress, + bytesDownloaded: downloadedBytes(for: meta.downloadKey) + ) + #endif } + drainQueue() + } + + private func finalizeDownload(workItem: LibraryItem, downloadKey: String, completed: [Int: DownloadedTrack]) { + let sortedTracks = completed.sorted(by: { $0.key < $1.key }).map { $0.value } let downloaded = DownloadedItem( itemId: workItem.id, episodeId: workItem.episodeId, title: workItem.title, author: workItem.author, durationSeconds: workItem.durationSeconds, - tracks: tracks + tracks: sortedTracks ) downloadedItems[downloadKey] = downloaded states[downloadKey] = .downloaded + pendingItems.removeValue(forKey: downloadKey) + inFlightBytes.removeValue(forKey: downloadKey) + perTrackBytes.removeValue(forKey: downloadKey) + perTrackFractions.removeValue(forKey: downloadKey) + completedTracks.removeValue(forKey: downloadKey) + totalTrackCount.removeValue(forKey: downloadKey) + pendingRequests.removeValue(forKey: downloadKey) + #if os(iOS) + activityManager.endActivity(downloadKey: downloadKey) + #endif persistIndex() + persistPending() } - /// Downloads with up to `maxAttempts` retries and resume-data support so a brief - /// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask - /// with explicit delegate (wrapped in a continuation) so we reliably get - /// `didWriteData` progress callbacks — the async `download(for:delegate:)` API - /// often doesn't fire them. - private func downloadWithRetry(request: URLRequest, filename: String, downloadKey: String, maxAttempts: Int = 5) async throws -> URL { - var resumeData: Data? = nil - var lastError: Error = URLError(.unknown) + /// Called by the delegate when a track fails irrecoverably. iOS's background + /// URLSession already retries transient network errors; if we still end up + /// here, the user will need to retry manually. + fileprivate func handleTrackFailed(meta: TaskMeta, errorMessage: String) { + activeTaskCount = max(0, activeTaskCount - 1) + defer { drainQueue() } - for attempt in 0.. (URL, URLResponse) { - let session = client.session - return try await withCheckedThrowingContinuation { continuation in - let delegate = DownloadProgressDelegate( - manager: self, - downloadKey: downloadKey, - continuation: continuation - ) - let task: URLSessionDownloadTask - if let resumeData { - task = session.downloadTask(withResumeData: resumeData) - } else { - task = session.downloadTask(with: request) - } - task.delegate = delegate - task.resume() - } } private func loadIndex() { @@ -401,83 +583,214 @@ final class DownloadManager: @unchecked Sendable { // non-fatal } } + + // MARK: - Pending-download persistence + + /// Snapshot written to disk so that a background download surviving an app + /// kill can be picked up again on next launch. + private struct PendingSnapshot: Codable { + var pendingItems: [String: LibraryItem] + var completedTracks: [String: [Int: DownloadedTrack]] + var totalTrackCount: [String: Int] + } + + private func persistPending() { + let snapshot = PendingSnapshot( + pendingItems: pendingItems, + completedTracks: completedTracks, + totalTrackCount: totalTrackCount + ) + do { + let data = try JSONEncoder().encode(snapshot) + try data.write(to: pendingFile, options: .atomic) + } catch { + // non-fatal + } + } + + private func loadPending() { + guard let data = try? Data(contentsOf: pendingFile), + let snapshot = try? JSONDecoder().decode(PendingSnapshot.self, from: data) + else { return } + pendingItems = snapshot.pendingItems + completedTracks = snapshot.completedTracks + totalTrackCount = snapshot.totalTrackCount + // Restore states from progress (won't be `.downloaded` since persistIndex + // is the only path that flips that). + for (key, item) in pendingItems { + if let total = totalTrackCount[key], total > 0 { + let done = Double(completedTracks[key]?.count ?? 0) + states[key] = .downloading(progress: min(0.999, done / Double(total))) + } else { + states[key] = .downloading(progress: 0) + } + _ = item // referenced for clarity + } + } } -/// Per-task delegate for a single `URLSessionDownloadTask`. Forwards live byte -/// progress to the manager and bridges the delegate callbacks back to async/await -/// via a checked continuation. The system deletes the `didFinishDownloadingTo` -/// temp URL immediately after the callback returns, so we move it to a stable -/// location before resuming. -private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { - private let manager: DownloadManager - private let downloadKey: String - private let continuation: CheckedContinuation<(URL, URLResponse), Error> - private var stableTempURL: URL? - private var didResume = false - private let lock = NSLock() +// MARK: - Queued unit - init( - manager: DownloadManager, - downloadKey: String, - continuation: CheckedContinuation<(URL, URLResponse), Error> - ) { - self.manager = manager - self.downloadKey = downloadKey - self.continuation = continuation - super.init() +/// One entry in `DownloadManager.globalQueue`. Holds everything needed to spin +/// up the URLSessionDownloadTask later, including the prepared URLRequest with +/// its bearer headers and cellular-allowance flag already applied. +fileprivate struct QueuedUnit { + let meta: TaskMeta + let request: URLRequest +} + +// MARK: - Task metadata + +/// Encoded into `URLSessionTask.taskDescription` so the delegate has everything +/// it needs to route bytes back to the right download / track on the iOS +/// background queue (which has no access to MainActor state). +/// +/// Uses a hand-rolled tab-separated encoding rather than `Codable` so we don't +/// need a nonisolated conformance — the project's +/// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` would otherwise pin Encodable +/// to MainActor, which can't be called from the URLSession delegate queue. +fileprivate struct TaskMeta: Sendable { + let downloadKey: String + let trackIndex: Int + let itemId: String + let episodeId: String? + let destName: String + let ino: String + let filename: String + let durationSeconds: Double + + private nonisolated static let separator = "\u{1F}" // ASCII unit separator — won't appear in IDs/filenames + + nonisolated func encoded() -> String { + let parts: [String] = [ + downloadKey, + String(trackIndex), + itemId, + episodeId ?? "", + destName, + ino, + filename, + String(durationSeconds) + ] + return parts.joined(separator: Self.separator) } - private func resumeOnce(with result: Result<(URL, URLResponse), Error>) { - lock.lock(); defer { lock.unlock() } - guard !didResume else { return } - didResume = true - continuation.resume(with: result) + nonisolated static func decode(_ description: String?) -> TaskMeta? { + guard let description else { return nil } + let parts = description.components(separatedBy: separator) + guard parts.count == 8, + let trackIndex = Int(parts[1]), + let durationSeconds = Double(parts[7]) + else { return nil } + return TaskMeta( + downloadKey: parts[0], + trackIndex: trackIndex, + itemId: parts[2], + episodeId: parts[3].isEmpty ? nil : parts[3], + destName: parts[4], + ino: parts[5], + filename: parts[6], + durationSeconds: durationSeconds + ) } +} - func urlSession( +// MARK: - URLSessionDownloadDelegate + +extension DownloadManager: URLSessionDownloadDelegate { + + nonisolated func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { - manager._updateInFlightBytes(totalBytesWritten, for: downloadKey) - if totalBytesExpectedToWrite > 0 { - let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) - manager._reportTrackByteFraction(fraction, for: downloadKey) + guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return } + // Throttle: only hop to MainActor at most every ~250 ms per task. The + // OS fires didWriteData many times a second; uncapped this floods + // SwiftUI's diff loop and starves the main thread (which slows the + // download too, since URLSession shares the same process). + let now = DispatchTime.now().uptimeNanoseconds + let lastNs = Self.lastProgressNs[downloadTask.taskIdentifier] ?? 0 + if now - lastNs < 250_000_000 { return } + Self.lastProgressNs[downloadTask.taskIdentifier] = now + let expected = totalBytesExpectedToWrite + Task { @MainActor [weak self] in + self?.applyTaskProgress(meta: meta, totalBytesWritten: totalBytesWritten, expectedBytes: expected) } } - func urlSession( + nonisolated func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { - // Move out of the system-temp folder before this delegate method returns - // (otherwise the OS deletes it). - let target = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".tmp") + guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return } + // Must move synchronously here — the OS removes `location` immediately + // after this method returns. + let downloadsDir = AppPaths.downloadsDirectory + let itemDir: URL = { + var dir = downloadsDir.appendingPathComponent(meta.itemId, isDirectory: true) + if let episodeId = meta.episodeId { + dir = dir.appendingPathComponent(episodeId, isDirectory: true) + } + return dir + }() + let destURL = itemDir.appendingPathComponent(meta.destName) do { - try FileManager.default.moveItem(at: location, to: target) - stableTempURL = target + try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) + try? FileManager.default.removeItem(at: destURL) + try FileManager.default.moveItem(at: location, to: destURL) } catch { - stableTempURL = nil + // didCompleteWithError will follow; bail out of completion reporting. + return + } + Task { @MainActor [weak self] in + self?.handleTrackCompleted(meta: meta) } } - func urlSession( + nonisolated func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { - if let error { - resumeOnce(with: .failure(error)) + // Free the throttle slot for this task identifier — tasks come and go, + // and this dict would otherwise grow without bound. + Self.lastProgressNs.removeValue(forKey: task.taskIdentifier) + + // Success path: `didFinishDownloadingTo` already fired and will decrement + // the active-task counter via `handleTrackCompleted`. + guard let error else { return } + + let nsError = error as NSError + if nsError.code == NSURLErrorCancelled { + // We initiated cancellation (cancel/delete/handleTrackFailed). The + // active counter still needs to come down so the next queued task + // can start. + Task { @MainActor [weak self] in + self?.handleTaskAborted() + } return } - guard let url = stableTempURL, let response = task.response else { - resumeOnce(with: .failure(URLError(.cannotCreateFile))) + guard let meta = TaskMeta.decode(task.taskDescription) else { + Task { @MainActor [weak self] in self?.handleTaskAborted() } return } - resumeOnce(with: .success((url, response))) + let message = error.localizedDescription + Task { @MainActor [weak self] in + self?.handleTrackFailed(meta: meta, errorMessage: message) + } } + + #if os(iOS) + nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + Task { @MainActor in + let handler = DownloadManager.pendingBackgroundCompletion + DownloadManager.pendingBackgroundCompletion = nil + handler?() + } + } + #endif } diff --git a/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift b/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift index 915558d..20b1409 100644 --- a/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift @@ -29,7 +29,9 @@ final class NotificationManager { let content = UNMutableNotificationContent() content.title = "Download fehlgeschlagen" - content.body = "„\(itemTitle)" konnte nicht heruntergeladen werden." + // \u{201E} = „ (DOUBLE LOW-9 QUOTATION MARK, German opening) + // \u{201C} = " (LEFT DOUBLE QUOTATION MARK, German closing) + content.body = "\u{201E}\(itemTitle)\u{201C} konnte nicht heruntergeladen werden." content.sound = .default let request = UNNotificationRequest( diff --git a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift index f644820..01ce982 100644 --- a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift +++ b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift @@ -519,7 +519,9 @@ final class PlayerEngine { object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] notification in - Task { @MainActor [weak self] in + // Already on .main queue — assumeIsolated hops to MainActor without + // a Task, which would otherwise capture Notification (non-Sendable). + MainActor.assumeIsolated { self?.handleAudioInterruption(notification: notification) } } @@ -529,7 +531,7 @@ final class PlayerEngine { object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] notification in - Task { @MainActor [weak self] in + MainActor.assumeIsolated { self?.handleRouteChange(notification: notification) } } diff --git a/ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift b/ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift index 2c7cd0e..d9b4348 100644 --- a/ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift @@ -82,13 +82,13 @@ final class ProgressSyncManager { } enum AppPaths { - static var supportDirectory: URL { + nonisolated static var supportDirectory: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true) } - static var downloadsDirectory: URL { + nonisolated static var downloadsDirectory: URL { supportDirectory.appendingPathComponent("downloads", isDirectory: true) } } diff --git a/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift b/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift index d829c09..5f8337c 100644 --- a/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift +++ b/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift @@ -63,6 +63,13 @@ struct FullHistoryView: View { Button(String(localized: "settings.done")) { dismiss() } } } + #else + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "settings.done")) { dismiss() } + .keyboardShortcut(.cancelAction) + } + } #endif .overlay { if app.history.entries.isEmpty { @@ -77,6 +84,8 @@ struct FullHistoryView: View { #if os(iOS) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) + #else + .frame(minWidth: 480, minHeight: 560) #endif } diff --git a/ABS Client/Audiobookshelf swift/Views/MainView.swift b/ABS Client/Audiobookshelf swift/Views/MainView.swift index a8be7cf..f228181 100644 --- a/ABS Client/Audiobookshelf swift/Views/MainView.swift +++ b/ABS Client/Audiobookshelf swift/Views/MainView.swift @@ -116,6 +116,21 @@ struct MainView: View { FullHistoryView() .environment(app) } + #if os(iOS) + .alert( + "Download fehlgeschlagen", + isPresented: Binding( + get: { app.downloads.downloadFailureAlertTitle != nil }, + set: { if !$0 { app.downloads.downloadFailureAlertTitle = nil } } + ) + ) { + Button("OK") { app.downloads.downloadFailureAlertTitle = nil } + } message: { + if let title = app.downloads.downloadFailureAlertTitle { + Text("\u{201E}\(title)\u{201C} konnte nicht heruntergeladen werden.") + } + } + #endif } @ViewBuilder diff --git a/ABS Client/Audiobookshelf swift/Views/SettingsView.swift b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift index ef258af..be6f663 100644 --- a/ABS Client/Audiobookshelf swift/Views/SettingsView.swift +++ b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift @@ -10,6 +10,8 @@ struct SettingsView: View { @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue @AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true @AppStorage("historyEnabled") private var historyEnabled: Bool = false + @AppStorage("downloadsParallel") private var downloadsParallel: Bool = true + @AppStorage("downloadsOverMobile") private var downloadsOverMobile: Bool = true @State private var showLogoutConfirm: Bool = false @State private var showHistoryDisableConfirm: Bool = false @@ -226,10 +228,12 @@ struct SettingsView: View { Text("\(app.downloads.downloadedItems.count) Einträge") .foregroundStyle(.secondary) } + Toggle("Parallel herunterladen", isOn: $downloadsParallel) + Toggle("Download über Mobilnetz erlauben", isOn: $downloadsOverMobile) } header: { Text("Downloads") } footer: { - Text("Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden.") + Text("Parallel lädt alle Tracks eines Hörbuchs gleichzeitig — schneller, aber höhere Server- und Netzlast. Bei deaktiviertem Mobilnetz starten Downloads erst wenn WLAN verfügbar ist.") } } @@ -346,8 +350,13 @@ struct SettingsView: View { private var aboutPane: some View { Form { - LabeledContent("Version", value: appVersion) - LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge") + Section("Downloads") { + Toggle("Parallel herunterladen", isOn: $downloadsParallel) + LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge") + } + Section("Über") { + LabeledContent("Version", value: appVersion) + } } .formStyle(.grouped) } diff --git a/ABS Client/DownloadWidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json b/ABS Client/DownloadWidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..ae11776 --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.337", + "green" : "0.847", + "red" : "0.298" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ABS Client/DownloadWidgetExtension/Assets.xcassets/Contents.json b/ABS Client/DownloadWidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ABS Client/DownloadWidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ABS Client/DownloadWidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..2942fb7 --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.094", + "green" : "0.196", + "red" : "0.078" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift b/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift index 1fa4c34..6fcb18e 100644 --- a/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift +++ b/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift @@ -15,7 +15,7 @@ struct DownloadLiveActivityWidget: Widget { // Expanded region shown when the user long-presses the island DynamicIslandExpandedRegion(.leading) { Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(.green) .font(.title2) } DynamicIslandExpandedRegion(.center) { @@ -27,29 +27,32 @@ struct DownloadLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.bottom) { VStack(spacing: 4) { ProgressView(value: context.state.progress) - .tint(.blue) + .tint(.green) HStack { Text(bytesFormatted(context.state.bytesDownloaded)) .font(.caption2) .foregroundStyle(.secondary) + .contentTransition(.numericText()) Spacer() Text(percentFormatted(context.state.progress)) .font(.caption2.bold()) .monospacedDigit() + .contentTransition(.numericText()) } } } } compactLeading: { Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(.green) .font(.caption) } compactTrailing: { Text(percentFormatted(context.state.progress)) .font(.caption2.bold()) .monospacedDigit() + .contentTransition(.numericText()) } minimal: { Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(.green) .font(.caption2) } } @@ -65,7 +68,7 @@ struct DownloadLockScreenView: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(.green) Text(context.attributes.itemTitle) .font(.subheadline.bold()) .lineLimit(1) @@ -73,12 +76,14 @@ struct DownloadLockScreenView: View { Text(percentFormatted(context.state.progress)) .font(.subheadline.bold()) .monospacedDigit() + .contentTransition(.numericText()) } ProgressView(value: context.state.progress) - .tint(.blue) + .tint(.green) Text(bytesFormatted(context.state.bytesDownloaded) + " heruntergeladen") .font(.caption) .foregroundStyle(.secondary) + .contentTransition(.numericText()) } .padding() } diff --git a/ABS Client/DownloadWidgetExtension/Info.plist b/ABS Client/DownloadWidgetExtension/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ABS Client/Info-iOS.plist b/ABS Client/Info-iOS.plist index abec678..da899a1 100644 --- a/ABS Client/Info-iOS.plist +++ b/ABS Client/Info-iOS.plist @@ -46,6 +46,10 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait diff --git a/Exports/Mac/Calendarr b/Exports/Mac/Calendarr new file mode 160000 index 0000000..e744b18 --- /dev/null +++ b/Exports/Mac/Calendarr @@ -0,0 +1 @@ +Subproject commit e744b1829e99db6b80922f75542ced329138e474