Compare commits

..

6 Commits

Author SHA1 Message Date
Scarriffle
77342d619b Rename app to Nook (display name only)
Changes the user-visible name from "ABS Client" to "Nook" across the
iOS home screen, macOS Dock/menu bar, login header, sidebar header, and
README. Bundle ID (com.local.ABS-Client), keychain key, and background
download session ID are intentionally left unchanged so the existing
App Store listing (ID 6770326901) updates in place — installed users
keep the app, stored credentials, and downloads.

- Build settings: INFOPLIST_KEY_CFBundleDisplayName, CFBundleName,
  PRODUCT_NAME → "Nook" (Debug + Release); built app is now Nook.app
- Info-iOS.plist: CFBundleDisplayName + CFBundleName → "Nook"
- Localizable.strings (de + en): login.title + sidebar.app_title → "Nook"
- LoginView: hardcoded headers → "Nook"
- README: branding text → "Nook" (git repo URLs and App Store URL
  unchanged — repo and listing rename happen separately)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 11:35:26 +02:00
Scarriffle
a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library
- Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library
- Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail
- Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished
- Add AirPlay picker in PlayerBar
- Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup
- Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s
- Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id)
- Bump CFBundleVersion to 8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 08:54:43 +02:00
Scarriffle
1dbd133b75 Remove iPad references from README — app is iPhone- and Mac-only
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:43:59 +02:00
Scarriffle
fec48cb2af Update README with App Store availability and current feature list
- Remove "not yet available" warning; app is now live on iOS and macOS App Store
- Add direct App Store link (works for both platforms)
- Reorganize features into sections: library, playback, downloads & offline,
  progress sync, language & platform
- Document recently shipped features: bidirectional progress sync, chapters,
  bookmarks, history with XML export, live download progress, background
  downloads, live activities, parallel/sequential download modes, mobile-data
  toggle, DE/EN localization

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:41:05 +02:00
Scarriffle
134482bb4a Enhance download notifications and settings; add support for live activities
- Updated notification body to use proper German quotation marks for download failure alerts.
- Refactored audio interruption handling in PlayerEngine to utilize MainActor.assumeIsolated for better performance.
- Introduced nonisolated properties for support and downloads directories in ProgressSyncManager.
- Improved FullHistoryView and MainView with additional toolbar items and alerts for download failures.
- Added new toggles in SettingsView for parallel downloads and mobile data usage.
- Created new color assets for the DownloadWidgetExtension and updated widget appearance.
- Implemented Info.plist for DownloadWidgetExtension to support widget functionality.
- Enabled live activities support in iOS Info.plist.
2026-06-08 19:32:54 +02:00
Scarriffle
f053f1d320 Neues Icon, parallele Downloads 2026-06-08 19:32:18 +02:00
43 changed files with 2874 additions and 488 deletions

View File

