Compare commits
2 Commits
9497c6e315
...
134482bb4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
134482bb4a | ||
|
|
f053f1d320 |
@@ -6,16 +6,70 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
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 */
|
/* Begin PBXFileReference section */
|
||||||
39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */ = {
|
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = "Audiobookshelf swift";
|
path = "Audiobookshelf swift";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
39C427112FD189220033EE46 /* DownloadWidgetExtension */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
39C4271E2FD189230033EE46 /* Exceptions for "DownloadWidgetExtension" folder in "DownloadWidgetExtensionExtension" target */,
|
||||||
|
);
|
||||||
|
path = DownloadWidgetExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -26,6 +80,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
39C427082FD189220033EE46 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
39C427102FD189220033EE46 /* SwiftUI.framework in Frameworks */,
|
||||||
|
39C4270E2FD189220033EE46 /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -33,7 +96,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
|
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
|
||||||
|
39C427112FD189220033EE46 /* DownloadWidgetExtension */,
|
||||||
|
39C4270C2FD189220033EE46 /* Frameworks */,
|
||||||
39614D0C2FB4D44500DBEF5E /* Products */,
|
39614D0C2FB4D44500DBEF5E /* Products */,
|
||||||
|
39C427032FD180A90033EE46 /* DownloadWidgetExtension */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -41,10 +107,30 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
|
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
|
||||||
|
39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -55,10 +141,12 @@
|
|||||||
39614D072FB4D44400DBEF5E /* Sources */,
|
39614D072FB4D44400DBEF5E /* Sources */,
|
||||||
39614D082FB4D44400DBEF5E /* Frameworks */,
|
39614D082FB4D44400DBEF5E /* Frameworks */,
|
||||||
39614D092FB4D44400DBEF5E /* Resources */,
|
39614D092FB4D44400DBEF5E /* Resources */,
|
||||||
|
39C427222FD189230033EE46 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
39C4271C2FD189230033EE46 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
|
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
|
||||||
@@ -70,6 +158,28 @@
|
|||||||
productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */;
|
productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -77,12 +187,15 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2640;
|
LastSwiftUpdateCheck = 2650;
|
||||||
LastUpgradeCheck = 2640;
|
LastUpgradeCheck = 2640;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
39614D0A2FB4D44400DBEF5E = {
|
39614D0A2FB4D44400DBEF5E = {
|
||||||
CreatedOnToolsVersion = 26.4.1;
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
};
|
};
|
||||||
|
39C4270A2FD189220033EE46 = {
|
||||||
|
CreatedOnToolsVersion = 26.5;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 39614D062FB4D44400DBEF5E /* Build configuration list for PBXProject "Audiobookshelf swift" */;
|
buildConfigurationList = 39614D062FB4D44400DBEF5E /* Build configuration list for PBXProject "Audiobookshelf swift" */;
|
||||||
@@ -101,6 +214,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
39614D0A2FB4D44400DBEF5E /* Audiobookshelf swift */,
|
39614D0A2FB4D44400DBEF5E /* Audiobookshelf swift */,
|
||||||
|
39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -113,6 +227,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
39C427092FD189220033EE46 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -123,8 +244,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
39C427072FD189220033EE46 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
39C4271C2FD189230033EE46 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */;
|
||||||
|
targetProxy = 39C4271B2FD189230033EE46 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
39614D142FB4D44500DBEF5E /* Debug */ = {
|
39614D142FB4D44500DBEF5E /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -178,9 +314,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.4;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -238,9 +374,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.4;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -259,7 +395,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -273,7 +409,7 @@
|
|||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -287,7 +423,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -313,7 +449,7 @@
|
|||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -327,7 +463,7 @@
|
|||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -341,7 +477,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -358,6 +494,72 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -379,6 +581,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
39C4271F2FD189230033EE46 /* Build configuration list for PBXNativeTarget "DownloadWidgetExtensionExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
39C427202FD189230033EE46 /* Debug */,
|
||||||
|
39C427212FD189230033EE46 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 39614D032FB4D44400DBEF5E /* Project object */;
|
rootObject = 39614D032FB4D44400DBEF5E /* Project object */;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -9,6 +9,24 @@
|
|||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</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>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
102
ABS Client/Audiobookshelf swift/AppIcon.icon/icon.json
Normal file
102
ABS Client/Audiobookshelf swift/AppIcon.icon/icon.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import AVFAudio
|
import AVFAudio
|
||||||
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Audiobookshelf_swiftApp: App {
|
struct Audiobookshelf_swiftApp: App {
|
||||||
@State private var appState = AppState()
|
@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() {
|
init() {
|
||||||
configureImageCache()
|
configureImageCache()
|
||||||
@@ -54,3 +62,20 @@ struct Audiobookshelf_swiftApp: App {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
|
#if os(iOS)
|
||||||
|
await NotificationManager.shared.requestPermissionIfNeeded()
|
||||||
|
#endif
|
||||||
auth.restoreSession()
|
auth.restoreSession()
|
||||||
network.start { [weak self] online in
|
network.start { [weak self] online in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct DownloadedTrack: Codable, Hashable {
|
struct DownloadedTrack: Codable, Hashable {
|
||||||
let ino: String
|
let ino: String
|
||||||
@@ -66,29 +69,111 @@ struct DownloadedItem: Codable, Hashable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class DownloadManager: @unchecked Sendable {
|
final class DownloadManager: NSObject, @unchecked Sendable {
|
||||||
private let client: ABSClient
|
private let client: ABSClient
|
||||||
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
||||||
private(set) var states: [String: DownloadState] = [:]
|
private(set) var states: [String: DownloadState] = [:]
|
||||||
private(set) var downloadedItems: [String: DownloadedItem] = [:]
|
private(set) var downloadedItems: [String: DownloadedItem] = [:]
|
||||||
/// Items currently being downloaded (removed on completion or cancellation).
|
/// 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] = [:]
|
private(set) var pendingItems: [String: LibraryItem] = [:]
|
||||||
/// Bytes received for the currently in-flight track per downloadKey.
|
/// Per-download in-flight bytes for the currently active track(s). Background
|
||||||
/// Reset between tracks; cleared when the download finishes/cancels.
|
/// session may have multiple parallel tasks; we sum the writes per key.
|
||||||
private(set) var inFlightBytes: [String: Int64] = [:]
|
private(set) var inFlightBytes: [String: Int64] = [:]
|
||||||
/// Current track index (0-based) per active download. Set at the start of
|
/// Completed tracks per active download, keyed by track index. Persisted so
|
||||||
/// each track iteration, cleared via defer when the download exits.
|
/// we can stitch the final DownloadedItem together if the app was killed.
|
||||||
private var currentTrackIndex: [String: Int] = [:]
|
private var completedTracks: [String: [Int: DownloadedTrack]] = [:]
|
||||||
/// Total number of tracks per active download.
|
/// Expected total track count per active download (persisted).
|
||||||
private var totalTrackCount: [String: Int] = [:]
|
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 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) {
|
init(client: ABSClient) {
|
||||||
self.client = client
|
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)
|
try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
|
||||||
loadIndex()
|
loadIndex()
|
||||||
|
loadPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
func state(for downloadKey: String) -> DownloadState {
|
func state(for downloadKey: String) -> DownloadState {
|
||||||
@@ -115,29 +200,6 @@ final class DownloadManager: @unchecked Sendable {
|
|||||||
return 0
|
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 {
|
private func fileSize(at url: URL) -> Int64 {
|
||||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
|
||||||
if let n = attrs[.size] as? NSNumber { return n.int64Value }
|
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).
|
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
|
||||||
func startDownload(item: LibraryItem) {
|
func startDownload(item: LibraryItem) {
|
||||||
let key = item.downloadKey
|
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)
|
states[key] = .downloading(progress: 0)
|
||||||
pendingItems[key] = item
|
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 }
|
guard let self else { return }
|
||||||
var workItem = item
|
var workItem = item
|
||||||
|
|
||||||
@@ -167,28 +235,120 @@ final class DownloadManager: @unchecked Sendable {
|
|||||||
} catch {
|
} catch {
|
||||||
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
||||||
self.pendingItems.removeValue(forKey: key)
|
self.pendingItems.removeValue(forKey: key)
|
||||||
self.activeTasks[key] = nil
|
#if os(iOS)
|
||||||
|
self.handleDownloadFailure(itemTitle: item.title, downloadKey: key)
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if workItem.audioFiles.isEmpty {
|
if workItem.audioFiles.isEmpty {
|
||||||
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
||||||
self.pendingItems.removeValue(forKey: key)
|
self.pendingItems.removeValue(forKey: key)
|
||||||
self.activeTasks[key] = nil
|
#if os(iOS)
|
||||||
|
self.handleDownloadFailure(itemTitle: workItem.title, downloadKey: key)
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.performDownload(workItem: workItem, downloadKey: key)
|
self.enqueueDownload(workItem: workItem, downloadKey: key)
|
||||||
self.activeTasks[key] = nil
|
|
||||||
}
|
}
|
||||||
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) {
|
func cancel(downloadKey: String) {
|
||||||
activeTasks[downloadKey]?.cancel()
|
|
||||||
activeTasks[downloadKey] = nil
|
|
||||||
states[downloadKey] = .notDownloaded
|
states[downloadKey] = .notDownloaded
|
||||||
pendingItems.removeValue(forKey: downloadKey)
|
pendingItems.removeValue(forKey: downloadKey)
|
||||||
inFlightBytes.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) {
|
func delete(downloadKey: String) {
|
||||||
@@ -234,142 +394,164 @@ final class DownloadManager: @unchecked Sendable {
|
|||||||
return "\(itemId)/\(fileName)"
|
return "\(itemId)/\(fileName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
#if os(iOS)
|
||||||
defer {
|
/// Ends the Live Activity and routes the failure to either an in-app alert
|
||||||
pendingItems.removeValue(forKey: downloadKey)
|
/// (foreground) or a local notification (background).
|
||||||
inFlightBytes.removeValue(forKey: downloadKey)
|
private func handleDownloadFailure(itemTitle: String, downloadKey: String) {
|
||||||
currentTrackIndex.removeValue(forKey: downloadKey)
|
activityManager.endActivity(downloadKey: downloadKey)
|
||||||
totalTrackCount.removeValue(forKey: 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 {
|
#endif
|
||||||
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
// MARK: - Background-session bookkeeping
|
||||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
|
||||||
|
/// 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
|
return
|
||||||
}
|
}
|
||||||
|
let completed = completedTracks[meta.downloadKey] ?? [:]
|
||||||
|
|
||||||
var tracks: [DownloadedTrack] = []
|
if completed.count == total {
|
||||||
let total = max(workItem.audioFiles.count, 1)
|
finalizeDownload(workItem: workItem, downloadKey: meta.downloadKey, completed: completed)
|
||||||
totalTrackCount[downloadKey] = total
|
} else {
|
||||||
|
let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0
|
||||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
let progress = min(0.999, (Double(completed.count) + inflightSum) / Double(total))
|
||||||
if Task.isCancelled {
|
states[meta.downloadKey] = .downloading(progress: progress)
|
||||||
states[downloadKey] = .notDownloaded
|
#if os(iOS)
|
||||||
return
|
activityManager.updateActivity(
|
||||||
}
|
downloadKey: meta.downloadKey,
|
||||||
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
progress: progress,
|
||||||
var request = URLRequest(url: url)
|
bytesDownloaded: downloadedBytes(for: meta.downloadKey)
|
||||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
)
|
||||||
currentTrackIndex[downloadKey] = idx
|
#endif
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
let downloaded = DownloadedItem(
|
||||||
itemId: workItem.id,
|
itemId: workItem.id,
|
||||||
episodeId: workItem.episodeId,
|
episodeId: workItem.episodeId,
|
||||||
title: workItem.title,
|
title: workItem.title,
|
||||||
author: workItem.author,
|
author: workItem.author,
|
||||||
durationSeconds: workItem.durationSeconds,
|
durationSeconds: workItem.durationSeconds,
|
||||||
tracks: tracks
|
tracks: sortedTracks
|
||||||
)
|
)
|
||||||
downloadedItems[downloadKey] = downloaded
|
downloadedItems[downloadKey] = downloaded
|
||||||
states[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()
|
persistIndex()
|
||||||
|
persistPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads with up to `maxAttempts` retries and resume-data support so a brief
|
/// Called by the delegate when a track fails irrecoverably. iOS's background
|
||||||
/// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask
|
/// URLSession already retries transient network errors; if we still end up
|
||||||
/// with explicit delegate (wrapped in a continuation) so we reliably get
|
/// here, the user will need to retry manually.
|
||||||
/// `didWriteData` progress callbacks — the async `download(for:delegate:)` API
|
fileprivate func handleTrackFailed(meta: TaskMeta, errorMessage: String) {
|
||||||
/// often doesn't fire them.
|
activeTaskCount = max(0, activeTaskCount - 1)
|
||||||
private func downloadWithRetry(request: URLRequest, filename: String, downloadKey: String, maxAttempts: Int = 5) async throws -> URL {
|
defer { drainQueue() }
|
||||||
var resumeData: Data? = nil
|
|
||||||
var lastError: Error = URLError(.unknown)
|
|
||||||
|
|
||||||
for attempt in 0..<maxAttempts {
|
// Only react once per download — if we already failed/cancelled the
|
||||||
try Task.checkCancellation()
|
// overall download, ignore further per-track failures.
|
||||||
do {
|
guard pendingItems[meta.downloadKey] != nil else { return }
|
||||||
let (tempURL, response) = try await streamingDownload(
|
let title = pendingItems[meta.downloadKey]?.title ?? "Download"
|
||||||
request: request,
|
states[meta.downloadKey] = .failed(message: errorMessage)
|
||||||
resumeData: resumeData,
|
pendingItems.removeValue(forKey: meta.downloadKey)
|
||||||
downloadKey: downloadKey
|
perTrackBytes.removeValue(forKey: meta.downloadKey)
|
||||||
)
|
perTrackFractions.removeValue(forKey: meta.downloadKey)
|
||||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
completedTracks.removeValue(forKey: meta.downloadKey)
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
totalTrackCount.removeValue(forKey: meta.downloadKey)
|
||||||
throw URLError(.badServerResponse)
|
inFlightBytes.removeValue(forKey: meta.downloadKey)
|
||||||
}
|
pendingRequests.removeValue(forKey: meta.downloadKey)
|
||||||
return tempURL
|
globalQueue.removeAll { $0.meta.downloadKey == meta.downloadKey }
|
||||||
} catch is CancellationError {
|
persistPending()
|
||||||
throw CancellationError()
|
#if os(iOS)
|
||||||
} catch let error as NSError {
|
handleDownloadFailure(itemTitle: title, downloadKey: meta.downloadKey)
|
||||||
resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
#endif
|
||||||
lastError = error
|
|
||||||
if attempt < maxAttempts - 1 {
|
// Cancel any sibling tasks for the same download so we don't keep
|
||||||
// Exponential backoff: 1 s, 2 s, 4 s, 8 s …
|
// pulling bytes for a download we've already given up on.
|
||||||
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
|
let cancelKey = meta.downloadKey
|
||||||
try await Task.sleep(nanoseconds: delay)
|
downloadSession.getAllTasks { tasks in
|
||||||
|
for task in tasks {
|
||||||
|
if let m = TaskMeta.decode(task.taskDescription), m.downloadKey == cancelKey {
|
||||||
|
task.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
task.delegate = delegate
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadIndex() {
|
private func loadIndex() {
|
||||||
@@ -401,83 +583,214 @@ final class DownloadManager: @unchecked Sendable {
|
|||||||
// non-fatal
|
// 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
|
// MARK: - Queued unit
|
||||||
/// 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(
|
/// One entry in `DownloadManager.globalQueue`. Holds everything needed to spin
|
||||||
manager: DownloadManager,
|
/// up the URLSessionDownloadTask later, including the prepared URLRequest with
|
||||||
downloadKey: String,
|
/// its bearer headers and cellular-allowance flag already applied.
|
||||||
continuation: CheckedContinuation<(URL, URLResponse), Error>
|
fileprivate struct QueuedUnit {
|
||||||
) {
|
let meta: TaskMeta
|
||||||
self.manager = manager
|
let request: URLRequest
|
||||||
self.downloadKey = downloadKey
|
}
|
||||||
self.continuation = continuation
|
|
||||||
super.init()
|
// 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>) {
|
nonisolated static func decode(_ description: String?) -> TaskMeta? {
|
||||||
lock.lock(); defer { lock.unlock() }
|
guard let description else { return nil }
|
||||||
guard !didResume else { return }
|
let parts = description.components(separatedBy: separator)
|
||||||
didResume = true
|
guard parts.count == 8,
|
||||||
continuation.resume(with: result)
|
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,
|
_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didWriteData bytesWritten: Int64,
|
didWriteData bytesWritten: Int64,
|
||||||
totalBytesWritten: Int64,
|
totalBytesWritten: Int64,
|
||||||
totalBytesExpectedToWrite: Int64
|
totalBytesExpectedToWrite: Int64
|
||||||
) {
|
) {
|
||||||
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
|
guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return }
|
||||||
if totalBytesExpectedToWrite > 0 {
|
// Throttle: only hop to MainActor at most every ~250 ms per task. The
|
||||||
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
// OS fires didWriteData many times a second; uncapped this floods
|
||||||
manager._reportTrackByteFraction(fraction, for: downloadKey)
|
// 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,
|
_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didFinishDownloadingTo location: URL
|
didFinishDownloadingTo location: URL
|
||||||
) {
|
) {
|
||||||
// Move out of the system-temp folder before this delegate method returns
|
guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return }
|
||||||
// (otherwise the OS deletes it).
|
// Must move synchronously here — the OS removes `location` immediately
|
||||||
let target = FileManager.default.temporaryDirectory
|
// after this method returns.
|
||||||
.appendingPathComponent(UUID().uuidString + ".tmp")
|
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 {
|
do {
|
||||||
try FileManager.default.moveItem(at: location, to: target)
|
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
||||||
stableTempURL = target
|
try? FileManager.default.removeItem(at: destURL)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destURL)
|
||||||
} catch {
|
} 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,
|
_ session: URLSession,
|
||||||
task: URLSessionTask,
|
task: URLSessionTask,
|
||||||
didCompleteWithError error: Error?
|
didCompleteWithError error: Error?
|
||||||
) {
|
) {
|
||||||
if let error {
|
// Free the throttle slot for this task identifier — tasks come and go,
|
||||||
resumeOnce(with: .failure(error))
|
// 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
|
return
|
||||||
}
|
}
|
||||||
guard let url = stableTempURL, let response = task.response else {
|
guard let meta = TaskMeta.decode(task.taskDescription) else {
|
||||||
resumeOnce(with: .failure(URLError(.cannotCreateFile)))
|
Task { @MainActor [weak self] in self?.handleTaskAborted() }
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -519,7 +519,9 @@ final class PlayerEngine {
|
|||||||
object: AVAudioSession.sharedInstance(),
|
object: AVAudioSession.sharedInstance(),
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] notification in
|
) { [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)
|
self?.handleAudioInterruption(notification: notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,7 +531,7 @@ final class PlayerEngine {
|
|||||||
object: AVAudioSession.sharedInstance(),
|
object: AVAudioSession.sharedInstance(),
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] notification in
|
) { [weak self] notification in
|
||||||
Task { @MainActor [weak self] in
|
MainActor.assumeIsolated {
|
||||||
self?.handleRouteChange(notification: notification)
|
self?.handleRouteChange(notification: notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ final class ProgressSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AppPaths {
|
enum AppPaths {
|
||||||
static var supportDirectory: URL {
|
nonisolated static var supportDirectory: URL {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
|
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
|
||||||
return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true)
|
return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var downloadsDirectory: URL {
|
nonisolated static var downloadsDirectory: URL {
|
||||||
supportDirectory.appendingPathComponent("downloads", isDirectory: true)
|
supportDirectory.appendingPathComponent("downloads", isDirectory: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ struct FullHistoryView: View {
|
|||||||
Button(String(localized: "settings.done")) { dismiss() }
|
Button(String(localized: "settings.done")) { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "settings.done")) { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
.overlay {
|
.overlay {
|
||||||
if app.history.entries.isEmpty {
|
if app.history.entries.isEmpty {
|
||||||
@@ -77,6 +84,8 @@ struct FullHistoryView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
|
#else
|
||||||
|
.frame(minWidth: 480, minHeight: 560)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ struct MainView: View {
|
|||||||
FullHistoryView()
|
FullHistoryView()
|
||||||
.environment(app)
|
.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
|
@ViewBuilder
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
||||||
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
@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 showLogoutConfirm: Bool = false
|
||||||
@State private var showHistoryDisableConfirm: Bool = false
|
@State private var showHistoryDisableConfirm: Bool = false
|
||||||
@@ -226,10 +228,12 @@ struct SettingsView: View {
|
|||||||
Text("\(app.downloads.downloadedItems.count) Einträge")
|
Text("\(app.downloads.downloadedItems.count) Einträge")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
Toggle("Parallel herunterladen", isOn: $downloadsParallel)
|
||||||
|
Toggle("Download über Mobilnetz erlauben", isOn: $downloadsOverMobile)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Downloads")
|
Text("Downloads")
|
||||||
} footer: {
|
} 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 {
|
private var aboutPane: some View {
|
||||||
Form {
|
Form {
|
||||||
LabeledContent("Version", value: appVersion)
|
Section("Downloads") {
|
||||||
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
|
Toggle("Parallel herunterladen", isOn: $downloadsParallel)
|
||||||
|
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
|
||||||
|
}
|
||||||
|
Section("Über") {
|
||||||
|
LabeledContent("Version", value: appVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ABS Client/DownloadWidgetExtension/Info.plist
Normal file
11
ABS Client/DownloadWidgetExtension/Info.plist
Normal 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>
|
||||||
@@ -46,6 +46,10 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
1
Exports/Mac/Calendarr
Submodule
1
Exports/Mac/Calendarr
Submodule
Submodule Exports/Mac/Calendarr added at e744b1829e
Reference in New Issue
Block a user