Compare commits

..

2 Commits

Author SHA1 Message Date
Scarriffle
134482bb4a Enhance download notifications and settings; add support for live activities
- Updated notification body to use proper German quotation marks for download failure alerts.
- Refactored audio interruption handling in PlayerEngine to utilize MainActor.assumeIsolated for better performance.
- Introduced nonisolated properties for support and downloads directories in ProgressSyncManager.
- Improved FullHistoryView and MainView with additional toolbar items and alerts for download failures.
- Added new toggles in SettingsView for parallel downloads and mobile data usage.
- Created new color assets for the DownloadWidgetExtension and updated widget appearance.
- Implemented Info.plist for DownloadWidgetExtension to support widget functionality.
- Enabled live activities support in iOS Info.plist.
2026-06-08 19:32:54 +02:00
Scarriffle
f053f1d320 Neues Icon, parallele Downloads 2026-06-08 19:32:18 +02:00
27 changed files with 1413 additions and 217 deletions

View File

@@ -6,16 +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 = "<group>"; };
39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadWidgetBundle.swift; sourceTree = "<group>"; };
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = "<group>"; };
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 = "<group>";
};
39C427112FD189220033EE46 /* DownloadWidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
39C4271E2FD189230033EE46 /* Exceptions for "DownloadWidgetExtension" folder in "DownloadWidgetExtensionExtension" target */,
);
path = DownloadWidgetExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -26,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 */
@@ -33,7 +96,10 @@
isa = PBXGroup;
children = (
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
39C427112FD189220033EE46 /* DownloadWidgetExtension */,
39C4270C2FD189220033EE46 /* Frameworks */,
39614D0C2FB4D44500DBEF5E /* Products */,
39C427032FD180A90033EE46 /* DownloadWidgetExtension */,
);
sourceTree = "<group>";
};
@@ -41,10 +107,30 @@
isa = PBXGroup;
children = (
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
39C427032FD180A90033EE46 /* DownloadWidgetExtension */ = {
isa = PBXGroup;
children = (
39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */,
39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */,
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */,
);
path = DownloadWidgetExtension;
sourceTree = "<group>";
};
39C4270C2FD189220033EE46 /* Frameworks */ = {
isa = PBXGroup;
children = (
39C4270D2FD189220033EE46 /* WidgetKit.framework */,
39C4270F2FD189220033EE46 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -55,10 +141,12 @@
39614D072FB4D44400DBEF5E /* Sources */,
39614D082FB4D44400DBEF5E /* Frameworks */,
39614D092FB4D44400DBEF5E /* Resources */,
39C427222FD189230033EE46 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
39C4271C2FD189230033EE46 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
@@ -70,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 */
@@ -77,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" */;
@@ -101,6 +214,7 @@
projectRoot = "";
targets = (
39614D0A2FB4D44400DBEF5E /* Audiobookshelf swift */,
39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */,
);
};
/* End PBXProject section */
@@ -113,6 +227,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
39C427092FD189220033EE46 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -123,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;
@@ -178,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;
@@ -238,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;
@@ -259,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;
@@ -273,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",
@@ -287,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;
@@ -313,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;
@@ -327,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",
@@ -341,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;
@@ -358,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 */
@@ -379,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 */;

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39C4270A2FD189220033EE46"
BuildableName = "DownloadWidgetExtensionExtension.appex"
BlueprintName = "DownloadWidgetExtensionExtension"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -9,6 +9,24 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>DownloadWidgetExtensionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>39614D0A2FB4D44400DBEF5E</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>39C4270A2FD189220033EE46</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><g><path d="m486.093 255.403h-10.908v-17.81c0-38.189-9.918-75.579-28.722-108.528l2.531-2.531c6.518-6.518 7.423-16.745 2.154-24.317-44.531-64.005-117.484-102.217-195.148-102.217s-150.617 38.212-195.148 102.217c-5.269 7.574-4.362 17.802 2.158 24.321l2.528 2.528c-18.804 32.948-28.722 70.338-28.722 108.528v17.81h-10.909c-14.285-.001-25.907 11.621-25.907 25.906v124.25c0 14.286 11.622 25.908 25.907 25.908h11.486c2.375 10.525 11.794 18.412 23.026 18.412h23.01c13.019 0 23.61-10.591 23.61-23.61v-18.75c0-4.142-3.358-7.5-7.5-7.5s-7.5 3.358-7.5 7.5v18.75c0 4.748-3.862 8.61-8.61 8.61h-23.009c-4.748 0-8.61-3.862-8.61-8.61l.005-165.766c.052-4.698 3.89-8.504 8.605-8.504h23.01c4.748 0 8.61 3.858 8.61 8.6v111.92c0 4.142 3.358 7.5 7.5 7.5s7.5-3.358 7.5-7.5v-111.92c0-13.013-10.591-23.6-23.61-23.6h-23.01c-3.036 0-5.937.582-8.605 1.63v-1.037c0-34.43 8.651-68.157 25.079-98.107 3.362 1.931 7.261 2.801 11.221 2.427 5.69-.541 10.801-3.573 14.021-8.321 34.725-51.206 92.245-81.777 153.864-81.777s119.139 30.571 153.865 81.777c3.22 4.748 8.33 7.781 14.021 8.321.605.058 1.209.086 1.81.086 3.328 0 6.555-.889 9.404-2.526 16.432 29.953 25.086 63.685 25.086 98.12v1.037c-2.668-1.048-5.569-1.63-8.605-1.63h-23.01c-13.019 0-23.61 10.587-23.61 23.6v165.67c0 13.013 10.591 23.6 23.61 23.6h23.01c11.232 0 20.65-7.883 23.026-18.402h11.487c14.285 0 25.907-11.622 25.907-25.908v-124.25c-.001-14.285-11.623-25.907-25.908-25.907zm-471.093 150.157v-124.25c0-6.014 4.893-10.907 10.907-10.907h10.903v146.065h-10.903c-6.014 0-10.907-4.893-10.907-10.908zm410.305-278.579c-.718-.068-2.066-.394-3.025-1.808-37.522-55.327-99.682-88.358-166.28-88.358s-128.758 33.031-166.279 88.358c-.958 1.414-2.307 1.739-3.025 1.807-.694.065-2.026.004-3.175-1.144l-9.906-9.906c-1.377-1.377-1.567-3.542-.45-5.147 41.728-59.976 110.078-95.783 182.835-95.783s141.107 35.807 182.835 95.784c1.117 1.604.928 3.768-.448 5.144l-9.91 9.91c-1.148 1.147-2.482 1.209-3.172 1.143zm71.695 278.579c0 6.015-4.893 10.908-10.907 10.908h-10.903v-69.908c0-4.142-3.358-7.5-7.5-7.5s-7.5 3.358-7.5 7.5v79.71c0 4.742-3.862 8.6-8.61 8.6h-23.01c-4.748 0-8.61-3.858-8.61-8.6v-165.67c0-4.742 3.862-8.6 8.61-8.6h23.01c4.715 0 8.553 3.806 8.605 8.504l.005 51.056c0 4.142 3.358 7.5 7.5 7.5s7.5-3.358 7.5-7.5v-41.157h10.903c6.014 0 10.907 4.893 10.907 10.907z"/><path d="m195.39 202.033c-4.142 0-7.5 3.358-7.5 7.5v70.711c0 4.142 3.358 7.5 7.5 7.5h141.423c4.142 0 7.5-3.358 7.5-7.5v-70.711c0-4.142-3.358-7.5-7.5-7.5zm133.923 70.711h-126.423v-55.711h126.423z"/><path d="m388.76 231.78c0-4.142-3.358-7.5-7.5-7.5s-7.5 3.358-7.5 7.5v168.245h-193.522c-16.173 0-30.758 6.901-40.988 17.903v-202.338c0-22.597 18.388-40.98 40.99-40.98h193.52v22.17c0 4.142 3.358 7.5 7.5 7.5s7.5-3.358 7.5-7.5v-29.67c0-4.142-3.358-7.5-7.5-7.5h-201.02c-30.873 0-55.99 25.112-55.99 55.98v240.42.001.002c0 30.872 25.116 55.988 55.988 55.988h201.022c4.142 0 7.5-3.358 7.5-7.5zm-15 224.239v8.655h-193.522c-4.776 0-8.663-3.886-8.663-8.663s3.886-8.663 8.663-8.663h193.522v8.66zm-193.522 40.981c-22.601 0-40.988-18.387-40.988-40.988s18.387-40.988 40.988-40.988h193.522v17.325h-193.522c-13.047 0-23.663 10.615-23.663 23.663s10.615 23.663 23.663 23.663h193.522v17.325z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1 @@
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m496 399.53a35.552 35.552 0 0 1 -27.493 34.57v-131.478a35.543 35.543 0 0 1 27.493 34.572zm-50.43-149.384v-.693a189.349 189.349 0 0 0 -323.459-133.891 188.22 188.22 0 0 0 -55.677 133.891v.693a44.424 44.424 0 0 0 -22.851 36.162c-.387.061-.766.137-1.15.2v-37.055c0-117.761 95.807-213.57 213.567-213.57s213.57 95.809 213.57 213.57v37.058c-.383-.066-.765-.142-1.152-.2a44.424 44.424 0 0 0 -22.848-36.162zm6.937 197.6v-158.763a28.382 28.382 0 0 0 -56.763 0v158.758a28.382 28.382 0 1 0 56.763 0zm-364.63-187.14a28.41 28.41 0 0 0 -28.377 28.377v158.758a28.382 28.382 0 1 0 56.763 0v-158.758a28.411 28.411 0 0 0 -28.381-28.377zm-71.877 138.924v-62.336a35.542 35.542 0 0 1 27.5-34.572v131.478a35.551 35.551 0 0 1 -27.5-34.57zm469.569-107.3v-42.777c0-61.077-23.946-118.658-67.427-162.141s-101.065-67.427-162.142-67.427-118.656 23.948-162.139 67.427-67.428 101.064-67.428 162.141v42.776a51.538 51.538 0 0 0 -26.433 44.965v62.336a51.568 51.568 0 0 0 43.582 50.882 44.377 44.377 0 0 0 88.674-2.671v-158.758a44.3 44.3 0 0 0 -49.766-44.041c2.4-93.629 79.31-169.057 173.51-169.057s171.112 75.428 173.511 169.057a44.3 44.3 0 0 0 -49.767 44.041v158.758a44.377 44.377 0 0 0 88.674 2.671 51.565 51.565 0 0 0 43.582-50.882v-62.336a51.535 51.535 0 0 0 -26.43-44.965z" fill="#fc0005" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 512 512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g id="_x30_2_Open_Book"><g><path d="m477.676 75.199h-7.883v-24.733c0-2.595-2.026-4.74-4.616-4.891-76.779-4.422-147.14 19.184-209.177 70.072-62.032-50.884-132.368-74.454-209.177-70.072-2.59.151-4.616 2.296-4.616 4.891v24.733h-7.883c-15.515 0-28.139 12.624-28.139 28.139v252.243c0 16.747 14.317 29.144 30.201 28.06 81.019-5.386 154.054 11.179 217.081 49.251.876.529 1.799.706 2.533.706 1.577 0 3.023-1.002 2.533-.706 37.11-22.416 77.712-37.316 121.346-44.731v73.886c0 1.813 1.002 3.478 2.602 4.327 1.6.851 3.54.751 5.044-.273l33.401-22.628 33.403 22.631c1.485 1.004 3.427 1.13 5.044.27 1.6-.849 2.602-2.514 2.602-4.327v-79.088c4.523.164 9.064.376 13.638.681 15.925 1.096 30.201-11.363 30.201-28.06v-252.242c.001-15.515-12.623-28.139-28.138-28.139zm-17.68-20.074v283.257c-84.128.299-149.435 25.757-199.098 77.675 0-30.74 0-256.701 0-291.834 59.151-48.985 126.144-72.238 199.098-69.098zm-407.992 0c72.99-3.131 139.954 20.115 199.098 69.098v291.834c-49.665-51.92-114.963-77.378-199.098-77.675zm-16.269 318.744c-10.579.723-19.754-7.519-19.754-18.287v-252.244c0-10.115 8.228-18.342 18.342-18.342h7.883v258.296c0 2.69 2.18 4.898 4.898 4.898h.038c75.386-.697 135.173 19.228 181.898 60.383-60.05-28.866-125.48-39.226-193.305-34.704zm416.445 78.945-28.505-19.314c-1.65-1.117-3.824-1.128-5.496 0l-28.503 19.314v-66.219c21.481-3.064 41.446-4.137 62.504-3.841zm43.839-97.233c0 10.645-9.026 19-19.754 18.287-71.479-4.765-136.401 7.361-193.35 34.747 46.75-41.184 106.609-61.124 181.941-60.425 2.717.135 4.937-2.246 4.937-4.898v-258.296h7.883c10.115 0 18.342 8.228 18.342 18.342v252.243z"/><path d="m301.494 175.45c34.083-32.767 75.948-50.153 124.434-51.674 2.705-.084 4.827-2.346 4.743-5.049-.086-2.705-2.308-4.86-5.049-4.743-50.985 1.6-95.032 19.904-130.916 54.406-1.952 1.875-2.011 4.975-.136 6.924 1.872 1.951 4.974 2.013 6.924.136z"/><path d="m301.494 222.621c34.083-32.767 75.948-50.153 124.434-51.674 2.705-.084 4.827-2.346 4.743-5.049-.086-2.705-2.308-4.862-5.049-4.743-50.985 1.6-95.032 19.904-130.916 54.406-1.952 1.875-2.011 4.975-.136 6.924 1.872 1.95 4.974 2.012 6.924.136z"/><path d="m301.494 272.037c34.083-32.767 75.948-50.153 124.434-51.674 2.705-.084 4.827-2.346 4.743-5.049-.086-2.705-2.308-4.788-5.049-4.743-50.985 1.6-95.032 19.904-130.916 54.406-1.952 1.875-2.011 4.975-.136 6.924 1.872 1.951 4.974 2.012 6.924.136z"/><path d="m301.494 321.757c34.083-32.767 75.948-50.153 124.434-51.674 2.705-.084 4.827-2.346 4.743-5.049-.086-2.705-2.308-4.774-5.049-4.743-50.985 1.6-95.032 19.904-130.916 54.405-1.952 1.875-2.011 4.975-.136 6.924 1.872 1.952 4.974 2.013 6.924.137z"/><path d="m91.623 113.984c-2.631-.055-4.963 2.038-5.049 4.743-.084 2.703 2.038 4.965 4.743 5.049 48.486 1.521 90.351 18.907 124.436 51.674 1.948 1.875 5.051 1.816 6.924-.136 1.875-1.949 1.815-5.049-.136-6.924-35.885-34.502-79.932-52.806-130.918-54.406z"/><path d="m91.623 161.155c-2.631-.098-4.963 2.038-5.049 4.743-.084 2.703 2.038 4.965 4.743 5.049 48.486 1.521 90.351 18.907 124.436 51.674 1.948 1.875 5.051 1.816 6.924-.136 1.875-1.949 1.815-5.049-.136-6.924-35.885-34.502-79.932-52.806-130.918-54.406z"/><path d="m91.623 210.571c-2.631-.069-4.963 2.038-5.049 4.743-.084 2.703 2.038 4.965 4.743 5.049 48.486 1.521 90.351 18.907 124.436 51.674 1.948 1.875 5.051 1.816 6.924-.136 1.875-1.949 1.815-5.049-.136-6.924-35.885-34.502-79.932-52.806-130.918-54.406z"/><path d="m91.623 260.291c-2.631-.1-4.963 2.038-5.049 4.743-.084 2.703 2.038 4.965 4.743 5.049 48.486 1.521 90.351 18.907 124.436 51.674 1.948 1.875 5.051 1.816 6.924-.136 1.875-1.949 1.815-5.049-.136-6.924-35.885-34.502-79.932-52.806-130.918-54.406z"/></g></g><g id="Layer_1"/></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -0,0 +1,19 @@
#if os(iOS)
import ActivityKit
import Foundation
/// Shared between the main app (to drive the activity lifecycle) and the
/// DownloadWidgetExtension (to render the Lock Screen / Dynamic Island UI).
/// Both targets must compile this exact definition keep them in sync.
struct DownloadActivityAttributes: ActivityAttributes {
/// Item title set once at activity start; never changes during the download.
let itemTitle: String
struct ContentState: Codable, Hashable {
/// Overall fraction across all tracks (0.0 1.0).
var progress: Double
/// Total bytes written to disk so far, shown as "X MB downloaded".
var bytesDownloaded: Int64
}
}
#endif

View File

@@ -0,0 +1,63 @@
#if os(iOS)
import ActivityKit
import Foundation
import os.log
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ABS", category: "LiveActivity")
/// Manages one `Activity<DownloadActivityAttributes>` per active download key.
/// All methods must be called from the MainActor (same isolation as DownloadManager).
@MainActor
final class DownloadActivityManager {
// Keyed by downloadKey (matches DownloadManager's key scheme).
private var activities: [String: Activity<DownloadActivityAttributes>] = [:]
/// Requests a new Live Activity for a download that is starting.
/// Logs the reason to the console (visible in Xcode) if the request fails.
func startActivity(downloadKey: String, itemTitle: String) {
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
logger.warning("Live Activities disabled (areActivitiesEnabled=false) — skipping for '\(itemTitle)'")
return
}
let attributes = DownloadActivityAttributes(itemTitle: itemTitle)
let initial = DownloadActivityAttributes.ContentState(
progress: 0,
bytesDownloaded: 0
)
let content = ActivityContent(state: initial, staleDate: nil)
do {
let activity = try Activity.request(attributes: attributes, content: content)
activities[downloadKey] = activity
logger.info("Live Activity started: id=\(activity.id) title='\(itemTitle)'")
} catch {
// Most common reason: no widget extension is installed/embedded that
// provides an ActivityConfiguration for DownloadActivityAttributes.
logger.error("Activity.request() failed: \(error.localizedDescription)")
}
}
/// Pushes an incremental progress update to the running Live Activity.
func updateActivity(downloadKey: String, progress: Double, bytesDownloaded: Int64) {
guard let activity = activities[downloadKey] else { return }
let state = DownloadActivityAttributes.ContentState(
progress: progress,
bytesDownloaded: bytesDownloaded
)
let content = ActivityContent(state: state, staleDate: nil)
// activity.update() is async; fire-and-forget is intentional a skipped
// frame doesn't hurt the download and avoids blocking the download loop.
Task { await activity.update(content) }
}
/// Ends the Live Activity with `.immediate` dismissal so the Lock Screen
/// badge disappears right away instead of lingering for the default 4 hours.
func endActivity(downloadKey: String) {
guard let activity = activities.removeValue(forKey: downloadKey) else { return }
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
}
#endif

View File

@@ -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 -> 01).
/// 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<Void, Never>] = [:]
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 (01) 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,141 +394,163 @@ 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) }
}
}
#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
}
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)
return
}
var tracks: [DownloadedTrack] = []
let total = max(workItem.audioFiles.count, 1)
totalTrackCount[downloadKey] = total
/// 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)
for (idx, file) in workItem.audioFiles.enumerated() {
if Task.isCancelled {
states[downloadKey] = .notDownloaded
// 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
}
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 completed = completedTracks[meta.downloadKey] ?? [:]
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
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
}
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
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..<maxAttempts {
try Task.checkCancellation()
do {
let (tempURL, response) = try await streamingDownload(
request: request,
resumeData: resumeData,
downloadKey: downloadKey
)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
try? FileManager.default.removeItem(at: tempURL)
throw URLError(.badServerResponse)
}
return tempURL
} catch is CancellationError {
throw CancellationError()
} catch let error as NSError {
resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
lastError = error
if attempt < maxAttempts - 1 {
// Exponential backoff: 1 s, 2 s, 4 s, 8 s
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
try await Task.sleep(nanoseconds: delay)
}
}
}
throw lastError
}
// Only react once per download if we already failed/cancelled the
// overall download, ignore further per-track failures.
guard pendingItems[meta.downloadKey] != nil else { return }
let title = pendingItems[meta.downloadKey]?.title ?? "Download"
states[meta.downloadKey] = .failed(message: errorMessage)
pendingItems.removeValue(forKey: meta.downloadKey)
perTrackBytes.removeValue(forKey: meta.downloadKey)
perTrackFractions.removeValue(forKey: meta.downloadKey)
completedTracks.removeValue(forKey: meta.downloadKey)
totalTrackCount.removeValue(forKey: meta.downloadKey)
inFlightBytes.removeValue(forKey: meta.downloadKey)
pendingRequests.removeValue(forKey: meta.downloadKey)
globalQueue.removeAll { $0.meta.downloadKey == meta.downloadKey }
persistPending()
#if os(iOS)
handleDownloadFailure(itemTitle: title, downloadKey: meta.downloadKey)
#endif
/// Runs a single download attempt via URLSessionDownloadTask, reporting byte
/// progress to `inFlightBytes` and moving the system-temp file to a stable
/// location before returning.
private func streamingDownload(
request: URLRequest,
resumeData: Data?,
downloadKey: String
) async throws -> (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)
// Cancel any sibling tasks for the same download so we don't keep
// pulling bytes for a download we've already given up on.
let cancelKey = meta.downloadKey
downloadSession.getAllTasks { tasks in
for task in tasks {
if let m = TaskMeta.decode(task.taskDescription), m.downloadKey == cancelKey {
task.cancel()
}
}
task.delegate = delegate
task.resume()
}
}
@@ -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]
}
/// 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()
init(
manager: DownloadManager,
downloadKey: String,
continuation: CheckedContinuation<(URL, URLResponse), Error>
) {
self.manager = manager
self.downloadKey = downloadKey
self.continuation = continuation
super.init()
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 resumeOnce(with result: Result<(URL, URLResponse), Error>) {
lock.lock(); defer { lock.unlock() }
guard !didResume else { return }
didResume = true
continuation.resume(with: result)
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
}
}
}
func urlSession(
// MARK: - Queued unit
/// 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)
}
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
)
}
}
// 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
}