@@ -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 */;

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,102 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"fill" : {
"linear-gradient" : [
"display-p3:0.10645,0.27174,0.19292,1.00000",
"display-p3:0.00006,0.13678,0.07057,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"blur-material" : 0.5,
"hidden" : false,
"layers" : [
{
"fill" : {
"solid" : "display-p3:0.30163,0.65906,0.39475,1.00000"
},
"hidden" : false,
"image-name" : "audiobook.svg",
"name" : "audiobook",
"position" : {
"scale" : 1.45,
"translation-in-points" : [
-0.6150821872817005,
-21.261194425549093
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.77
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
},
{
"hidden" : true,
"layers" : [
{
"blend-mode" : "screen",
"fill" : {
"solid" : "display-p3:0.00169,0.78170,0.17447,1.00000"
},
"glass" : true,
"image-name" : "headset-.svg",
"name" : "headset-",
"opacity" : 0.33,
"position" : {
"scale" : 1.75,
"translation-in-points" : [
-0.614357187281712,
-63.2819525338457
]
}
},
{
"blend-mode" : "screen",
"fill" : {
"solid" : "display-p3:0.31727,0.82379,0.38520,1.00000"
},
"glass" : true,
"image-name" : "open-book-3.svg",
"name" : "open-book-3",
"position" : {
"scale" : 0.75,
"translation-in-points" : [
-0.6139821872816356,
146.59439922330398
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : false,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : "shared"
}
}

View File

@@ -1,11 +1,19 @@
import SwiftUI 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

View File

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

View File

@@ -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 (`&uuml;`, `&euro;`, `&#8364;` 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] = [
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&apos;": "'", "&nbsp;": " ",
"&euro;": "", "&pound;": "£", "&yen;": "¥",
"&copy;": "©", "&reg;": "®", "&trade;": "",
"&hellip;": "", "&mdash;": "", "&ndash;": "",
"&laquo;": "«", "&raquo;": "»",
"&bdquo;": "", "&ldquo;": "\u{201C}", "&rdquo;": "\u{201D}",
"&lsquo;": "\u{2018}", "&rsquo;": "\u{2019}",
"&auml;": "ä", "&Auml;": "Ä",
"&ouml;": "ö", "&Ouml;": "Ö",
"&uuml;": "ü", "&Uuml;": "Ü",
"&szlig;": "ß",
"&eacute;": "é", "&Eacute;": "É",
"&egrave;": "è", "&Egrave;": "È",
"&agrave;": "à", "&Agrave;": "À",
"&acirc;": "â", "&Acirc;": "Â",
"&ecirc;": "ê", "&Ecirc;": "Ê",
"&icirc;": "î", "&Icirc;": "Î",
"&ocirc;": "ô", "&Ocirc;": "Ô",
"&ucirc;": "û", "&Ucirc;": "Û",
"&ntilde;": "ñ", "&Ntilde;": "Ñ",
"&ccedil;": "ç", "&Ccedil;": "Ç",
]
for (entity, char) in named {
result = result.replacingOccurrences(of: entity, with: char)
}
// Numeric refs: &#1234; (decimal) or &#xABCD; (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
) )
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
import Foundation import 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 -> 01).
/// Drives a smooth progress ring even for single-track audiobooks where
/// `completedTracks` stays at 0 for the entire download.
private var perTrackFractions: [String: [Int: Double]] = [:]
/// Global FIFO queue of waiting tasks across ALL downloads. When the user
/// has parallel mode off, only `maxConcurrentDownloads` tasks run at any
/// time globally so starting Book 2 while Book 1 is in flight leaves
/// Book 2 entirely queued until Book 1 finishes.
private var globalQueue: [QueuedUnit] = []
/// Number of URLSession tasks we've handed off to the OS and that haven't
/// reported completion yet. Compared against `maxConcurrentDownloads` to
/// decide whether to drain the queue.
private var activeTaskCount: Int = 0
/// Per-track URLRequest cache used so we can rebuild a download task
/// even after the queue has shifted.
private var pendingRequests: [String: [Int: URLRequest]] = [:]
private var maxConcurrentDownloads: Int {
let parallel = UserDefaults.standard.object(forKey: "downloadsParallel") as? Bool ?? true
return parallel ? Int.max : 1
}
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } private var 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 (01) of the
/// currently downloading track. Combines with the completed-track count to
/// drive a smooth overall progress ring, even for single-track downloads.
nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) {
Task { @MainActor [self] in
guard let idx = self.currentTrackIndex[downloadKey],
let total = self.totalTrackCount[downloadKey],
total > 0 else { return }
let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total)
// Clamp below 1.0 so `performDownload` is the only place that
// transitions to `.downloaded` after the final track is persisted.
self.states[downloadKey] = .downloading(progress: min(0.999, overall))
}
}
private func fileSize(at url: URL) -> Int64 { 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]
} }
/// Per-task delegate for a single `URLSessionDownloadTask`. Forwards live byte private func persistPending() {
/// progress to the manager and bridges the delegate callbacks back to async/await let snapshot = PendingSnapshot(
/// via a checked continuation. The system deletes the `didFinishDownloadingTo` pendingItems: pendingItems,
/// temp URL immediately after the callback returns, so we move it to a stable completedTracks: completedTracks,
/// location before resuming. totalTrackCount: totalTrackCount
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { )
private let manager: DownloadManager do {
private let downloadKey: String let data = try JSONEncoder().encode(snapshot)
private let continuation: CheckedContinuation<(URL, URLResponse), Error> try data.write(to: pendingFile, options: .atomic)
private var stableTempURL: URL? } catch {
private var didResume = false // non-fatal
private let lock = NSLock() }
init(
manager: DownloadManager,
downloadKey: String,
continuation: CheckedContinuation<(URL, URLResponse), Error>
) {
self.manager = manager
self.downloadKey = downloadKey
self.continuation = continuation
super.init()
} }
private func resumeOnce(with result: Result<(URL, URLResponse), Error>) { private func loadPending() {
lock.lock(); defer { lock.unlock() } guard let data = try? Data(contentsOf: pendingFile),
guard !didResume else { return } let snapshot = try? JSONDecoder().decode(PendingSnapshot.self, from: data)
didResume = true else { return }
continuation.resume(with: result) pendingItems = snapshot.pendingItems
completedTracks = snapshot.completedTracks
totalTrackCount = snapshot.totalTrackCount
// Restore states from progress (won't be `.downloaded` since persistIndex
// is the only path that flips that).
for (key, item) in pendingItems {
if let total = totalTrackCount[key], total > 0 {
let done = Double(completedTracks[key]?.count ?? 0)
states[key] = .downloading(progress: min(0.999, done / Double(total)))
} else {
states[key] = .downloading(progress: 0)
}
_ = item // referenced for clarity
}
}
} }
func urlSession( // MARK: - Queued unit
/// One entry in `DownloadManager.globalQueue`. Holds everything needed to spin
/// up the URLSessionDownloadTask later, including the prepared URLRequest with
/// its bearer headers and cellular-allowance flag already applied.
fileprivate struct QueuedUnit {
let meta: TaskMeta
let request: URLRequest
}
// MARK: - Task metadata
/// Encoded into `URLSessionTask.taskDescription` so the delegate has everything
/// it needs to route bytes back to the right download / track on the iOS
/// background queue (which has no access to MainActor state).
///
/// Uses a hand-rolled tab-separated encoding rather than `Codable` so we don't
/// need a nonisolated conformance the project's
/// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` would otherwise pin Encodable
/// to MainActor, which can't be called from the URLSession delegate queue.
fileprivate struct TaskMeta: Sendable {
let downloadKey: String
let trackIndex: Int
let itemId: String
let episodeId: String?
let destName: String
let ino: String
let filename: String
let durationSeconds: Double
private nonisolated static let separator = "\u{1F}" // ASCII unit separator won't appear in IDs/filenames
nonisolated func encoded() -> String {
let parts: [String] = [
downloadKey,
String(trackIndex),
itemId,
episodeId ?? "",
destName,
ino,
filename,
String(durationSeconds)
]
return parts.joined(separator: Self.separator)
}
nonisolated static func decode(_ description: String?) -> TaskMeta? {
guard let description else { return nil }
let parts = description.components(separatedBy: separator)
guard parts.count == 8,
let trackIndex = Int(parts[1]),
let durationSeconds = Double(parts[7])
else { return nil }
return TaskMeta(
downloadKey: parts[0],
trackIndex: trackIndex,
itemId: parts[2],
episodeId: parts[3].isEmpty ? nil : parts[3],
destName: parts[4],
ino: parts[5],
filename: parts[6],
durationSeconds: durationSeconds
)
}
}
// MARK: - URLSessionDownloadDelegate
extension DownloadManager: URLSessionDownloadDelegate {
nonisolated func urlSession(
_ session: URLSession, _ 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 // 02π Position auf der Umlaufbahn bei t=0
let orbitRadius: CGFloat // 80150pt Radius der Umlaufbahn
let size: CGFloat // 4 / 7 / 10pt
let appearDelay: Double // 00.9s Stagger-Fade-In
let angularVelocity: Double // rad/s Umlaufgeschwindigkeit, Vorzeichen = Richtung
let radialWobble: CGFloat // 05pt sanftes Atmen des Radius
let wobbleFrequency: Double // 0.40.8 Hz
let wobblePhase: Double // 02π
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()
let minIdleDuration: TimeInterval = 0.5
while !readySignaled || Date().timeIntervalSince(idleStart) < minIdleDuration {
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
} }
private struct LoadingDots: View { // Phase 2 Sog ins Icon-Zentrum
@State private var phase: Int = 0 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 }
var body: some View { // Phase 3 Flash + Scale-Pulse + Haptic
HStack(spacing: 7) { phase = .flash
ForEach(0..<3, id: \.self) { i in #if os(iOS)
Circle() UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3)) #endif
.frame(width: 7, height: 7) // Ring: 1.0 2.5×, opacity 1 0 (0.4s easeOut)
.scaleEffect(phase == i ? 1.25 : 1.0) ringScale = 1.0
.animation(.easeInOut(duration: 0.35), value: phase) ringOpacity = 1.0
} withAnimation(.easeOut(duration: 0.4)) {
} ringScale = 2.5
.onAppear { ringOpacity = 0
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in }
phase = (phase + 1) % 3 // 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()
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.337",
"green" : "0.847",
"red" : "0.298"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.094",
"green" : "0.196",
"red" : "0.078"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

Submodule Exports/Mac/Calendarr added at e744b1829e

View File

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