Compare commits
6 Commits
9497c6e315
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77342d619b | ||
|
|
a17a61ad9a | ||
|
|
1dbd133b75 | ||
|
|
fec48cb2af | ||
|
|
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 /* Nook.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nook.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,18 +96,41 @@
|
|||||||
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>";
|
||||||
};
|
};
|
||||||
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
|
39614D0B2FB4D44500DBEF5E /* Nook.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 */,
|
||||||
@@ -67,9 +155,31 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "Audiobookshelf swift";
|
productName = "Audiobookshelf swift";
|
||||||
productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */;
|
productReference = 39614D0B2FB4D44500DBEF5E /* Nook.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 = 8;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -268,12 +404,12 @@
|
|||||||
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
||||||
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
||||||
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
INFOPLIST_KEY_CFBundleDisplayName = "Nook";
|
||||||
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
INFOPLIST_KEY_CFBundleName = "Nook";
|
||||||
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,9 +423,9 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "Nook";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = 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 = 8;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -322,12 +458,12 @@
|
|||||||
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
||||||
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
||||||
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
INFOPLIST_KEY_CFBundleDisplayName = "Nook";
|
||||||
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
INFOPLIST_KEY_CFBundleName = "Nook";
|
||||||
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,9 +477,9 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "Nook";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -358,6 +494,76 @@
|
|||||||
};
|
};
|
||||||
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 = 8;
|
||||||
|
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.6;
|
||||||
|
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";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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 = 8;
|
||||||
|
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.6;
|
||||||
|
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";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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 +585,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,114 @@
|
|||||||
|
<?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 = "NO"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
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 = "Nook.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">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
||||||
|
BuildableName = "Nook.app"
|
||||||
|
BlueprintName = "Audiobookshelf swift"
|
||||||
|
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<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 = "Nook.app"
|
||||||
|
BlueprintName = "Audiobookshelf swift"
|
||||||
|
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -6,9 +6,24 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>Audiobookshelf swift.xcscheme_^#shared#^_</key>
|
<key>Audiobookshelf swift.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>DownloadWidgetExtensionExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>isShown</key>
|
||||||
|
<false/>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
<dict>
|
||||||
|
<key>39C4270A2FD189220033EE46</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</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()
|
||||||
@@ -48,9 +56,35 @@ struct Audiobookshelf_swiftApp: App {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
// Nur die Kategorie registrieren — setActive(true) passiert erst in play(),
|
// `.spokenAudio` + `.longFormAudio` policy: iOS-Hinweis dass es sich um
|
||||||
// damit beim App-Start keine laufende Fremd-Wiedergabe unterbrochen wird.
|
// ernsthafte Audio-Wiedergabe handelt (nicht Tasten-Sounds). Aktiviert
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
// u. a. das AirPlay-Routing im Control Center / Lockscreen und sorgt
|
||||||
|
// für richtigen Ducking-/Interruption-Behavior bei Anrufen.
|
||||||
|
// setActive(true) passiert erst in play() damit beim App-Start
|
||||||
|
// keine laufende Fremd-Wiedergabe unterbrochen wird.
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(
|
||||||
|
.playback,
|
||||||
|
mode: .spokenAudio,
|
||||||
|
policy: .longFormAudio,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
}
|
}
|
||||||
#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
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ struct Library: Codable, Identifiable, Hashable {
|
|||||||
let mediaType: String?
|
let mediaType: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wichtig für `ForEach`: Synthetische Podcast-Episoden-Items (z.B. aus der
|
||||||
|
/// library-weiten Folgensuche) teilen sich denselben `id` (Podcast-Container-ID)
|
||||||
|
/// und unterscheiden sich nur über `episodeId`. Default-`Identifiable` würde
|
||||||
|
/// hier kollidieren. **Immer** `ForEach(items, id: \.syncKey)` benutzen wenn
|
||||||
|
/// die Liste sowohl Container als auch Episoden-Items enthalten kann.
|
||||||
struct LibraryItem: Codable, Identifiable, Hashable {
|
struct LibraryItem: Codable, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
@@ -74,6 +79,10 @@ struct PlaybackProgress: Codable, Hashable {
|
|||||||
var duration: Double
|
var duration: Double
|
||||||
var isFinished: Bool
|
var isFinished: Bool
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
/// Server-assigned UUID for this progress entry. Required to delete it —
|
||||||
|
/// Audiobookshelf's `DELETE /api/me/progress/:id` matches by this id, NOT
|
||||||
|
/// by libraryItemId. Optional so older persisted caches still decode.
|
||||||
|
var id: String?
|
||||||
|
|
||||||
var syncKey: String {
|
var syncKey: String {
|
||||||
if let episodeId { return "\(itemId)|\(episodeId)" }
|
if let episodeId { return "\(itemId)|\(episodeId)" }
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ final class ABSClient {
|
|||||||
func fetchLibraries() async throws -> [Library] {
|
func fetchLibraries() async throws -> [Library] {
|
||||||
let req = try makeRequest(path: "/api/libraries")
|
let req = try makeRequest(path: "/api/libraries")
|
||||||
let dto = try await perform(req, as: LibrariesResponseDTO.self)
|
let dto = try await perform(req, as: LibrariesResponseDTO.self)
|
||||||
return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
|
return dto.libraries.map { Library(id: $0.id, name: Self.decodeHTMLEntities($0.name), mediaType: $0.mediaType) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
||||||
@@ -84,6 +84,62 @@ final class ABSClient {
|
|||||||
return dto.results.map { Self.toLibraryItem(from: $0) }
|
return dto.results.map { Self.toLibraryItem(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audiobookshelf serves podcast titles straight from the RSS feed, which
|
||||||
|
/// often contains HTML entities (`ü`, `€`, `€` etc.).
|
||||||
|
/// SwiftUI renders those literally, so we decode them here once at the
|
||||||
|
/// API boundary — every title/author/chapter/library name goes through
|
||||||
|
/// this before reaching the UI.
|
||||||
|
static func decodeHTMLEntities(_ s: String) -> String {
|
||||||
|
guard s.contains("&") else { return s }
|
||||||
|
var result = s
|
||||||
|
let named: [String: String] = [
|
||||||
|
"&": "&", "<": "<", ">": ">", """: "\"",
|
||||||
|
"'": "'", " ": " ",
|
||||||
|
"€": "€", "£": "£", "¥": "¥",
|
||||||
|
"©": "©", "®": "®", "™": "™",
|
||||||
|
"…": "…", "—": "—", "–": "–",
|
||||||
|
"«": "«", "»": "»",
|
||||||
|
"„": "„", "“": "\u{201C}", "”": "\u{201D}",
|
||||||
|
"‘": "\u{2018}", "’": "\u{2019}",
|
||||||
|
"ä": "ä", "Ä": "Ä",
|
||||||
|
"ö": "ö", "Ö": "Ö",
|
||||||
|
"ü": "ü", "Ü": "Ü",
|
||||||
|
"ß": "ß",
|
||||||
|
"é": "é", "É": "É",
|
||||||
|
"è": "è", "È": "È",
|
||||||
|
"à": "à", "À": "À",
|
||||||
|
"â": "â", "Â": "Â",
|
||||||
|
"ê": "ê", "Ê": "Ê",
|
||||||
|
"î": "î", "Î": "Î",
|
||||||
|
"ô": "ô", "Ô": "Ô",
|
||||||
|
"û": "û", "Û": "Û",
|
||||||
|
"ñ": "ñ", "Ñ": "Ñ",
|
||||||
|
"ç": "ç", "Ç": "Ç",
|
||||||
|
]
|
||||||
|
for (entity, char) in named {
|
||||||
|
result = result.replacingOccurrences(of: entity, with: char)
|
||||||
|
}
|
||||||
|
// Numeric refs: Ӓ (decimal) or ꯍ (hex)
|
||||||
|
if result.contains("&#"),
|
||||||
|
let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
|
||||||
|
let nsRange = NSRange(result.startIndex..., in: result)
|
||||||
|
// Reverse so prior replacements don't shift later ranges.
|
||||||
|
let matches = regex.matches(in: result, range: nsRange).reversed()
|
||||||
|
for match in matches {
|
||||||
|
guard let full = Range(match.range, in: result),
|
||||||
|
let hexFlag = Range(match.range(at: 1), in: result),
|
||||||
|
let num = Range(match.range(at: 2), in: result) else { continue }
|
||||||
|
let isHex = !result[hexFlag].isEmpty
|
||||||
|
let numStr = String(result[num])
|
||||||
|
let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
|
||||||
|
if let code, let scalar = Unicode.Scalar(code) {
|
||||||
|
result.replaceSubrange(full, with: String(scalar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
||||||
let meta = raw.media?.metadata
|
let meta = raw.media?.metadata
|
||||||
let mediaType = raw.mediaType ?? "book"
|
let mediaType = raw.mediaType ?? "book"
|
||||||
@@ -99,16 +155,16 @@ final class ABSClient {
|
|||||||
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
||||||
var item = LibraryItem(
|
var item = LibraryItem(
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
title: meta?.title ?? "Unbekannt",
|
title: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
|
||||||
author: authorString,
|
author: decodeHTMLEntities(authorString),
|
||||||
durationSeconds: raw.media?.duration ?? 0,
|
durationSeconds: raw.media?.duration ?? 0,
|
||||||
audioFiles: files
|
audioFiles: files
|
||||||
)
|
)
|
||||||
item.mediaType = mediaType
|
item.mediaType = mediaType
|
||||||
item.description = meta?.description
|
item.description = meta?.description.map { decodeHTMLEntities($0) }
|
||||||
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
|
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
|
||||||
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
|
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
|
||||||
return Chapter(id: id, start: start, end: end, title: title)
|
return Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title))
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
@@ -129,7 +185,7 @@ final class ABSClient {
|
|||||||
)
|
)
|
||||||
return PodcastEpisode(
|
return PodcastEpisode(
|
||||||
id: ep.id,
|
id: ep.id,
|
||||||
title: ep.title ?? "Folge",
|
title: Self.decodeHTMLEntities(ep.title ?? "Folge"),
|
||||||
pubDate: ep.pubDate,
|
pubDate: ep.pubDate,
|
||||||
publishedAtMillis: ep.publishedAt,
|
publishedAtMillis: ep.publishedAt,
|
||||||
season: ep.season,
|
season: ep.season,
|
||||||
@@ -167,7 +223,8 @@ final class ABSClient {
|
|||||||
currentTime: dto.currentTime ?? 0,
|
currentTime: dto.currentTime ?? 0,
|
||||||
duration: dto.duration ?? 0,
|
duration: dto.duration ?? 0,
|
||||||
isFinished: dto.isFinished ?? false,
|
isFinished: dto.isFinished ?? false,
|
||||||
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
|
updatedAt: Self.parseLastUpdate(dto.lastUpdate),
|
||||||
|
id: dto.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +251,26 @@ final class ABSClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a media-progress entry on the server. Audiobookshelf's
|
||||||
|
/// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
|
||||||
|
/// NOT by libraryItemId (which is what PATCH uses) — this is the source
|
||||||
|
/// of the long-running "delete didn't stick" bug. 404 is treated as
|
||||||
|
/// success (entry already gone).
|
||||||
|
func deleteProgress(progressId: String) async throws {
|
||||||
|
let req = try makeRequest(
|
||||||
|
path: "/api/me/progress/\(progressId)",
|
||||||
|
method: "DELETE"
|
||||||
|
)
|
||||||
|
let (_, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw ABSClientError.httpStatus(0)
|
||||||
|
}
|
||||||
|
if http.statusCode == 404 { return }
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw ABSClientError.httpStatus(http.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fetchAllProgress() async throws -> [PlaybackProgress] {
|
func fetchAllProgress() async throws -> [PlaybackProgress] {
|
||||||
let req = try makeRequest(path: "/api/me")
|
let req = try makeRequest(path: "/api/me")
|
||||||
let dto = try await perform(req, as: MeResponseDTO.self)
|
let dto = try await perform(req, as: MeResponseDTO.self)
|
||||||
@@ -205,7 +282,8 @@ final class ABSClient {
|
|||||||
currentTime: p.currentTime ?? 0,
|
currentTime: p.currentTime ?? 0,
|
||||||
duration: p.duration ?? 0,
|
duration: p.duration ?? 0,
|
||||||
isFinished: p.isFinished ?? false,
|
isFinished: p.isFinished ?? false,
|
||||||
updatedAt: Self.parseLastUpdate(p.lastUpdate)
|
updatedAt: Self.parseLastUpdate(p.lastUpdate),
|
||||||
|
id: p.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ final class AppState {
|
|||||||
/// Used to show progress bars on covers in the library views.
|
/// Used to show progress bars on covers in the library views.
|
||||||
var progressCache: [String: PlaybackProgress] = [:]
|
var progressCache: [String: PlaybackProgress] = [:]
|
||||||
|
|
||||||
|
/// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
|
||||||
|
/// Populated whenever the user actually plays an item, persisted to disk so
|
||||||
|
/// the Home screen can show "recently played" cards on first launch without
|
||||||
|
/// any network round-trip.
|
||||||
|
var recentItemsCache: [String: LibraryItem] = [:]
|
||||||
|
|
||||||
/// Server-progress that's newer than local but we haven't applied yet because
|
/// Server-progress that's newer than local but we haven't applied yet because
|
||||||
/// playback is active/paused. Offered via alert on next play; cleared on
|
/// playback is active/paused. Offered via alert on next play; cleared on
|
||||||
/// item change.
|
/// item change.
|
||||||
@@ -66,7 +72,11 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
|
#if os(iOS)
|
||||||
|
await NotificationManager.shared.requestPermissionIfNeeded()
|
||||||
|
#endif
|
||||||
auth.restoreSession()
|
auth.restoreSession()
|
||||||
|
loadRecentItemsCache()
|
||||||
network.start { [weak self] online in
|
network.start { [weak self] online in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if online {
|
if online {
|
||||||
@@ -217,6 +227,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentItem = workItem
|
currentItem = workItem
|
||||||
|
// Cache full item details for the Home screen — we paid the
|
||||||
|
// fetchItemDetail cost above anyway, no reason to redo it later.
|
||||||
|
recentItemsCache[workItem.syncKey] = workItem
|
||||||
|
persistRecentItems()
|
||||||
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
||||||
if player.errorMessage == nil {
|
if player.errorMessage == nil {
|
||||||
player.play()
|
player.play()
|
||||||
@@ -241,6 +255,52 @@ final class AppState {
|
|||||||
await play(item: item, overrideStartAt: entry.position)
|
await play(item: item, overrideStartAt: entry.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a media-progress entry from the server AND the local caches
|
||||||
|
/// (`progressCache`, `recentItemsCache`, pending sync queue). The Home
|
||||||
|
/// screen reacts immediately because it reads from `progressCache`. If
|
||||||
|
/// the server call fails we still clear the local cache — the next
|
||||||
|
/// `refreshProgressCache()` will re-sync from authoritative state (and
|
||||||
|
/// the entry would come back if the server delete didn't go through).
|
||||||
|
func removeProgress(itemId: String, episodeId: String? = nil) async {
|
||||||
|
let key: String
|
||||||
|
if let episodeId { key = "\(itemId)|\(episodeId)" } else { key = itemId }
|
||||||
|
|
||||||
|
// Grab the server-assigned progress UUID BEFORE we wipe the cache —
|
||||||
|
// Audiobookshelf's DELETE endpoint matches by this UUID, not by
|
||||||
|
// libraryItemId, so we need it to actually hit the right entry.
|
||||||
|
var progressId = progressCache[key]?.id
|
||||||
|
|
||||||
|
progressCache.removeValue(forKey: key)
|
||||||
|
recentItemsCache.removeValue(forKey: key)
|
||||||
|
persistRecentItems()
|
||||||
|
|
||||||
|
guard network.isOnline else { return }
|
||||||
|
|
||||||
|
// Cache might be from before the `id` field was added — fall back to
|
||||||
|
// a fresh fetchProgress to obtain it.
|
||||||
|
if progressId == nil {
|
||||||
|
progressId = (try? await client.fetchProgress(itemId: itemId, episodeId: episodeId))?.id
|
||||||
|
}
|
||||||
|
guard let pid = progressId else { return }
|
||||||
|
try? await client.deleteProgress(progressId: pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays an item from the Home screen / recent-items cache. Books resume
|
||||||
|
/// via the normal flow, but podcasts need an extra step: the cached
|
||||||
|
/// LibraryItem carries an `episodeId` but no `audioFiles` (those live on
|
||||||
|
/// the PodcastEpisode, not the podcast container). We fetch the episode
|
||||||
|
/// list, locate the matching one, and hand off to `play(podcast:episode:)`.
|
||||||
|
func playFromRecents(_ item: LibraryItem) async {
|
||||||
|
guard item.isPodcastEpisode, let episodeId = item.episodeId else {
|
||||||
|
await play(item: item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard network.isOnline,
|
||||||
|
let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: item.id),
|
||||||
|
let episode = episodes.first(where: { $0.id == episodeId }) else { return }
|
||||||
|
await play(podcast: podcast, episode: episode)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience for podcast episodes.
|
/// Convenience for podcast episodes.
|
||||||
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
||||||
var synthetic = LibraryItem(
|
var synthetic = LibraryItem(
|
||||||
@@ -320,6 +380,105 @@ final class AppState {
|
|||||||
syncTimer = timer
|
syncTimer = timer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent items cache
|
||||||
|
|
||||||
|
private var recentItemsFile: URL {
|
||||||
|
AppPaths.supportDirectory.appendingPathComponent("recent-items.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecentItemsCache() {
|
||||||
|
guard let data = try? Data(contentsOf: recentItemsFile),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: LibraryItem].self, from: data) else { return }
|
||||||
|
recentItemsCache = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistRecentItems() {
|
||||||
|
// Cap at 100 most-recently-touched syncKeys (based on progressCache) so
|
||||||
|
// the file doesn't grow unbounded for users with massive listening history.
|
||||||
|
let keep = Set(progressCache.values
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(100)
|
||||||
|
.map { $0.syncKey })
|
||||||
|
let trimmed = recentItemsCache.filter { keep.contains($0.key) || recentItemsCache.count <= 100 }
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
||||||
|
let data = try JSONEncoder().encode(trimmed)
|
||||||
|
try data.write(to: recentItemsFile, options: .atomic)
|
||||||
|
recentItemsCache = trimmed
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills `recentItemsCache` for the top-N most recent progress entries that
|
||||||
|
/// don't yet have details. Pulls from downloaded items first (no network),
|
||||||
|
/// then `fetchItemDetail` for the rest. Called by HomeView on appear.
|
||||||
|
func fillRecentItemsCache(limit: Int = 20) async {
|
||||||
|
let topKeys = progressCache.values
|
||||||
|
.filter { !$0.isFinished }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(limit)
|
||||||
|
.map { $0.syncKey }
|
||||||
|
|
||||||
|
var newlyAdded = false
|
||||||
|
for key in topKeys {
|
||||||
|
// Skip if already cached AND has audio files. A cached podcast
|
||||||
|
// item with empty audioFiles is a stale entry from the old
|
||||||
|
// fillRecentItemsCache path (fetchItemDetail returned a container,
|
||||||
|
// not an episode) — re-fetch via fetchEpisodes below.
|
||||||
|
if let existing = recentItemsCache[key], !existing.audioFiles.isEmpty { continue }
|
||||||
|
// 1) Try downloaded items first
|
||||||
|
if let downloaded = downloads.downloadedItems[key] {
|
||||||
|
let files: [AudioFile] = downloaded.tracks.enumerated().map { idx, t in
|
||||||
|
AudioFile(ino: t.ino, filename: t.filename, ext: "", durationSeconds: t.durationSeconds, index: idx)
|
||||||
|
}
|
||||||
|
var li = LibraryItem(
|
||||||
|
id: downloaded.itemId,
|
||||||
|
title: downloaded.title,
|
||||||
|
author: downloaded.author,
|
||||||
|
durationSeconds: downloaded.durationSeconds,
|
||||||
|
audioFiles: files
|
||||||
|
)
|
||||||
|
if let episodeId = downloaded.episodeId {
|
||||||
|
li.mediaType = "podcast"
|
||||||
|
li.episodeId = episodeId
|
||||||
|
}
|
||||||
|
recentItemsCache[key] = li
|
||||||
|
newlyAdded = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 2) Fetch from server
|
||||||
|
guard network.isOnline,
|
||||||
|
let progress = progressCache[key] else { continue }
|
||||||
|
|
||||||
|
if let episodeId = progress.episodeId {
|
||||||
|
// Podcast episode: fetch episodes and synthesize a LibraryItem
|
||||||
|
// that carries the episode's title + audioFile (analogous to
|
||||||
|
// `play(podcast:episode:)`). Without this the cached item has
|
||||||
|
// the podcast container's title, not the episode title.
|
||||||
|
guard let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: progress.itemId),
|
||||||
|
let episode = episodes.first(where: { $0.id == episodeId }) else { continue }
|
||||||
|
var synthetic = LibraryItem(
|
||||||
|
id: podcast.id,
|
||||||
|
title: episode.title,
|
||||||
|
author: podcast.title,
|
||||||
|
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
|
||||||
|
audioFiles: [episode.audioFile]
|
||||||
|
)
|
||||||
|
synthetic.mediaType = "podcast"
|
||||||
|
synthetic.episodeId = episode.id
|
||||||
|
recentItemsCache[key] = synthetic
|
||||||
|
newlyAdded = true
|
||||||
|
} else {
|
||||||
|
// Audiobook
|
||||||
|
guard let detail = try? await client.fetchItemDetail(itemId: progress.itemId) else { continue }
|
||||||
|
recentItemsCache[key] = detail
|
||||||
|
newlyAdded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newlyAdded { persistRecentItems() }
|
||||||
|
}
|
||||||
|
|
||||||
private func reportProgress(force: Bool) {
|
private func reportProgress(force: Bool) {
|
||||||
guard let item = currentItem else { return }
|
guard let item = currentItem else { return }
|
||||||
let t = player.absoluteCurrentTime
|
let t = player.absoluteCurrentTime
|
||||||
|
|||||||
@@ -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,114 @@ 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
|
||||||
|
config.networkServiceType = .responsiveData
|
||||||
|
config.httpMaximumConnectionsPerHost = 6
|
||||||
|
#else
|
||||||
|
// macOS hat keinen Background-Session-Daemon mit Auto-Retry —
|
||||||
|
// gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein
|
||||||
|
// parallel laufender Download bei geteilter Bandbreite zu lange ohne
|
||||||
|
// Bytes auf und failed mit Timeout.
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.networkServiceType = .responsiveData
|
||||||
|
config.httpMaximumConnectionsPerHost = 6
|
||||||
|
#endif
|
||||||
|
config.allowsCellularAccess = true
|
||||||
|
config.waitsForConnectivity = true
|
||||||
|
// 300s statt 60s: bei zwei parallelen großen Tracks kann eine Task
|
||||||
|
// temporär für deutlich mehr als 60s ohne Bytes bleiben, besonders
|
||||||
|
// auf macOS-Foreground-Sessions ohne iOS-Background-Retry-Logik.
|
||||||
|
config.timeoutIntervalForRequest = 300
|
||||||
|
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 +203,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 +218,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 +238,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,141 +397,163 @@ 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Background-session bookkeeping
|
||||||
|
|
||||||
|
/// Sums per-task in-flight byte counts (the background session may have
|
||||||
|
/// several tasks for the same download) and recomputes the overall progress
|
||||||
|
/// ring. The progress includes already-completed tracks PLUS the fraction
|
||||||
|
/// of each in-flight track — without this, single-track audiobooks stay
|
||||||
|
/// at 0 % until the whole file is on disk.
|
||||||
|
fileprivate func applyTaskProgress(meta: TaskMeta, totalBytesWritten: Int64, expectedBytes: Int64) {
|
||||||
|
perTrackBytes[meta.downloadKey, default: [:]][meta.trackIndex] = totalBytesWritten
|
||||||
|
if expectedBytes > 0 {
|
||||||
|
let fraction = min(1, max(0, Double(totalBytesWritten) / Double(expectedBytes)))
|
||||||
|
perTrackFractions[meta.downloadKey, default: [:]][meta.trackIndex] = fraction
|
||||||
|
}
|
||||||
|
let trackSum = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0
|
||||||
|
inFlightBytes[meta.downloadKey] = trackSum
|
||||||
|
|
||||||
|
if let total = totalTrackCount[meta.downloadKey], total > 0 {
|
||||||
|
let completed = Double(completedTracks[meta.downloadKey]?.count ?? 0)
|
||||||
|
let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0
|
||||||
|
let progress = min(0.999, (completed + inflightSum) / Double(total))
|
||||||
|
states[meta.downloadKey] = .downloading(progress: progress)
|
||||||
|
#if os(iOS)
|
||||||
|
activityManager.updateActivity(
|
||||||
|
downloadKey: meta.downloadKey,
|
||||||
|
progress: progress,
|
||||||
|
bytesDownloaded: downloadedBytes(for: meta.downloadKey)
|
||||||
|
)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
|
||||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var tracks: [DownloadedTrack] = []
|
/// Called when a single track completes successfully (its file has already
|
||||||
let total = max(workItem.audioFiles.count, 1)
|
/// been moved to the final destination by the delegate). Records the track,
|
||||||
totalTrackCount[downloadKey] = total
|
/// decrements the global active-task counter, and asks the queue to drain —
|
||||||
|
/// in sequential mode that frees the single slot for the next queued track.
|
||||||
|
fileprivate func handleTrackCompleted(meta: TaskMeta) {
|
||||||
|
activeTaskCount = max(0, activeTaskCount - 1)
|
||||||
|
|
||||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
// Track is now on disk — clear its in-flight byte/fraction counters so
|
||||||
if Task.isCancelled {
|
// we don't double-count (folderSize and completedTracks already include
|
||||||
states[downloadKey] = .notDownloaded
|
// 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
|
||||||
}
|
}
|
||||||
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
let completed = completedTracks[meta.downloadKey] ?? [:]
|
||||||
var request = URLRequest(url: url)
|
|
||||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
|
||||||
currentTrackIndex[downloadKey] = idx
|
|
||||||
|
|
||||||
let tempURL: URL
|
if completed.count == total {
|
||||||
do {
|
finalizeDownload(workItem: workItem, downloadKey: meta.downloadKey, completed: completed)
|
||||||
tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey)
|
} else {
|
||||||
} catch is CancellationError {
|
let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0
|
||||||
states[downloadKey] = .notDownloaded
|
let progress = min(0.999, (Double(completed.count) + inflightSum) / Double(total))
|
||||||
return
|
states[meta.downloadKey] = .downloading(progress: progress)
|
||||||
} catch {
|
#if os(iOS)
|
||||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
activityManager.updateActivity(
|
||||||
return
|
downloadKey: meta.downloadKey,
|
||||||
|
progress: progress,
|
||||||
|
bytesDownloaded: downloadedBytes(for: meta.downloadKey)
|
||||||
|
)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = file.ext.isEmpty ? "mp3" : file.ext
|
drainQueue()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
|
||||||
// Exponential backoff: 1 s, 2 s, 4 s, 8 s …
|
|
||||||
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
|
|
||||||
try await Task.sleep(nanoseconds: delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs a single download attempt via URLSessionDownloadTask, reporting byte
|
// Cancel any sibling tasks for the same download so we don't keep
|
||||||
/// progress to `inFlightBytes` and moving the system-temp file to a stable
|
// pulling bytes for a download we've already given up on.
|
||||||
/// location before returning.
|
let cancelKey = meta.downloadKey
|
||||||
private func streamingDownload(
|
downloadSession.getAllTasks { tasks in
|
||||||
request: URLRequest,
|
for task in tasks {
|
||||||
resumeData: Data?,
|
if let m = TaskMeta.decode(task.taskDescription), m.downloadKey == cancelKey {
|
||||||
downloadKey: String
|
task.cancel()
|
||||||
) 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,83 +586,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,13 @@ final class PlayerEngine {
|
|||||||
|
|
||||||
let queue = AVQueuePlayer(items: trackPlayerItems)
|
let queue = AVQueuePlayer(items: trackPlayerItems)
|
||||||
queue.rate = rate
|
queue.rate = rate
|
||||||
|
// Enable AirPlay / Bluetooth / CarPlay routing. Without this, AVPlayer
|
||||||
|
// refuses to send audio to external devices even when the user picks
|
||||||
|
// them in Control Center / the route picker.
|
||||||
|
queue.allowsExternalPlayback = true
|
||||||
|
#if os(iOS)
|
||||||
|
queue.usesExternalPlaybackWhileExternalScreenIsActive = true
|
||||||
|
#endif
|
||||||
self.player = queue
|
self.player = queue
|
||||||
|
|
||||||
let center = NotificationCenter.default
|
let center = NotificationCenter.default
|
||||||
@@ -519,7 +526,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 +538,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
/// Cross-platform SwiftUI wrapper around `AVRoutePickerView`. Opens the system
|
||||||
|
/// route picker (AirPlay devices, Bluetooth, HomePod, Apple TV, etc.) when
|
||||||
|
/// tapped. Available on iOS 11+ and macOS 10.15+ via AppKit.
|
||||||
|
///
|
||||||
|
/// The actual audio routing requires `AVPlayer.allowsExternalPlayback = true`
|
||||||
|
/// — see `PlayerEngine.load(...)` where the queue player is configured.
|
||||||
|
#if os(iOS)
|
||||||
|
struct AirPlayPickerButton: UIViewRepresentable {
|
||||||
|
var tintColor: UIColor = .label
|
||||||
|
var activeTintColor: UIColor = .systemGreen
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let view = AVRoutePickerView()
|
||||||
|
view.tintColor = tintColor
|
||||||
|
view.activeTintColor = activeTintColor
|
||||||
|
view.prioritizesVideoDevices = false
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {
|
||||||
|
uiView.tintColor = tintColor
|
||||||
|
uiView.activeTintColor = activeTintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
struct AirPlayPickerButton: NSViewRepresentable {
|
||||||
|
func makeNSView(context: Context) -> AVRoutePickerView {
|
||||||
|
AVRoutePickerView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: AVRoutePickerView, context: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -3,20 +3,21 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
#if os(iOS)
|
|
||||||
@State private var splashVisible = true
|
/// Bootstrap fertig — Signal an den Splash, Phase 2 zu starten.
|
||||||
#endif
|
@State private var isReadyToDismiss = false
|
||||||
|
/// Splash hat seine Fade-Phase ausgespielt und kann aus dem View-Tree raus.
|
||||||
|
@State private var splashFinished = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
mainContent
|
mainContent
|
||||||
#if os(iOS)
|
if !splashFinished {
|
||||||
if splashVisible {
|
SplashScreenView(isReady: isReadyToDismiss) {
|
||||||
SplashView()
|
splashFinished = true
|
||||||
.zIndex(10)
|
}
|
||||||
.transition(.opacity.animation(.easeOut(duration: 0.55)))
|
.zIndex(10)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.task { await boot() }
|
.task { await boot() }
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
@@ -42,17 +43,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func boot() async {
|
private func boot() async {
|
||||||
#if os(iOS)
|
|
||||||
// Run bootstrap and minimum splash time in parallel;
|
|
||||||
// dismiss splash only after BOTH complete.
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask { await app.bootstrap() }
|
|
||||||
group.addTask { try? await Task.sleep(for: .seconds(1.2)) }
|
|
||||||
await group.waitForAll()
|
|
||||||
}
|
|
||||||
withAnimation { splashVisible = false }
|
|
||||||
#else
|
|
||||||
await app.bootstrap()
|
await app.bootstrap()
|
||||||
#endif
|
// SplashScreenView wartet selbst auf seine Mindest-Loop-Zeit, daher
|
||||||
|
// kein künstlicher Sleep mehr hier nötig. Sobald isReadyToDismiss
|
||||||
|
// gesetzt wird, fängt der Splash mit Phase 2 an und ruft am Ende
|
||||||
|
// seinen onComplete-Callback (splashFinished = true).
|
||||||
|
isReadyToDismiss = 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Default landing screen showing "continue listening" rows, Netflix-style.
|
||||||
|
/// Data comes from `AppState.progressCache` (sorted by `updatedAt`) joined
|
||||||
|
/// with `AppState.recentItemsCache` for the cover/title/author metadata.
|
||||||
|
/// A row is only shown when the user actually has a library of that media
|
||||||
|
/// type — pure audiobook users don't see an empty "Podcasts" row and vice versa.
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(AppState.self) private var app
|
||||||
|
let libraries: [Library]
|
||||||
|
/// Shared with the library views via the same `libraryLayout` AppStorage
|
||||||
|
/// key — toggling grid/list in the toolbar affects both Home and Library.
|
||||||
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
|
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .grid }
|
||||||
|
|
||||||
|
private var hasBookLibrary: Bool {
|
||||||
|
libraries.contains { ($0.mediaType ?? "book") != "podcast" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasPodcastLibrary: Bool {
|
||||||
|
libraries.contains { $0.mediaType == "podcast" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything at 95 % or above is considered "done with the content" —
|
||||||
|
/// only credits left. Treats the item as finished even when the server
|
||||||
|
/// hasn't flipped `isFinished` yet.
|
||||||
|
private static let nearEndFraction: Double = 0.95
|
||||||
|
/// Minimum playback position to count as "actually started". Filters out
|
||||||
|
/// items the user only previewed for a few seconds or where the server
|
||||||
|
/// reset progress without removing the entry.
|
||||||
|
private static let minStartedSeconds: Double = 30
|
||||||
|
|
||||||
|
private func isStillListening(_ p: PlaybackProgress) -> Bool {
|
||||||
|
if p.isFinished { return false }
|
||||||
|
if p.currentTime < Self.minStartedSeconds { return false }
|
||||||
|
guard p.duration > 0 else { return true } // unknown duration → keep showing
|
||||||
|
return (p.currentTime / p.duration) < Self.nearEndFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audiobooksRecent: [LibraryItem] {
|
||||||
|
app.progressCache.values
|
||||||
|
.filter { isStillListening($0) && $0.episodeId == nil }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(20)
|
||||||
|
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var podcastsRecent: [LibraryItem] {
|
||||||
|
app.progressCache.values
|
||||||
|
.filter { isStillListening($0) && $0.episodeId != nil }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(20)
|
||||||
|
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var anyContent: Bool {
|
||||||
|
(hasBookLibrary && !audiobooksRecent.isEmpty) || (hasPodcastLibrary && !podcastsRecent.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if anyContent {
|
||||||
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
|
if hasPodcastLibrary && !podcastsRecent.isEmpty {
|
||||||
|
sectionHeader("Zuletzt gehörte Podcasts")
|
||||||
|
sectionBody(items: podcastsRecent)
|
||||||
|
}
|
||||||
|
if hasBookLibrary && !audiobooksRecent.isEmpty {
|
||||||
|
sectionHeader("Zuletzt gehörte Hörbücher")
|
||||||
|
sectionBody(items: audiobooksRecent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Noch nichts gehört",
|
||||||
|
systemImage: "headphones",
|
||||||
|
description: Text("Sobald du ein Hörbuch oder einen Podcast startest, erscheinen die zuletzt gehörten Folgen hier.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.refreshable {
|
||||||
|
await app.refreshProgressCache()
|
||||||
|
await app.fillRecentItemsCache()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.task(id: app.progressCache.count) {
|
||||||
|
await app.fillRecentItemsCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionBody(items: [LibraryItem]) -> some View {
|
||||||
|
switch layout {
|
||||||
|
case .grid:
|
||||||
|
cardRow(items: items)
|
||||||
|
case .list:
|
||||||
|
listRows(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardRow(items: [LibraryItem]) -> some View {
|
||||||
|
HomeCardRow(items: items) { tapped in
|
||||||
|
Task { await app.playFromRecents(tapped) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func listRows(items: [LibraryItem]) -> some View {
|
||||||
|
// Reuses LibraryListRow so the Home list rows inherit the listened +
|
||||||
|
// download indicators automatically. Tapping calls playFromRecents
|
||||||
|
// (which handles podcasts correctly by looking up the episode), not
|
||||||
|
// the library's default play() path.
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
|
||||||
|
LibraryListRow(item: item, dimDownloading: false)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
Task { await app.playFromRecents(item) }
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
#endif
|
||||||
|
if idx < items.count - 1 {
|
||||||
|
Divider()
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.leading, 76)
|
||||||
|
#else
|
||||||
|
.padding(.leading, 84)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single card on the home screen: cover + progress bar + title + author.
|
||||||
|
/// Sized to roughly match the library grid covers so the visual rhythm is
|
||||||
|
/// consistent across screens.
|
||||||
|
struct HomeMediaCard: View {
|
||||||
|
@Environment(AppState.self) private var app
|
||||||
|
let item: LibraryItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private let cardWidth: CGFloat = 150
|
||||||
|
#else
|
||||||
|
private let cardWidth: CGFloat = 190
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
cover
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
if !isListened {
|
||||||
|
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
.padding(.bottom, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text(item.author)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(width: cardWidth)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var downloadBadge: some View {
|
||||||
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
|
switch state {
|
||||||
|
case .downloaded:
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
case .downloading(let p):
|
||||||
|
DownloadProgressRing(progress: p)
|
||||||
|
#if os(iOS)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
#else
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
#endif
|
||||||
|
case .failed:
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .red)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
case .notDownloaded:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cover: some View {
|
||||||
|
Rectangle()
|
||||||
|
#if os(iOS)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
#else
|
||||||
|
.fill(AnyShapeStyle(.quaternary))
|
||||||
|
#endif
|
||||||
|
// Explicit square frame — without this the Rectangle inside a
|
||||||
|
// LazyHStack stretches to fill the available vertical space and
|
||||||
|
// pushes siblings way off to the right.
|
||||||
|
.frame(width: cardWidth, height: cardWidth)
|
||||||
|
.overlay {
|
||||||
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().scaledToFit()
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
#if os(macOS)
|
||||||
|
.controlSize(.small)
|
||||||
|
#endif
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontally scrollable card row that measures its own content vs. the
|
||||||
|
/// available viewport. Scrolling is only enabled when items actually overflow,
|
||||||
|
/// and chevron buttons appear on macOS hover so the user knows there's more
|
||||||
|
/// off-screen and can page through it without trackpad/scrollwheel.
|
||||||
|
private struct HomeCardRow: View {
|
||||||
|
let items: [LibraryItem]
|
||||||
|
let onTap: (LibraryItem) -> Void
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private let cardWidth: CGFloat = 150
|
||||||
|
#else
|
||||||
|
private let cardWidth: CGFloat = 190
|
||||||
|
#endif
|
||||||
|
private let spacing: CGFloat = 14
|
||||||
|
private let horizontalPadding: CGFloat = 20
|
||||||
|
|
||||||
|
@State private var viewportWidth: CGFloat = 0
|
||||||
|
@State private var scrollIndex: Int = 0
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var isHovering = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var contentWidth: CGFloat {
|
||||||
|
guard !items.isEmpty else { return 0 }
|
||||||
|
return CGFloat(items.count) * cardWidth
|
||||||
|
+ CGFloat(items.count - 1) * spacing
|
||||||
|
+ horizontalPadding * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canScroll: Bool { contentWidth > viewportWidth + 1 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: spacing) {
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
|
HomeMediaCard(item: item) { onTap(item) }
|
||||||
|
.id(item.syncKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
}
|
||||||
|
.scrollDisabled(!canScroll)
|
||||||
|
.onAppear { viewportWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, newValue in viewportWidth = newValue }
|
||||||
|
#if os(macOS)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
chevron(systemName: "chevron.left", visible: isHovering && canScroll) {
|
||||||
|
scrollBy(-1, proxy: proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
chevron(systemName: "chevron.right", visible: isHovering && canScroll) {
|
||||||
|
scrollBy(1, proxy: proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onHover { isHovering = $0 }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GeometryReader has no intrinsic size — give the row an explicit height
|
||||||
|
// matching cardWidth (square cover) + ~50pt for title/author.
|
||||||
|
.frame(height: cardWidth + 52)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private func chevron(systemName: String, visible: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(.black.opacity(0.55)))
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.opacity(visible ? 1 : 0)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pages by roughly one viewport in the requested direction. The actual
|
||||||
|
/// scroll position is tracked via `scrollIndex` — the index of the card we
|
||||||
|
/// want pinned to the leading edge after the scroll.
|
||||||
|
private func scrollBy(_ direction: Int, proxy: ScrollViewProxy) {
|
||||||
|
let cardsPerView = max(1, Int(viewportWidth / (cardWidth + spacing)))
|
||||||
|
let next = max(0, min(items.count - 1, scrollIndex + direction * cardsPerView))
|
||||||
|
scrollIndex = next
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
proxy.scrollTo(items[next].syncKey, anchor: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -22,7 +22,11 @@ struct LibraryGridView: View {
|
|||||||
private var gridContent: some View {
|
private var gridContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
ForEach(items) { item in
|
// Identify by syncKey, not by `id`: a synthetic episode item
|
||||||
|
// shares the podcast container's `id`, so the default
|
||||||
|
// `Identifiable`-based ForEach would collide if both appear in
|
||||||
|
// a single search result list.
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ struct LibraryItemCell: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
cover
|
cover
|
||||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if isListened {
|
||||||
|
listenedBadge.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if !(dimDownloading && isActivelyDownloading) {
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
downloadBadge.padding(4)
|
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -20,10 +25,12 @@ struct LibraryItemCell: View {
|
|||||||
LargeDownloadOverlay(progress: p)
|
LargeDownloadOverlay(progress: p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !isListened {
|
||||||
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||||
.padding(.horizontal, 3)
|
.padding(.horizontal, 3)
|
||||||
.padding(.bottom, 3)
|
.padding(.bottom, 3)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.font(.system(size: 11, weight: .bold))
|
.font(.system(size: 11, weight: .bold))
|
||||||
@@ -56,7 +63,7 @@ struct LibraryItemCell: View {
|
|||||||
}
|
}
|
||||||
// Ensure the cell fills its full grid column width
|
// Ensure the cell fills its full grid column width
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isActivelyDownloading: Bool {
|
private var isActivelyDownloading: Bool {
|
||||||
@@ -64,6 +71,14 @@ struct LibraryItemCell: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Already listened" — server's authoritative finished flag. The
|
||||||
|
/// Audiobookshelf server flips this once `markAsFinishedPercentComplete`
|
||||||
|
/// (set per library by the admin) is crossed, so we don't need our own
|
||||||
|
/// threshold here.
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cover
|
// MARK: - Cover
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
@@ -122,23 +137,37 @@ struct LibraryItemCell: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download badge
|
// MARK: - Badges
|
||||||
|
|
||||||
|
/// Top-right corner: green checkmark if the server has marked this item
|
||||||
|
/// as finished. Surfaces the "I've listened to this" status that used to
|
||||||
|
/// be tangled with the download checkmark.
|
||||||
|
@ViewBuilder
|
||||||
|
private var listenedBadge: some View {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom-right corner: download status. Was previously a checkmark in the
|
||||||
|
/// top-right; the checkmark now signals "listened", and the download icon
|
||||||
|
/// is a downward arrow so the two states are visually unambiguous.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var downloadBadge: some View {
|
private var downloadBadge: some View {
|
||||||
let state = app.downloads.state(for: item.downloadKey)
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
switch state {
|
switch state {
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.shadow(radius: 2)
|
.shadow(radius: 2)
|
||||||
case .downloading(let p):
|
case .downloading(let p):
|
||||||
DownloadProgressRing(progress: p)
|
DownloadProgressRing(progress: p)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.frame(width: 26, height: 26)
|
.frame(width: 22, height: 22)
|
||||||
#else
|
#else
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 26, height: 26)
|
||||||
#endif
|
#endif
|
||||||
case .failed:
|
case .failed:
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
@@ -150,37 +179,6 @@ struct LibraryItemCell: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Context menu
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var downloadMenuItems: some View {
|
|
||||||
let key = item.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
if item.isPodcastContainer {
|
|
||||||
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
|
||||||
} else {
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button {
|
|
||||||
app.downloads.startDownload(item: item)
|
|
||||||
} label: {
|
|
||||||
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button {
|
|
||||||
app.downloads.cancel(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared components
|
// MARK: - Shared components
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ struct LibraryListView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
List {
|
List {
|
||||||
ForEach(items) { item in
|
// Identify by syncKey — see LibraryGridView comment.
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
@@ -32,7 +33,7 @@ struct LibraryListView: View {
|
|||||||
#else
|
#else
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
|
||||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
@@ -70,7 +71,7 @@ struct LibraryListRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
|
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
|
||||||
if fraction > 0 {
|
if fraction > 0 && !isListened {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||||
@@ -100,6 +101,11 @@ struct LibraryListRow: View {
|
|||||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
if !(dimDownloading && isActivelyDownloading) {
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
downloadStatus
|
downloadStatus
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -111,7 +117,7 @@ struct LibraryListRow: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
#endif
|
#endif
|
||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isActivelyDownloading: Bool {
|
private var isActivelyDownloading: Bool {
|
||||||
@@ -119,6 +125,10 @@ struct LibraryListRow: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
Group {
|
Group {
|
||||||
if let url = app.client.coverURL(itemId: item.id) {
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
@@ -153,7 +163,9 @@ struct LibraryListRow: View {
|
|||||||
let state = app.downloads.state(for: item.downloadKey)
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
switch state {
|
switch state {
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Down-arrow now signals "downloaded"; the checkmark is reserved
|
||||||
|
// for the listened indicator that sits next to it.
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
case .downloading(let p):
|
case .downloading(let p):
|
||||||
@@ -172,32 +184,6 @@ struct LibraryListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var downloadMenuItems: some View {
|
|
||||||
let key = item.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
if item.isPodcastContainer {
|
|
||||||
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
|
||||||
} else {
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button { app.downloads.startDownload(item: item) } label: {
|
|
||||||
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button { app.downloads.cancel(downloadKey: key) } label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func formatDuration(_ seconds: Double) -> String {
|
private func formatDuration(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct LoginView: View {
|
|||||||
Image(systemName: "books.vertical.fill")
|
Image(systemName: "books.vertical.fill")
|
||||||
.font(.system(size: 52, weight: .regular))
|
.font(.system(size: 52, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("ABS Client")
|
Text("Nook")
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
||||||
@@ -124,7 +124,7 @@ struct LoginView: View {
|
|||||||
Image(systemName: "books.vertical.fill")
|
Image(systemName: "books.vertical.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
Text("ABS Client")
|
Text("Nook")
|
||||||
.font(.largeTitle).bold()
|
.font(.largeTitle).bold()
|
||||||
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum LibraryFilter: Hashable {
|
enum LibraryFilter: Hashable {
|
||||||
|
case home
|
||||||
case library(String)
|
case library(String)
|
||||||
case downloaded
|
case downloaded
|
||||||
case history
|
case history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One indexed (podcast, episode) pair used to make episodes searchable from the
|
||||||
|
/// library overview — without having to dive into each show first.
|
||||||
|
struct PodcastEpisodeMatch: Hashable {
|
||||||
|
let podcast: LibraryItem
|
||||||
|
let episode: PodcastEpisode
|
||||||
|
|
||||||
|
var syncKey: String { "\(podcast.id)|\(episode.id)" }
|
||||||
|
|
||||||
|
/// Synthesizes a `LibraryItem` so the cell/list views can render this
|
||||||
|
/// episode the same way they render any other item: episode title up top,
|
||||||
|
/// podcast name as the subtitle, podcast cover as the artwork.
|
||||||
|
var asLibraryItem: LibraryItem {
|
||||||
|
var item = LibraryItem(
|
||||||
|
id: podcast.id,
|
||||||
|
title: episode.title,
|
||||||
|
author: podcast.title,
|
||||||
|
durationSeconds: episode.durationSeconds > 0
|
||||||
|
? episode.durationSeconds
|
||||||
|
: episode.audioFile.durationSeconds,
|
||||||
|
audioFiles: [episode.audioFile]
|
||||||
|
)
|
||||||
|
item.mediaType = "podcast"
|
||||||
|
item.episodeId = episode.id
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LibraryViewModel {
|
final class LibraryViewModel {
|
||||||
@@ -15,21 +43,93 @@ final class LibraryViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var selection: LibraryFilter?
|
var selection: LibraryFilter?
|
||||||
|
|
||||||
|
/// Episode index per library, populated lazily the first time the user
|
||||||
|
/// types in the search bar of a podcast library. Episodes aren't part of
|
||||||
|
/// `items` (those only hold podcast containers) so we have to load them
|
||||||
|
/// explicitly. Kept in memory only — cleared whenever the library reloads.
|
||||||
|
var episodeIndex: [String: [PodcastEpisodeMatch]] = [:]
|
||||||
|
/// Library IDs currently being indexed. Prevents duplicate in-flight loads
|
||||||
|
/// when the user types rapidly while the first request is still running.
|
||||||
|
var episodeIndexLoading: Set<String> = []
|
||||||
|
|
||||||
|
var isLoadingEpisodeIndex: Bool {
|
||||||
|
!episodeIndexLoading.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
func loadLibraries(client: ABSClient) async {
|
func loadLibraries(client: ABSClient) async {
|
||||||
do {
|
do {
|
||||||
libraries = try await client.fetchLibraries()
|
libraries = try await client.fetchLibraries()
|
||||||
if selection == nil, let first = libraries.first {
|
// Home is the new default landing screen — first library is only
|
||||||
selection = .library(first.id)
|
// used as fallback when there are zero libraries.
|
||||||
|
if selection == nil {
|
||||||
|
selection = .home
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches every podcast's episodes in the given library and caches them
|
||||||
|
/// flat so the search field can match against episode titles too. No-ops
|
||||||
|
/// when the index is already populated for this library.
|
||||||
|
func loadEpisodeIndex(libraryId: String, client: ABSClient) async {
|
||||||
|
guard episodeIndex[libraryId] == nil,
|
||||||
|
!episodeIndexLoading.contains(libraryId) else { return }
|
||||||
|
let podcasts = items.filter { $0.isPodcastContainer }
|
||||||
|
guard !podcasts.isEmpty else {
|
||||||
|
episodeIndex[libraryId] = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeIndexLoading.insert(libraryId)
|
||||||
|
defer { episodeIndexLoading.remove(libraryId) }
|
||||||
|
|
||||||
|
var collected: [PodcastEpisodeMatch] = []
|
||||||
|
var anySuccess = false
|
||||||
|
for p in podcasts {
|
||||||
|
// Bail out if the user already navigated away — keeps a slow scan
|
||||||
|
// from blocking later searches.
|
||||||
|
guard selection == .library(libraryId) else { return }
|
||||||
|
do {
|
||||||
|
let (_, eps) = try await client.fetchEpisodes(podcastItemId: p.id)
|
||||||
|
anySuccess = true
|
||||||
|
for ep in eps {
|
||||||
|
collected.append(PodcastEpisodeMatch(podcast: p, episode: ep))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Individual podcast fetch errors are ignored — the rest of
|
||||||
|
// the library is still searchable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bei totalem Offline-Failure (kein einziger Fetch erfolgreich) NICHT
|
||||||
|
// den Cache mit `[]` setzen — sonst wäre der Index "geladen aber leer"
|
||||||
|
// und ein erneuter Suchversuch würde nichts mehr tun. Nil-lassen
|
||||||
|
// erlaubt Retry beim nächsten Tippen.
|
||||||
|
if anySuccess {
|
||||||
|
episodeIndex[libraryId] = collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func episodeMatch(syncKey: String, in libraryId: String) -> PodcastEpisodeMatch? {
|
||||||
|
episodeIndex[libraryId]?.first(where: { $0.syncKey == syncKey })
|
||||||
|
}
|
||||||
|
|
||||||
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
||||||
guard let selection else { return }
|
guard let selection else { return }
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .home:
|
||||||
|
// HomeView reads progressCache + recentItemsCache directly; no
|
||||||
|
// library items to load into vm.items.
|
||||||
|
items = []
|
||||||
|
errorMessage = nil
|
||||||
case .library(let id):
|
case .library(let id):
|
||||||
|
// Reload of items invalidates the episode index — episodes are
|
||||||
|
// tied to the previous snapshot of `items`. Auch das Loading-Flag
|
||||||
|
// löschen damit ein nach-dem-Refresh neu getippter Suchbegriff
|
||||||
|
// sofort einen Neu-Aufbau triggern kann (eine ggf. noch laufende
|
||||||
|
// alte loadEpisodeIndex-Schleife läuft zu Ende und das Resultat
|
||||||
|
// wird vom nächsten Trigger ohnehin überschrieben).
|
||||||
|
episodeIndex.removeValue(forKey: id)
|
||||||
|
episodeIndexLoading.remove(id)
|
||||||
do {
|
do {
|
||||||
items = try await client.fetchItems(libraryId: id)
|
items = try await client.fetchItems(libraryId: id)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -77,6 +177,7 @@ struct MainView: View {
|
|||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
@State private var vm = LibraryViewModel()
|
@State private var vm = LibraryViewModel()
|
||||||
@State private var navPath: [LibraryItem] = []
|
@State private var navPath: [LibraryItem] = []
|
||||||
|
@State private var librarySearchText: String = ""
|
||||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||||
@State private var showFullHistory: Bool = false
|
@State private var showFullHistory: Bool = false
|
||||||
@@ -95,8 +196,19 @@ struct MainView: View {
|
|||||||
.task { await loadAll() }
|
.task { await loadAll() }
|
||||||
.onChange(of: vm.selection) { _, _ in
|
.onChange(of: vm.selection) { _, _ in
|
||||||
navPath.removeAll()
|
navPath.removeAll()
|
||||||
|
librarySearchText = ""
|
||||||
Task { await loadAll() }
|
Task { await loadAll() }
|
||||||
}
|
}
|
||||||
|
.onChange(of: librarySearchText) { _, newValue in
|
||||||
|
// First keystroke in a podcast library kicks off the episode
|
||||||
|
// index load (cached after that, so subsequent searches are
|
||||||
|
// instant). Skipped for audiobook libraries — those don't
|
||||||
|
// have episodes to scan.
|
||||||
|
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
currentLibraryIsPodcast,
|
||||||
|
let libId = currentLibraryId else { return }
|
||||||
|
Task { await vm.loadEpisodeIndex(libraryId: libId, client: app.client) }
|
||||||
|
}
|
||||||
.onChange(of: app.downloads.pendingItems.count) { _, _ in
|
.onChange(of: app.downloads.pendingItems.count) { _, _ in
|
||||||
if vm.selection == .downloaded {
|
if vm.selection == .downloaded {
|
||||||
Task { await vm.loadItems(client: app.client, downloads: app.downloads) }
|
Task { await vm.loadItems(client: app.client, downloads: app.downloads) }
|
||||||
@@ -116,6 +228,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
|
||||||
@@ -192,18 +319,49 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleSelect(_ item: LibraryItem) {
|
private func handleSelect(_ item: LibraryItem) {
|
||||||
if item.isPodcastContainer {
|
if item.isPodcastEpisode {
|
||||||
|
// Synthetic episode result from library-wide podcast search.
|
||||||
|
// Prefer the in-memory index (no extra round-trip); fall back to
|
||||||
|
// re-fetching via the Home/Recents path if the index expired.
|
||||||
|
if let libId = currentLibraryId,
|
||||||
|
let match = vm.episodeMatch(syncKey: item.syncKey, in: libId) {
|
||||||
|
Task { await app.play(podcast: match.podcast, episode: match.episode) }
|
||||||
|
} else {
|
||||||
|
Task { await app.playFromRecents(item) }
|
||||||
|
}
|
||||||
|
} else if item.isPodcastContainer {
|
||||||
navPath.append(item)
|
navPath.append(item)
|
||||||
} else {
|
} else {
|
||||||
Task { await app.play(item: item) }
|
Task { await app.play(item: item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentLibraryId: String? {
|
||||||
|
if case .library(let id) = vm.selection { return id }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentLibraryIsPodcast: Bool {
|
||||||
|
guard let id = currentLibraryId else { return false }
|
||||||
|
if let mt = vm.libraries.first(where: { $0.id == id })?.mediaType {
|
||||||
|
return mt == "podcast"
|
||||||
|
}
|
||||||
|
// Fallback: `vm.libraries` ist noch leer (User tippt direkt nach
|
||||||
|
// App-Start, bevor `loadLibraries()` durch ist). Wir leiten den Typ
|
||||||
|
// aus den bereits geladenen Items ab — sobald die Library Items
|
||||||
|
// hat die Podcast-Container sind, ist es eine Podcast-Library.
|
||||||
|
return vm.items.contains { $0.isPodcastContainer }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - macOS sidebar
|
// MARK: - macOS sidebar
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
List(selection: $vm.selection) {
|
List(selection: $vm.selection) {
|
||||||
|
Section {
|
||||||
|
Label("Home", systemImage: "house.fill")
|
||||||
|
.tag(LibraryFilter.home)
|
||||||
|
}
|
||||||
Section(String(localized: "sidebar.libraries")) {
|
Section(String(localized: "sidebar.libraries")) {
|
||||||
ForEach(vm.libraries) { lib in
|
ForEach(vm.libraries) { lib in
|
||||||
Label(lib.name, systemImage: "books.vertical")
|
Label(lib.name, systemImage: "books.vertical")
|
||||||
@@ -359,17 +517,60 @@ struct MainView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detail: some View {
|
private var detail: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if vm.selection == .history {
|
if vm.selection == .home {
|
||||||
|
HomeView(libraries: vm.libraries)
|
||||||
|
.navigationTitle("Home")
|
||||||
|
.toolbar {
|
||||||
|
// Same Kachel/Liste picker the library view has, so the
|
||||||
|
// shared `libraryLayout` setting can be toggled from Home
|
||||||
|
// too. Otherwise the user would land here with no UI
|
||||||
|
// affordance to switch into Listenansicht.
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Picker("Ansicht", selection: $layoutRaw) {
|
||||||
|
ForEach(LibraryLayout.allCases) { l in
|
||||||
|
Image(systemName: l.systemImage)
|
||||||
|
.help(l.label)
|
||||||
|
.tag(l.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.help("Zwischen Kachel- und Listenansicht wechseln")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if vm.selection == .history {
|
||||||
historyDetailContent
|
historyDetailContent
|
||||||
.navigationTitle(String(localized: "sidebar.history"))
|
.navigationTitle(String(localized: "sidebar.history"))
|
||||||
} else {
|
} else {
|
||||||
libraryContent
|
libraryContent
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
if vm.selection == .home {
|
||||||
|
HomeView(libraries: vm.libraries)
|
||||||
|
} else {
|
||||||
libraryContent
|
libraryContent
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var filteredItems: [LibraryItem] {
|
||||||
|
let q = librarySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !q.isEmpty else { return vm.items }
|
||||||
|
let containerMatches = vm.items.filter { item in
|
||||||
|
item.title.localizedCaseInsensitiveContains(q)
|
||||||
|
|| item.author.localizedCaseInsensitiveContains(q)
|
||||||
|
}
|
||||||
|
// For podcast libraries, also surface matching episodes from across
|
||||||
|
// the entire library — the user shouldn't have to dive into each show
|
||||||
|
// first. Episode rows render with the show name as their subtitle.
|
||||||
|
guard currentLibraryIsPodcast, let libId = currentLibraryId else {
|
||||||
|
return containerMatches
|
||||||
|
}
|
||||||
|
let episodeMatches = (vm.episodeIndex[libId] ?? [])
|
||||||
|
.filter { $0.episode.title.localizedCaseInsensitiveContains(q) }
|
||||||
|
.map { $0.asLibraryItem }
|
||||||
|
return containerMatches + episodeMatches
|
||||||
|
}
|
||||||
|
|
||||||
private var libraryContent: some View {
|
private var libraryContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if vm.isLoading && vm.items.isEmpty {
|
if vm.isLoading && vm.items.isEmpty {
|
||||||
@@ -382,12 +583,54 @@ struct MainView: View {
|
|||||||
} else if vm.items.isEmpty {
|
} else if vm.items.isEmpty {
|
||||||
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
|
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
} else if filteredItems.isEmpty {
|
||||||
|
if vm.isLoadingEpisodeIndex {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Folgen werden durchsucht …")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
libraryGridOrList.transition(.opacity)
|
ContentUnavailableView.search(text: librarySearchText)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if vm.isLoadingEpisodeIndex {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text("Folgen werden durchsucht …")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
libraryGridOrList
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: vm.isLoading)
|
.animation(.easeInOut(duration: 0.2), value: vm.isLoading)
|
||||||
.animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty)
|
.animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: filteredItems.isEmpty)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: vm.isLoadingEpisodeIndex)
|
||||||
|
.searchable(text: $librarySearchText, placement: .automatic, prompt: searchPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchPrompt: String {
|
||||||
|
switch vm.selection {
|
||||||
|
case .library(let id):
|
||||||
|
let mediaType = vm.libraries.first(where: { $0.id == id })?.mediaType
|
||||||
|
return mediaType == "podcast" ? "Podcasts & Folgen durchsuchen" : "Hörbücher durchsuchen"
|
||||||
|
case .downloaded:
|
||||||
|
return "Downloads durchsuchen"
|
||||||
|
default:
|
||||||
|
return "Suchen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -396,9 +639,9 @@ struct MainView: View {
|
|||||||
let isDownloaded = vm.selection == .downloaded
|
let isDownloaded = vm.selection == .downloaded
|
||||||
switch layout {
|
switch layout {
|
||||||
case .grid:
|
case .grid:
|
||||||
LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
LibraryGridView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
||||||
case .list:
|
case .list:
|
||||||
LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
LibraryListView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -438,9 +681,11 @@ struct MainView: View {
|
|||||||
private var libraryMenu: some View {
|
private var libraryMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
Picker("Bibliothek", selection: Binding(
|
Picker("Bibliothek", selection: Binding(
|
||||||
get: { vm.selection ?? .library("") },
|
get: { vm.selection ?? .home },
|
||||||
set: { vm.selection = $0 }
|
set: { vm.selection = $0 }
|
||||||
)) {
|
)) {
|
||||||
|
Label("Home", systemImage: "house.fill")
|
||||||
|
.tag(LibraryFilter.home)
|
||||||
Section("Bibliotheken") {
|
Section("Bibliotheken") {
|
||||||
ForEach(vm.libraries) { lib in
|
ForEach(vm.libraries) { lib in
|
||||||
Label(lib.name, systemImage: "books.vertical")
|
Label(lib.name, systemImage: "books.vertical")
|
||||||
@@ -479,6 +724,7 @@ struct MainView: View {
|
|||||||
|
|
||||||
private var selectionIcon: String {
|
private var selectionIcon: String {
|
||||||
switch vm.selection {
|
switch vm.selection {
|
||||||
|
case .home: return "house.fill"
|
||||||
case .downloaded: return "arrow.down.circle.fill"
|
case .downloaded: return "arrow.down.circle.fill"
|
||||||
case .history: return "clock.arrow.circlepath"
|
case .history: return "clock.arrow.circlepath"
|
||||||
default: return "books.vertical"
|
default: return "books.vertical"
|
||||||
@@ -490,6 +736,8 @@ struct MainView: View {
|
|||||||
|
|
||||||
private var currentTitle: String {
|
private var currentTitle: String {
|
||||||
switch vm.selection {
|
switch vm.selection {
|
||||||
|
case .home:
|
||||||
|
return "Home"
|
||||||
case .library(let id):
|
case .library(let id):
|
||||||
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Unified right-click / long-press menu for any `LibraryItem` shown on the
|
||||||
|
/// Home, Library, or Heruntergeladen screens. Includes both download actions
|
||||||
|
/// (start / cancel / delete files) AND a "Fortschritt entfernen" action when
|
||||||
|
/// the user has any server-side progress on the item.
|
||||||
|
///
|
||||||
|
/// The "Fortschritt entfernen" row was previously Home-only; the download
|
||||||
|
/// rows were previously Library-only — combining them in one place means the
|
||||||
|
/// same menu shows up regardless of which screen the user is on.
|
||||||
|
@ViewBuilder
|
||||||
|
func mediaItemContextMenu(for item: LibraryItem, app: AppState) -> some View {
|
||||||
|
let key = item.downloadKey
|
||||||
|
let state = app.downloads.state(for: key)
|
||||||
|
if item.isPodcastContainer {
|
||||||
|
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
||||||
|
} else {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded, .failed:
|
||||||
|
Button {
|
||||||
|
app.downloads.startDownload(item: item)
|
||||||
|
} label: {
|
||||||
|
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
case .downloading:
|
||||||
|
Button {
|
||||||
|
app.downloads.cancel(downloadKey: key)
|
||||||
|
} label: {
|
||||||
|
Label("Download abbrechen", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
case .downloaded:
|
||||||
|
Button(role: .destructive) {
|
||||||
|
app.downloads.delete(downloadKey: key)
|
||||||
|
} label: {
|
||||||
|
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.progressCache[item.syncKey] != nil {
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await app.removeProgress(itemId: item.id, episodeId: item.episodeId) }
|
||||||
|
} label: {
|
||||||
|
Label("Fortschritt entfernen", systemImage: "arrow.uturn.backward.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,10 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
rateMenu
|
rateMenu
|
||||||
|
|
||||||
|
AirPlayPickerButton()
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.disabled(!app.player.isReady)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
app.stopPlayback()
|
app.stopPlayback()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -150,6 +154,11 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
sleepMenu
|
sleepMenu
|
||||||
|
|
||||||
|
AirPlayPickerButton()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.disabled(!app.player.isReady)
|
||||||
|
.help("Auf anderem Gerät abspielen (AirPlay)")
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
if historyEnabled {
|
if historyEnabled {
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ struct PodcastDetailView: View {
|
|||||||
@State private var podcastDetail: LibraryItem?
|
@State private var podcastDetail: LibraryItem?
|
||||||
@State private var isLoading: Bool = true
|
@State private var isLoading: Bool = true
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
/// Bevorzugt die nach `fetchEpisodes` aufgefrischte Detail-Version
|
||||||
|
/// des Podcasts (mit Cover, Description etc.), fällt auf den initial
|
||||||
|
/// übergebenen Container zurück solange der Fetch noch läuft oder
|
||||||
|
/// fehlgeschlagen ist.
|
||||||
|
private var resolvedPodcast: LibraryItem { podcastDetail ?? podcast }
|
||||||
|
|
||||||
|
private var filteredEpisodes: [PodcastEpisode] {
|
||||||
|
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !q.isEmpty else { return episodes }
|
||||||
|
return episodes.filter { ep in
|
||||||
|
ep.title.localizedCaseInsensitiveContains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -15,10 +30,11 @@ struct PodcastDetailView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.navigationTitle(podcastDetail?.title ?? podcast.title)
|
.navigationTitle(resolvedPodcast.title)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
.searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen")
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +75,16 @@ struct PodcastDetailView: View {
|
|||||||
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
||||||
} else if episodes.isEmpty {
|
} else if episodes.isEmpty {
|
||||||
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
||||||
|
} else if filteredEpisodes.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
} else {
|
} else {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
List {
|
List {
|
||||||
ForEach(episodes, id: \.id) { ep in
|
ForEach(filteredEpisodes, id: \.id) { ep in
|
||||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,13 +92,13 @@ struct PodcastDetailView: View {
|
|||||||
#else
|
#else
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
|
ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in
|
||||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||||
}
|
}
|
||||||
if idx < episodes.count - 1 {
|
if idx < filteredEpisodes.count - 1 {
|
||||||
Divider().padding(.leading, 16)
|
Divider().padding(.leading, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +140,10 @@ private struct EpisodeRow: View {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[syntheticItem.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "play.circle.fill")
|
Image(systemName: "play.circle.fill")
|
||||||
@@ -163,7 +185,7 @@ private struct EpisodeRow: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
||||||
if frac > 0 {
|
if frac > 0 && !isListened {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||||
@@ -179,6 +201,14 @@ private struct EpisodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.top, 4)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
downloadButton
|
downloadButton
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(width: 32)
|
.frame(width: 32)
|
||||||
@@ -191,7 +221,7 @@ private struct EpisodeRow: View {
|
|||||||
#else
|
#else
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
#endif
|
#endif
|
||||||
.contextMenu { contextMenuItems }
|
.contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -216,7 +246,9 @@ private struct EpisodeRow: View {
|
|||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Down-arrow = downloaded; checkmark is reserved for the listened
|
||||||
|
// indicator that may sit next to this in the row trailing area.
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
case .failed(let msg):
|
case .failed(let msg):
|
||||||
@@ -232,32 +264,6 @@ private struct EpisodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contextMenuItems: some View {
|
|
||||||
let key = syntheticItem.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button {
|
|
||||||
app.downloads.startDownload(item: syntheticItem)
|
|
||||||
} label: {
|
|
||||||
Label("Folge herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button {
|
|
||||||
app.downloads.cancel(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Folge löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatDuration(_ seconds: Double) -> String {
|
private func formatDuration(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||||
let total = Int(seconds)
|
let total = Int(seconds)
|
||||||
|
|||||||
@@ -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,9 +350,14 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var aboutPane: some View {
|
private var aboutPane: some View {
|
||||||
Form {
|
Form {
|
||||||
LabeledContent("Version", value: appVersion)
|
Section("Downloads") {
|
||||||
|
Toggle("Parallel herunterladen", isOn: $downloadsParallel)
|
||||||
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
|
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
|
||||||
}
|
}
|
||||||
|
Section("Über") {
|
||||||
|
LabeledContent("Version", value: appVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,95 +1,306 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct SplashView: View {
|
// MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset)
|
||||||
@State private var appeared = false
|
|
||||||
|
extension Image {
|
||||||
|
/// Lädt das echte AppIcon zur Laufzeit:
|
||||||
|
/// iOS via `CFBundleIcons` aus der Info.plist, macOS via
|
||||||
|
/// `NSApp.applicationIconImage`. Fällt auf ein SF-Symbol zurück wenn die
|
||||||
|
/// Plattform-spezifische Auflösung leer ist (z.B. Preview ohne Bundle).
|
||||||
|
static var appIcon: Image {
|
||||||
|
#if os(iOS)
|
||||||
|
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
|
||||||
|
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||||
|
let files = primary["CFBundleIconFiles"] as? [String],
|
||||||
|
let name = files.last,
|
||||||
|
let ui = UIImage(named: name) {
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
}
|
||||||
|
return Image(systemName: "books.vertical.fill")
|
||||||
|
#else
|
||||||
|
if let ns = NSApp?.applicationIconImage {
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
}
|
||||||
|
return Image(systemName: "books.vertical.fill")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color-Helper für hellere/dunklere Akzent-Töne
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
func lighter(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: amount) }
|
||||||
|
func darker(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: -amount) }
|
||||||
|
|
||||||
|
private func adjustBrightness(by amount: CGFloat) -> Color {
|
||||||
|
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
#if os(iOS)
|
||||||
|
UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||||
|
#else
|
||||||
|
(NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self))
|
||||||
|
.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||||
|
#endif
|
||||||
|
return Color(hue: Double(h),
|
||||||
|
saturation: Double(s),
|
||||||
|
brightness: Double(min(1.0, max(0.0, b + amount))),
|
||||||
|
opacity: Double(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Partikel-Konfiguration
|
||||||
|
|
||||||
|
private struct ParticleConfig: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let startAngle: Double // 0…2π — Position auf der Umlaufbahn bei t=0
|
||||||
|
let orbitRadius: CGFloat // 80…150pt — Radius der Umlaufbahn
|
||||||
|
let size: CGFloat // 4 / 7 / 10pt
|
||||||
|
let appearDelay: Double // 0…0.9s — Stagger-Fade-In
|
||||||
|
let angularVelocity: Double // rad/s — Umlaufgeschwindigkeit, Vorzeichen = Richtung
|
||||||
|
let radialWobble: CGFloat // 0…5pt — sanftes Atmen des Radius
|
||||||
|
let wobbleFrequency: Double // 0.4…0.8 Hz
|
||||||
|
let wobblePhase: Double // 0…2π
|
||||||
|
let shade: ParticleShade
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ParticleShade {
|
||||||
|
case light, accent, dark
|
||||||
|
|
||||||
|
func color(base: Color) -> Color {
|
||||||
|
switch self {
|
||||||
|
case .light: return base.lighter(by: 0.25)
|
||||||
|
case .accent: return base
|
||||||
|
case .dark: return base.darker(by: 0.20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acht Partikel auf unterschiedlichen Umlaufbahnen. Reproduzierbar bei
|
||||||
|
/// jedem App-Start (kein Random). Drei Bahnen-Schichten (innen/mitte/außen)
|
||||||
|
/// plus gemischte Drehrichtungen (Vorzeichen von `angularVelocity`) damit
|
||||||
|
/// das Bild nicht wie ein gleichförmiges Karussell wirkt.
|
||||||
|
private let particleConfigs: [ParticleConfig] = [
|
||||||
|
.init(id: 0, startAngle: 0.45, orbitRadius: 88, size: 7, appearDelay: 0.05, angularVelocity: 0.32, radialWobble: 3, wobbleFrequency: 0.7, wobblePhase: 0.3, shade: .light),
|
||||||
|
.init(id: 1, startAngle: 1.20, orbitRadius: 130, size: 4, appearDelay: 0.30, angularVelocity: -0.22, radialWobble: 4, wobbleFrequency: 0.5, wobblePhase: 1.1, shade: .accent),
|
||||||
|
.init(id: 2, startAngle: 2.00, orbitRadius: 85, size: 10, appearDelay: 0.55, angularVelocity: 0.18, radialWobble: 2, wobbleFrequency: 0.4, wobblePhase: 2.4, shade: .dark),
|
||||||
|
.init(id: 3, startAngle: 2.80, orbitRadius: 115, size: 7, appearDelay: 0.20, angularVelocity: 0.28, radialWobble: 5, wobbleFrequency: 0.6, wobblePhase: 0.8, shade: .accent),
|
||||||
|
.init(id: 4, startAngle: 3.65, orbitRadius: 95, size: 4, appearDelay: 0.70, angularVelocity: -0.35, radialWobble: 3, wobbleFrequency: 0.55, wobblePhase: 3.2, shade: .light),
|
||||||
|
.init(id: 5, startAngle: 4.40, orbitRadius: 140, size: 10, appearDelay: 0.10, angularVelocity: -0.16, radialWobble: 4, wobbleFrequency: 0.35, wobblePhase: 4.0, shade: .dark),
|
||||||
|
.init(id: 6, startAngle: 5.20, orbitRadius: 100, size: 7, appearDelay: 0.45, angularVelocity: 0.24, radialWobble: 3, wobbleFrequency: 0.65, wobblePhase: 5.1, shade: .light),
|
||||||
|
.init(id: 7, startAngle: 5.80, orbitRadius: 120, size: 4, appearDelay: 0.85, angularVelocity: -0.30, radialWobble: 5, wobbleFrequency: 0.45, wobblePhase: 1.6, shade: .accent),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - SplashScreenView
|
||||||
|
|
||||||
|
/// Vierphasiger Splash:
|
||||||
|
/// 1. Partikel erscheinen gestaffelt um das Icon (~1.2s)
|
||||||
|
/// 2. Partikel driften organisch in Loop bis `isReady` true wird (min 0.5s)
|
||||||
|
/// 3. Alle Partikel werden ins Icon gesogen (0.6s) mit kleinem Icon-Wobble
|
||||||
|
/// 4. Flash-Ring + Icon-Scale-Pulse (0.4s, iOS auch Haptic)
|
||||||
|
/// 5. Fade-Out (0.4s) → `onComplete()`
|
||||||
|
struct SplashScreenView: View {
|
||||||
|
let isReady: Bool
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
private enum Phase { case appearing, idle, attracting, flash, fading }
|
||||||
|
|
||||||
|
@State private var phase: Phase = .appearing
|
||||||
|
@State private var particlesShown: Bool = false
|
||||||
|
@State private var particlesAttracted: Bool = false
|
||||||
|
@State private var iconScale: CGFloat = 1.0
|
||||||
|
@State private var iconWobble: CGFloat = 1.0
|
||||||
|
@State private var iconBrightness: Double = 0
|
||||||
|
@State private var ringScale: CGFloat = 1.0
|
||||||
|
@State private var ringOpacity: Double = 0
|
||||||
|
@State private var splashOpacity: Double = 1.0
|
||||||
|
/// Spiegel von `isReady` mit `@State`-Storage. Nötig weil die `.task`-
|
||||||
|
/// Closure die View-Struct beim ersten Erscheinen capturet und das
|
||||||
|
/// `let isReady` aus dem alten Snapshot liest — das bleibt forever false
|
||||||
|
/// auch wenn der Parent neu rendert. `@State`-Storage dagegen ist
|
||||||
|
/// shared über Reconstructions hinweg, also sieht der laufende Task die
|
||||||
|
/// Aktualisierung sobald `onChange` sie hier reinpiped.
|
||||||
|
@State private var readySignaled: Bool = false
|
||||||
|
|
||||||
|
/// Einmalig festgelegt — Basis für die TimelineView-Zeit. Als `@State`,
|
||||||
|
/// damit der Wert bei View-Re-Renders aus ContentView nicht versehentlich
|
||||||
|
/// auf „jetzt" zurückgesetzt wird und die Drift-Bewegung dadurch springt.
|
||||||
|
@State private var referenceDate: Date = .init()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
background
|
||||||
|
ZStack {
|
||||||
|
particleLayer
|
||||||
|
iconLayer
|
||||||
|
flashRing
|
||||||
|
}
|
||||||
|
.frame(width: 320, height: 320)
|
||||||
|
}
|
||||||
|
.opacity(splashOpacity)
|
||||||
|
.task { await runSplashAnimation() }
|
||||||
|
.onAppear {
|
||||||
|
// Falls der Parent isReady bereits beim ersten Render gesetzt hat
|
||||||
|
// (z.B. weil bootstrap super-schnell durchlief), spiegeln wir den
|
||||||
|
// Anfangswert direkt — ohne dass eine onChange-Flanke nötig wäre.
|
||||||
|
if isReady { readySignaled = true }
|
||||||
|
}
|
||||||
|
.onChange(of: isReady) { _, newValue in
|
||||||
|
if newValue { readySignaled = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Background
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Color(.systemBackground).ignoresSafeArea()
|
Color(.systemBackground).ignoresSafeArea()
|
||||||
#else
|
#else
|
||||||
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
|
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
VStack(spacing: 36) {
|
// MARK: Icon
|
||||||
|
|
||||||
// ── Animated icon ────────────────────────────────────────
|
private var iconLayer: some View {
|
||||||
|
Image.appIcon
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 96, height: 96)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 3)
|
||||||
|
.scaleEffect(iconScale * iconWobble)
|
||||||
|
.brightness(iconBrightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Flash-Ring
|
||||||
|
|
||||||
|
private var flashRing: some View {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white, lineWidth: 4)
|
||||||
|
.frame(width: 96, height: 96)
|
||||||
|
.scaleEffect(ringScale)
|
||||||
|
.opacity(ringOpacity)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Partikel
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var particleLayer: some View {
|
||||||
|
// TimelineView läuft während `.appearing` und `.idle`, damit die
|
||||||
|
// Partikel schon beim Stagger-Fade-In leicht driften (sonst gäb's
|
||||||
|
// einen sichtbaren Positions-Sprung beim Übergang zu `.idle`).
|
||||||
|
// Pausiert ab `.attracting` — die gefrorene Position dient als
|
||||||
|
// Startpunkt für die Attract-Animation Richtung .zero.
|
||||||
|
let paused = (phase == .attracting || phase == .flash || phase == .fading)
|
||||||
|
TimelineView(.animation(paused: paused)) { context in
|
||||||
|
let t = context.date.timeIntervalSince(referenceDate)
|
||||||
ZStack {
|
ZStack {
|
||||||
// Outer glow pulse
|
ForEach(particleConfigs) { cfg in
|
||||||
|
particleView(cfg: cfg, t: t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func particleView(cfg: ParticleConfig, t: TimeInterval) -> some View {
|
||||||
|
let drifted = idlePosition(for: cfg, t: t)
|
||||||
|
let position = particlesAttracted ? CGPoint.zero : drifted
|
||||||
|
let scale: CGFloat = particlesAttracted ? 0.0 : 1.0
|
||||||
|
|
||||||
|
// Wichtig: NICHT `Color.accentColor` (semantisch — konvertiert via
|
||||||
|
// UIColor unzuverlässig zum echten Asset-Wert, gibt manchmal den
|
||||||
|
// System-Tint = blau zurück). Direkt aus dem Asset-Catalog lesen.
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.accentColor.opacity(0.15))
|
.fill(cfg.shade.color(base: Color("AccentColor")))
|
||||||
.frame(width: 180, height: 180)
|
.frame(width: cfg.size, height: cfg.size)
|
||||||
.scaleEffect(appeared ? 1.0 : 0.2)
|
.opacity(particlesShown ? 1.0 : 0.0)
|
||||||
.blur(radius: appeared ? 12 : 40)
|
.scaleEffect(scale)
|
||||||
.animation(.easeOut(duration: 1.1), value: appeared)
|
.offset(x: position.x, y: position.y)
|
||||||
|
// Stagger-Fade-In: jeder Partikel mit eigener Verzögerung
|
||||||
// Ring border
|
.animation(.easeOut(duration: 0.5).delay(cfg.appearDelay), value: particlesShown)
|
||||||
Circle()
|
// Attract: 0.6s easeIn ins Zentrum, gleichzeitig auf scale=0
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
.animation(.easeIn(duration: 0.6), value: particlesAttracted)
|
||||||
.frame(width: 130, height: 130)
|
|
||||||
.scaleEffect(appeared ? 1.0 : 0.4)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
|
||||||
.animation(.easeOut(duration: 0.8).delay(0.1), value: appeared)
|
|
||||||
|
|
||||||
// Book icon springs into place
|
|
||||||
Image(systemName: "books.vertical.fill")
|
|
||||||
.font(.system(size: 58, weight: .regular))
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
.scaleEffect(appeared ? 1.0 : 0.1)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
|
||||||
.animation(.spring(duration: 0.65, bounce: 0.55), value: appeared)
|
|
||||||
.symbolEffect(.pulse.byLayer,
|
|
||||||
options: .speed(0.5).repeating,
|
|
||||||
value: appeared)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Text ─────────────────────────────────────────────────
|
private func idlePosition(for cfg: ParticleConfig, t: TimeInterval) -> CGPoint {
|
||||||
VStack(spacing: 6) {
|
// Echte Umlaufbahn: Winkel wächst mit t (Vorzeichen = Drehrichtung).
|
||||||
Text("ABS Client")
|
// Radius "atmet" leicht über sinusförmiges radialWobble — damit die
|
||||||
.font(.title2.bold())
|
// Bahn nicht maschinell perfekt-kreisförmig wirkt.
|
||||||
.opacity(appeared ? 1 : 0)
|
let angle = cfg.startAngle + t * cfg.angularVelocity
|
||||||
.offset(y: appeared ? 0 : 18)
|
let wobble = sin(t * cfg.wobbleFrequency + cfg.wobblePhase) * Double(cfg.radialWobble)
|
||||||
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
|
let radius = Double(cfg.orbitRadius) + wobble
|
||||||
|
return CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
|
||||||
Text("Audiobookshelf")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
|
||||||
.offset(y: appeared ? 0 : 10)
|
|
||||||
.animation(.easeOut(duration: 0.45).delay(0.42), value: appeared)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loading dots at bottom ────────────────────────────────
|
// MARK: Animation-Orchestrierung
|
||||||
VStack {
|
|
||||||
Spacer()
|
private func runSplashAnimation() async {
|
||||||
LoadingDots()
|
// Phase 1a — Partikel erscheinen gestaffelt
|
||||||
.opacity(appeared ? 1 : 0)
|
particlesShown = true
|
||||||
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
|
// Warten bis alle Partikel sichtbar sind (max appearDelay 0.85 + fade 0.5)
|
||||||
.padding(.bottom, 60)
|
try? await Task.sleep(for: .milliseconds(1400))
|
||||||
}
|
if Task.isCancelled { return }
|
||||||
}
|
phase = .idle
|
||||||
.onAppear { appeared = true }
|
|
||||||
}
|
// Phase 1b — Drift-Loop bis readySignaled UND min 500ms in idle.
|
||||||
}
|
// Wichtig: NICHT `isReady` lesen — siehe Doc-Kommentar an readySignaled.
|
||||||
|
let idleStart = Date()
|
||||||
private struct LoadingDots: View {
|
let minIdleDuration: TimeInterval = 0.5
|
||||||
@State private var phase: Int = 0
|
while !readySignaled || Date().timeIntervalSince(idleStart) < minIdleDuration {
|
||||||
|
try? await Task.sleep(for: .milliseconds(80))
|
||||||
var body: some View {
|
if Task.isCancelled { return }
|
||||||
HStack(spacing: 7) {
|
|
||||||
ForEach(0..<3, id: \.self) { i in
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
|
|
||||||
.frame(width: 7, height: 7)
|
|
||||||
.scaleEffect(phase == i ? 1.25 : 1.0)
|
|
||||||
.animation(.easeInOut(duration: 0.35), value: phase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
|
|
||||||
phase = (phase + 1) % 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2 — Sog ins Icon-Zentrum
|
||||||
|
phase = .attracting
|
||||||
|
withAnimation(.easeIn(duration: 0.6)) {
|
||||||
|
particlesAttracted = true
|
||||||
}
|
}
|
||||||
|
// Bei ~400ms in die Attract-Phase ein kleiner Icon-Wobble als die
|
||||||
|
// Partikel ankommen — fühlt sich an wie der Impact.
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.1)) { iconWobble = 1.06 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
withAnimation(.easeIn(duration: 0.1)) { iconWobble = 1.0 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
// Phase 3 — Flash + Scale-Pulse + Haptic
|
||||||
|
phase = .flash
|
||||||
|
#if os(iOS)
|
||||||
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
|
#endif
|
||||||
|
// Ring: 1.0 → 2.5×, opacity 1 → 0 (0.4s easeOut)
|
||||||
|
ringScale = 1.0
|
||||||
|
ringOpacity = 1.0
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
ringScale = 2.5
|
||||||
|
ringOpacity = 0
|
||||||
|
}
|
||||||
|
// Icon-Brightness: schnell hoch, langsamer runter
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) { iconBrightness = 0.6 }
|
||||||
|
// Parallel: Icon-Scale-Pulse 1.0 → 1.15 → 1.0
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { iconScale = 1.15 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
withAnimation(.easeIn(duration: 0.25)) { iconBrightness = 0 }
|
||||||
|
withAnimation(.easeIn(duration: 0.2)) { iconScale = 1.0 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
// Phase 4 — Fade
|
||||||
|
phase = .fading
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
splashOpacity = 0
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .milliseconds(420))
|
||||||
|
onComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
"library.settings" = "Einstellungen";
|
"library.settings" = "Einstellungen";
|
||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
"login.title" = "ABS Client";
|
"login.title" = "Nook";
|
||||||
"login.server_placeholder" = "https://mein-server.de";
|
"login.server_placeholder" = "https://mein-server.de";
|
||||||
"login.username" = "Benutzername";
|
"login.username" = "Benutzername";
|
||||||
"login.password" = "Passwort";
|
"login.password" = "Passwort";
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"sidebar.offline" = "Offline";
|
"sidebar.offline" = "Offline";
|
||||||
"sidebar.history" = "Verlauf";
|
"sidebar.history" = "Verlauf";
|
||||||
"sidebar.logout" = "Abmelden";
|
"sidebar.logout" = "Abmelden";
|
||||||
"sidebar.app_title" = "ABS Client";
|
"sidebar.app_title" = "Nook";
|
||||||
"sidebar.status_online" = "Online";
|
"sidebar.status_online" = "Online";
|
||||||
"sidebar.status_offline" = "Offline";
|
"sidebar.status_offline" = "Offline";
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
"library.settings" = "Settings";
|
"library.settings" = "Settings";
|
||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
"login.title" = "ABS Client";
|
"login.title" = "Nook";
|
||||||
"login.server_placeholder" = "https://my-server.com";
|
"login.server_placeholder" = "https://my-server.com";
|
||||||
"login.username" = "Username";
|
"login.username" = "Username";
|
||||||
"login.password" = "Password";
|
"login.password" = "Password";
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"sidebar.offline" = "Offline";
|
"sidebar.offline" = "Offline";
|
||||||
"sidebar.history" = "History";
|
"sidebar.history" = "History";
|
||||||
"sidebar.logout" = "Sign out";
|
"sidebar.logout" = "Sign out";
|
||||||
"sidebar.app_title" = "ABS Client";
|
"sidebar.app_title" = "Nook";
|
||||||
"sidebar.status_online" = "Online";
|
"sidebar.status_online" = "Online";
|
||||||
"sidebar.status_offline" = "Offline";
|
"sidebar.status_offline" = "Offline";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>ABS Client</string>
|
<string>Nook</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ABS Client</string>
|
<string>Nook</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -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
62
README.md
62
README.md
@@ -1,4 +1,4 @@
|
|||||||
# ABS-Client
|
# Nook
|
||||||
|
|
||||||
> Ein nativer iOS- und macOS-Client für deine selbst gehostete [Audiobookshelf](https://www.audiobookshelf.org/)-Instanz.
|
> Ein nativer iOS- und macOS-Client für deine selbst gehostete [Audiobookshelf](https://www.audiobookshelf.org/)-Instanz.
|
||||||
|
|
||||||
@@ -6,37 +6,70 @@
|
|||||||
|
|
||||||
## 📖 Über die App
|
## 📖 Über die App
|
||||||
|
|
||||||
**ABS-Client** ist eine native App für iPhone, iPad und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek – ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
|
**Nook** ist eine native App für iPhone und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek – ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
|
||||||
|
|
||||||
> ⚠️ **Hinweis:** Die App befindet sich noch in aktiver Entwicklung und ist aktuell noch nicht im App Store verfügbar. Der Release ist in Kürze geplant.
|
---
|
||||||
|
|
||||||
|
## 📲 Im App Store erhältlich
|
||||||
|
|
||||||
|
Nook ist sowohl für **iPhone** als auch für **Mac** kostenlos im App Store verfügbar:
|
||||||
|
|
||||||
|
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### Aktuell verfügbar
|
### Bibliothek & Inhalte
|
||||||
- 🔗 **Serververbindung** – Verbinde die App mit deiner eigenen Audiobookshelf-Instanz
|
- 🔗 **Serververbindung** – Verbinde die App mit deiner eigenen Audiobookshelf-Instanz
|
||||||
- 📚 **Bibliotheks-Browser** – Durchsuche und verwalte deine Hörbücher
|
- 📚 **Bibliotheks-Browser** – Durchsuche deine Hörbücher in Kachel- oder Listenansicht
|
||||||
- ⬇️ **Download** – Lade Hörbücher für die Offline-Nutzung herunter
|
- 🎙️ **Podcast-Unterstützung** – Episoden direkt aus deiner ABS-Bibliothek wiedergeben
|
||||||
- 🎧 **Wiedergabe** – Höre deine Hörbücher direkt in der App
|
- 🖼️ **Cover-Cache** – Cover werden lokal zwischengespeichert, kein erneutes Laden bei jedem App-Start
|
||||||
|
|
||||||
### Geplant
|
### Wiedergabe
|
||||||
- ⏱️ **Einschlaftimer** – Automatisches Stoppen der Wiedergabe nach einer festgelegten Zeit
|
- 🎧 **Vollwertiger Player** – Wiedergabe mit Scrubber, Skip-Buttons (15s / 30s / 45s / 60s / 90s konfigurierbar) und Geschwindigkeitsregler (0.75× bis 2.0×)
|
||||||
- 🌍 **Mehrsprachigkeit** – Die App ist aktuell nur auf Deutsch verfügbar; weitere Sprachen sind in Planung
|
- 📑 **Kapitel-Navigation** – Springe direkt zu Kapiteln; aktuelles Kapitel wird hervorgehoben
|
||||||
|
- 🔖 **Lesezeichen** – Markiere beliebige Stellen mit eigenem Namen, synchronisiert mit deinem ABS-Server
|
||||||
|
- ⏱️ **Einschlaftimer** – Mehrere Modi: Festgelegte Minuten, „Bis Ende des Kapitels" oder „Bis Ende des Hörbuchs"
|
||||||
|
- 🎚️ **Lockscreen- & Control-Center-Steuerung** – Vollständige Wiedergabesteuerung von außerhalb der App
|
||||||
|
|
||||||
|
### Downloads & Offline
|
||||||
|
- ⬇️ **Offline-Downloads** – Lade Hörbücher und Podcast-Episoden für die Offline-Wiedergabe
|
||||||
|
- 📊 **Live-Fortschritt** – Echtzeit-Anzeige der heruntergeladenen MB pro Hörbuch
|
||||||
|
- 🔄 **Hintergrund-Downloads** *(iOS)* – Downloads laufen weiter, auch wenn die App geschlossen oder das Gerät gesperrt ist
|
||||||
|
- 🎛️ **Parallel oder sequenziell** – Wähle in den Einstellungen ob mehrere Downloads gleichzeitig oder nacheinander laufen sollen
|
||||||
|
- 📶 **Mobilnetz-Schutz** – Optional Downloads nur über WLAN
|
||||||
|
- 🔔 **Live Activities** *(iOS)* – Download-Fortschritt direkt im Lockscreen und Dynamic Island
|
||||||
|
|
||||||
|
### Hörfortschritt & Verlauf
|
||||||
|
- 🔄 **Bidirektionale Synchronisation** – Hörfortschritt wird zum Server gepusht und periodisch nachgefragt; bei Konflikten (z. B. anderes Gerät war voraus) wird beim Fortsetzen gefragt
|
||||||
|
- 📜 **Hörverlauf** *(opt-in)* – Lokales Protokoll deiner Hörpositionen, ideal um nach versehentlichem Verspulen schnell zurückzuspringen
|
||||||
|
- 💾 **XML-Export** – Hörverlauf als XML exportieren und teilen
|
||||||
|
- 🌐 **Cross-Device Foreground-Sync** – Beim Zurückkehren in die App wird der Server sofort um aktuelle Daten gefragt
|
||||||
|
|
||||||
|
### Sprache & Plattform
|
||||||
|
- 🇩🇪 **Deutsch** und 🇬🇧 **Englisch** – Vollständig lokalisierte Oberfläche
|
||||||
|
- 🖥️ **macOS-Optimierung** – Eigene Sidebar-Navigation, Tastatursteuerung und Multi-Window-Support
|
||||||
|
- 📱 **iOS-Optimierung** – Liquid-Glass-Design, native Sheets und kontextuelle Menüs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
- **iOS / iPadOS:** 26.0 oder neuer
|
- **iOS:** 26.0 oder neuer (iPhone)
|
||||||
- **macOS:** 26.0 (Tahoe) oder neuer
|
- **macOS:** 26.0 (Tahoe) oder neuer
|
||||||
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz
|
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz (Version 2.x)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 Installation
|
||||||
|
|
||||||
Die App ist noch nicht im App Store verfügbar. Wer die App bereits jetzt nutzen oder testen möchte, kann das Projekt selbst aus dem Quellcode bauen:
|
### Aus dem App Store
|
||||||
|
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
|
||||||
|
|
||||||
|
Der Link funktioniert sowohl für iPhone als auch für Mac — der App Store leitet automatisch zur passenden Version weiter.
|
||||||
|
|
||||||
|
### Aus dem Quellcode bauen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.scarriffle.com/Scarriffle/ABS-Client.git
|
git clone https://git.scarriffle.com/Scarriffle/ABS-Client.git
|
||||||
@@ -72,6 +105,7 @@ Dieses Projekt ist lizenziert unter der **GNU General Public License v3.0**.
|
|||||||
Das bedeutet: Du kannst den Code frei nutzen, verändern und weitergeben – aber abgeleitete Projekte müssen ebenfalls unter der GPL v3 veröffentlicht werden.
|
Das bedeutet: Du kannst den Code frei nutzen, verändern und weitergeben – aber abgeleitete Projekte müssen ebenfalls unter der GPL v3 veröffentlicht werden.
|
||||||
|
|
||||||
Den vollständigen Lizenztext findest du in der [LICENSE](LICENSE)-Datei.
|
Den vollständigen Lizenztext findest du in der [LICENSE](LICENSE)-Datei.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📬 Kontakt
|
## 📬 Kontakt
|
||||||
@@ -82,4 +116,4 @@ Bei Fragen, Feedback oder Problemen:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ABS-Client ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).
|
> Nook ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).
|
||||||
|
|||||||
Reference in New Issue
Block a user