View File

@@ -0,0 +1,44 @@
import Foundation
import UserNotifications
#if canImport(UIKit)
import UIKit
#endif
/// Manages local UNUserNotificationCenter notifications for download events.
@MainActor
final class NotificationManager {
static let shared = NotificationManager()
private init() {}
/// Requests notification authorization the first time the app launches.
/// Skips the system prompt if permission has already been granted or denied.
func requestPermissionIfNeeded() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .notDetermined else { return }
_ = try? await center.requestAuthorization(options: [.alert, .sound])
}
/// Fires a local notification when a download fails.
/// Suppressed while the app is in the foreground an in-app alert already
/// surfaces the error there, so a notification would be redundant.
func sendDownloadFailedNotification(itemTitle: String) async {
#if canImport(UIKit)
guard UIApplication.shared.applicationState != .active else { return }
#endif
let content = UNMutableNotificationContent()
content.title = "Download fehlgeschlagen"
// \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(
identifier: "download-failed-\(UUID().uuidString)",
content: content,
trigger: nil // deliver immediately
)
try? await UNUserNotificationCenter.current().add(request)
}
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,9 +350,14 @@ struct SettingsView: View {
private var aboutPane: some View {
Form {
LabeledContent("Version", value: appVersion)
Section("Downloads") {
Toggle("Parallel herunterladen", isOn: $downloadsParallel)
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
}
Section("Über") {
LabeledContent("Version", value: appVersion)
}
}
.formStyle(.grouped)
}
#endif

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,17 @@
import ActivityKit
import Foundation
/// Shared between the main app (to drive the activity lifecycle) and the
/// DownloadWidgetExtension (to render the Lock Screen / Dynamic Island UI).
/// Both targets must compile this exact definition keep them in sync.
struct DownloadActivityAttributes: ActivityAttributes {
/// Item title set once at activity start; never changes during the download.
let itemTitle: String
struct ContentState: Codable, Hashable {
/// Overall fraction across all tracks (0.0 1.0).
var progress: Double
/// Total bytes written to disk so far, shown as "X MB downloaded".
var bytesDownloaded: Int64
}
}

View File

@@ -0,0 +1,135 @@
import ActivityKit
import SwiftUI
import WidgetKit
// MARK: - Widget configuration
struct DownloadLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
// Lock Screen / Notification Center banner
DownloadLockScreenView(context: context)
.activityBackgroundTint(Color(.systemBackground).opacity(0.6))
} dynamicIsland: { context in
DynamicIsland {
// Expanded region shown when the user long-presses the island
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.green)
.font(.title2)
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.itemTitle)
.font(.caption.bold())
.lineLimit(1)
.minimumScaleFactor(0.8)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 4) {
ProgressView(value: context.state.progress)
.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(.green)
.font(.caption)
} compactTrailing: {
Text(percentFormatted(context.state.progress))
.font(.caption2.bold())
.monospacedDigit()
.contentTransition(.numericText())
} minimal: {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.green)
.font(.caption2)
}
}
}
}
// MARK: - Lock Screen view
struct DownloadLockScreenView: View {
let context: ActivityViewContext<DownloadActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.green)
Text(context.attributes.itemTitle)
.font(.subheadline.bold())
.lineLimit(1)
Spacer()
Text(percentFormatted(context.state.progress))
.font(.subheadline.bold())
.monospacedDigit()
.contentTransition(.numericText())
}
ProgressView(value: context.state.progress)
.tint(.green)
Text(bytesFormatted(context.state.bytesDownloaded) + " heruntergeladen")
.font(.caption)
.foregroundStyle(.secondary)
.contentTransition(.numericText())
}
.padding()
}
}
// MARK: - Helpers
private func percentFormatted(_ fraction: Double) -> String {
String(format: "%.0f%%", fraction * 100)
}
private func bytesFormatted(_ bytes: Int64) -> String {
let mb = Double(bytes) / (1_024 * 1_024)
if mb >= 1 { return String(format: "%.1f MB", mb) }
let kb = Double(bytes) / 1_024
if kb >= 1 { return String(format: "%.0f KB", kb) }
return "\(bytes) B"
}
// MARK: - Previews
private let previewAttributes = DownloadActivityAttributes(itemTitle: "The Name of the Wind")
#Preview("Lock Screen", as: .content, using: previewAttributes) {
DownloadLiveActivityWidget()
} contentStates: {
DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800)
DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304)
}
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: previewAttributes) {
DownloadLiveActivityWidget()
} contentStates: {
DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800)
DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: previewAttributes) {
DownloadLiveActivityWidget()
} contentStates: {
DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800)
DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304)
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: previewAttributes) {
DownloadLiveActivityWidget()
} contentStates: {
DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800)
}

View File

@@ -0,0 +1,31 @@
// MARK: - Widget Extension Entry Point
//
// HOW TO WIRE THIS UP IN XCODE (one-time setup):
//
// 1. File > New > Target > Widget Extension
// - Product Name: DownloadWidgetExtension
// - Bundle ID: com.local.ABS-Client.DownloadWidgetExtension
// - Uncheck "Include Configuration App Intent"
// - Minimum deployment: iOS 16.2 (first stable ActivityKit release)
//
// 2. In the new target's Build Phases > Compile Sources, add:
// - DownloadWidgetBundle.swift (this file)
// - DownloadLiveActivityWidget.swift
// - DownloadActivityAttributes.swift (the copy in this folder)
//
// 3. In the main app target's Build Phases > Compile Sources, confirm:
// - DownloadActivityAttributes.swift (the copy in Services/)
// is included. The two copies must stay identical.
//
// 4. In the main app's Build Phases > Embed App Extensions, add the
// DownloadWidgetExtension.appex product.
import SwiftUI
import WidgetKit
@main
struct DownloadWidgetBundle: WidgetBundle {
var body: some Widget {
DownloadLiveActivityWidget()
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -46,6 +46,10 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

1
Exports/Mac/Calendarr Submodule

Submodule Exports/Mac/Calendarr added at e744b1829e