Compare commits
4 Commits
134482bb4a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77342d619b | ||
|
|
a17a61ad9a | ||
|
|
1dbd133b75 | ||
|
|
fec48cb2af |
@@ -37,7 +37,7 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* 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>"; };
|
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>"; };
|
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>"; };
|
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = "<group>"; };
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
|
39614D0B2FB4D44500DBEF5E /* Nook.app */,
|
||||||
39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */,
|
39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
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 */ = {
|
39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */ = {
|
||||||
@@ -395,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 = 2;
|
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;
|
||||||
@@ -404,8 +404,8 @@
|
|||||||
"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;
|
||||||
@@ -423,9 +423,9 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.5;
|
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;
|
||||||
@@ -449,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 = 2;
|
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;
|
||||||
@@ -458,8 +458,8 @@
|
|||||||
"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;
|
||||||
@@ -477,9 +477,9 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 2.5;
|
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;
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
|
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
|
||||||
@@ -512,13 +512,15 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
@@ -533,7 +535,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
|
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
|
||||||
@@ -545,13 +547,15 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.1;
|
MARKETING_VERSION = 2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
buildArchitectures = "Automatic">
|
buildArchitectures = "Automatic">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "NO"
|
||||||
buildForRunning = "YES"
|
buildForRunning = "NO"
|
||||||
buildForProfiling = "YES"
|
buildForProfiling = "NO"
|
||||||
buildForArchiving = "YES"
|
buildForArchiving = "YES"
|
||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
||||||
BuildableName = "ABS Client.app"
|
BuildableName = "Nook.app"
|
||||||
BlueprintName = "Audiobookshelf swift"
|
BlueprintName = "Audiobookshelf swift"
|
||||||
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -58,16 +58,15 @@
|
|||||||
allowLocationSimulation = "YES"
|
allowLocationSimulation = "YES"
|
||||||
launchAutomaticallySubstyle = "2"
|
launchAutomaticallySubstyle = "2"
|
||||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||||
<BuildableProductRunnable
|
<MacroExpansion>
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
||||||
BuildableName = "ABS Client.app"
|
BuildableName = "Nook.app"
|
||||||
BlueprintName = "Audiobookshelf swift"
|
BlueprintName = "Audiobookshelf swift"
|
||||||
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</MacroExpansion>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "_XCWidgetKind"
|
key = "_XCWidgetKind"
|
||||||
@@ -99,7 +98,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
|
||||||
BuildableName = "ABS Client.app"
|
BuildableName = "Nook.app"
|
||||||
BlueprintName = "Audiobookshelf swift"
|
BlueprintName = "Audiobookshelf swift"
|
||||||
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
|||||||
@@ -7,21 +7,18 @@
|
|||||||
<key>Audiobookshelf swift.xcscheme_^#shared#^_</key>
|
<key>Audiobookshelf swift.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>DownloadWidgetExtensionExtension.xcscheme_^#shared#^_</key>
|
<key>DownloadWidgetExtensionExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>isShown</key>
|
||||||
|
<false/>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>39614D0A2FB4D44400DBEF5E</key>
|
|
||||||
<dict>
|
|
||||||
<key>primary</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>39C4270A2FD189220033EE46</key>
|
<key>39C4270A2FD189220033EE46</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>primary</key>
|
<key>primary</key>
|
||||||
|
|||||||
@@ -56,9 +56,18 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ struct Library: Codable, Identifiable, Hashable {
|
|||||||
let mediaType: String?
|
let mediaType: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wichtig für `ForEach`: Synthetische Podcast-Episoden-Items (z.B. aus der
|
||||||
|
/// library-weiten Folgensuche) teilen sich denselben `id` (Podcast-Container-ID)
|
||||||
|
/// und unterscheiden sich nur über `episodeId`. Default-`Identifiable` würde
|
||||||
|
/// hier kollidieren. **Immer** `ForEach(items, id: \.syncKey)` benutzen wenn
|
||||||
|
/// die Liste sowohl Container als auch Episoden-Items enthalten kann.
|
||||||
struct LibraryItem: Codable, Identifiable, Hashable {
|
struct LibraryItem: Codable, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
@@ -74,6 +79,10 @@ struct PlaybackProgress: Codable, Hashable {
|
|||||||
var duration: Double
|
var duration: Double
|
||||||
var isFinished: Bool
|
var isFinished: Bool
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
/// Server-assigned UUID for this progress entry. Required to delete it —
|
||||||
|
/// Audiobookshelf's `DELETE /api/me/progress/:id` matches by this id, NOT
|
||||||
|
/// by libraryItemId. Optional so older persisted caches still decode.
|
||||||
|
var id: String?
|
||||||
|
|
||||||
var syncKey: String {
|
var syncKey: String {
|
||||||
if let episodeId { return "\(itemId)|\(episodeId)" }
|
if let episodeId { return "\(itemId)|\(episodeId)" }
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ final class ABSClient {
|
|||||||
func fetchLibraries() async throws -> [Library] {
|
func fetchLibraries() async throws -> [Library] {
|
||||||
let req = try makeRequest(path: "/api/libraries")
|
let req = try makeRequest(path: "/api/libraries")
|
||||||
let dto = try await perform(req, as: LibrariesResponseDTO.self)
|
let dto = try await perform(req, as: LibrariesResponseDTO.self)
|
||||||
return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
|
return dto.libraries.map { Library(id: $0.id, name: Self.decodeHTMLEntities($0.name), mediaType: $0.mediaType) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
||||||
@@ -84,6 +84,62 @@ final class ABSClient {
|
|||||||
return dto.results.map { Self.toLibraryItem(from: $0) }
|
return dto.results.map { Self.toLibraryItem(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audiobookshelf serves podcast titles straight from the RSS feed, which
|
||||||
|
/// often contains HTML entities (`ü`, `€`, `€` etc.).
|
||||||
|
/// SwiftUI renders those literally, so we decode them here once at the
|
||||||
|
/// API boundary — every title/author/chapter/library name goes through
|
||||||
|
/// this before reaching the UI.
|
||||||
|
static func decodeHTMLEntities(_ s: String) -> String {
|
||||||
|
guard s.contains("&") else { return s }
|
||||||
|
var result = s
|
||||||
|
let named: [String: String] = [
|
||||||
|
"&": "&", "<": "<", ">": ">", """: "\"",
|
||||||
|
"'": "'", " ": " ",
|
||||||
|
"€": "€", "£": "£", "¥": "¥",
|
||||||
|
"©": "©", "®": "®", "™": "™",
|
||||||
|
"…": "…", "—": "—", "–": "–",
|
||||||
|
"«": "«", "»": "»",
|
||||||
|
"„": "„", "“": "\u{201C}", "”": "\u{201D}",
|
||||||
|
"‘": "\u{2018}", "’": "\u{2019}",
|
||||||
|
"ä": "ä", "Ä": "Ä",
|
||||||
|
"ö": "ö", "Ö": "Ö",
|
||||||
|
"ü": "ü", "Ü": "Ü",
|
||||||
|
"ß": "ß",
|
||||||
|
"é": "é", "É": "É",
|
||||||
|
"è": "è", "È": "È",
|
||||||
|
"à": "à", "À": "À",
|
||||||
|
"â": "â", "Â": "Â",
|
||||||
|
"ê": "ê", "Ê": "Ê",
|
||||||
|
"î": "î", "Î": "Î",
|
||||||
|
"ô": "ô", "Ô": "Ô",
|
||||||
|
"û": "û", "Û": "Û",
|
||||||
|
"ñ": "ñ", "Ñ": "Ñ",
|
||||||
|
"ç": "ç", "Ç": "Ç",
|
||||||
|
]
|
||||||
|
for (entity, char) in named {
|
||||||
|
result = result.replacingOccurrences(of: entity, with: char)
|
||||||
|
}
|
||||||
|
// Numeric refs: Ӓ (decimal) or ꯍ (hex)
|
||||||
|
if result.contains("&#"),
|
||||||
|
let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
|
||||||
|
let nsRange = NSRange(result.startIndex..., in: result)
|
||||||
|
// Reverse so prior replacements don't shift later ranges.
|
||||||
|
let matches = regex.matches(in: result, range: nsRange).reversed()
|
||||||
|
for match in matches {
|
||||||
|
guard let full = Range(match.range, in: result),
|
||||||
|
let hexFlag = Range(match.range(at: 1), in: result),
|
||||||
|
let num = Range(match.range(at: 2), in: result) else { continue }
|
||||||
|
let isHex = !result[hexFlag].isEmpty
|
||||||
|
let numStr = String(result[num])
|
||||||
|
let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
|
||||||
|
if let code, let scalar = Unicode.Scalar(code) {
|
||||||
|
result.replaceSubrange(full, with: String(scalar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
||||||
let meta = raw.media?.metadata
|
let meta = raw.media?.metadata
|
||||||
let mediaType = raw.mediaType ?? "book"
|
let mediaType = raw.mediaType ?? "book"
|
||||||
@@ -99,16 +155,16 @@ final class ABSClient {
|
|||||||
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
||||||
var item = LibraryItem(
|
var item = LibraryItem(
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
title: meta?.title ?? "Unbekannt",
|
title: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
|
||||||
author: authorString,
|
author: decodeHTMLEntities(authorString),
|
||||||
durationSeconds: raw.media?.duration ?? 0,
|
durationSeconds: raw.media?.duration ?? 0,
|
||||||
audioFiles: files
|
audioFiles: files
|
||||||
)
|
)
|
||||||
item.mediaType = mediaType
|
item.mediaType = mediaType
|
||||||
item.description = meta?.description
|
item.description = meta?.description.map { decodeHTMLEntities($0) }
|
||||||
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
|
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
|
||||||
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
|
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
|
||||||
return Chapter(id: id, start: start, end: end, title: title)
|
return Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title))
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
@@ -129,7 +185,7 @@ final class ABSClient {
|
|||||||
)
|
)
|
||||||
return PodcastEpisode(
|
return PodcastEpisode(
|
||||||
id: ep.id,
|
id: ep.id,
|
||||||
title: ep.title ?? "Folge",
|
title: Self.decodeHTMLEntities(ep.title ?? "Folge"),
|
||||||
pubDate: ep.pubDate,
|
pubDate: ep.pubDate,
|
||||||
publishedAtMillis: ep.publishedAt,
|
publishedAtMillis: ep.publishedAt,
|
||||||
season: ep.season,
|
season: ep.season,
|
||||||
@@ -167,7 +223,8 @@ final class ABSClient {
|
|||||||
currentTime: dto.currentTime ?? 0,
|
currentTime: dto.currentTime ?? 0,
|
||||||
duration: dto.duration ?? 0,
|
duration: dto.duration ?? 0,
|
||||||
isFinished: dto.isFinished ?? false,
|
isFinished: dto.isFinished ?? false,
|
||||||
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
|
updatedAt: Self.parseLastUpdate(dto.lastUpdate),
|
||||||
|
id: dto.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +251,26 @@ final class ABSClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a media-progress entry on the server. Audiobookshelf's
|
||||||
|
/// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
|
||||||
|
/// NOT by libraryItemId (which is what PATCH uses) — this is the source
|
||||||
|
/// of the long-running "delete didn't stick" bug. 404 is treated as
|
||||||
|
/// success (entry already gone).
|
||||||
|
func deleteProgress(progressId: String) async throws {
|
||||||
|
let req = try makeRequest(
|
||||||
|
path: "/api/me/progress/\(progressId)",
|
||||||
|
method: "DELETE"
|
||||||
|
)
|
||||||
|
let (_, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw ABSClientError.httpStatus(0)
|
||||||
|
}
|
||||||
|
if http.statusCode == 404 { return }
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw ABSClientError.httpStatus(http.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fetchAllProgress() async throws -> [PlaybackProgress] {
|
func fetchAllProgress() async throws -> [PlaybackProgress] {
|
||||||
let req = try makeRequest(path: "/api/me")
|
let req = try makeRequest(path: "/api/me")
|
||||||
let dto = try await perform(req, as: MeResponseDTO.self)
|
let dto = try await perform(req, as: MeResponseDTO.self)
|
||||||
@@ -205,7 +282,8 @@ final class ABSClient {
|
|||||||
currentTime: p.currentTime ?? 0,
|
currentTime: p.currentTime ?? 0,
|
||||||
duration: p.duration ?? 0,
|
duration: p.duration ?? 0,
|
||||||
isFinished: p.isFinished ?? false,
|
isFinished: p.isFinished ?? false,
|
||||||
updatedAt: Self.parseLastUpdate(p.lastUpdate)
|
updatedAt: Self.parseLastUpdate(p.lastUpdate),
|
||||||
|
id: p.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ final class AppState {
|
|||||||
/// Used to show progress bars on covers in the library views.
|
/// Used to show progress bars on covers in the library views.
|
||||||
var progressCache: [String: PlaybackProgress] = [:]
|
var progressCache: [String: PlaybackProgress] = [:]
|
||||||
|
|
||||||
|
/// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
|
||||||
|
/// Populated whenever the user actually plays an item, persisted to disk so
|
||||||
|
/// the Home screen can show "recently played" cards on first launch without
|
||||||
|
/// any network round-trip.
|
||||||
|
var recentItemsCache: [String: LibraryItem] = [:]
|
||||||
|
|
||||||
/// Server-progress that's newer than local but we haven't applied yet because
|
/// Server-progress that's newer than local but we haven't applied yet because
|
||||||
/// playback is active/paused. Offered via alert on next play; cleared on
|
/// playback is active/paused. Offered via alert on next play; cleared on
|
||||||
/// item change.
|
/// item change.
|
||||||
@@ -70,6 +76,7 @@ final class AppState {
|
|||||||
await NotificationManager.shared.requestPermissionIfNeeded()
|
await NotificationManager.shared.requestPermissionIfNeeded()
|
||||||
#endif
|
#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 {
|
||||||
@@ -220,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()
|
||||||
@@ -244,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(
|
||||||
@@ -323,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
|
||||||
|
|||||||
@@ -146,20 +146,23 @@ final class DownloadManager: NSObject, @unchecked Sendable {
|
|||||||
let config = URLSessionConfiguration.background(withIdentifier: identifier)
|
let config = URLSessionConfiguration.background(withIdentifier: identifier)
|
||||||
config.sessionSendsLaunchEvents = true
|
config.sessionSendsLaunchEvents = true
|
||||||
config.isDiscretionary = false
|
config.isDiscretionary = false
|
||||||
// Tells iOS this is interactive (vs. opportunistic bulk transfer) so
|
|
||||||
// it scheduling gives us closer-to-foreground throughput. On `.background`
|
|
||||||
// sessions this is mostly advisory but still respected for prioritization.
|
|
||||||
config.networkServiceType = .responsiveData
|
config.networkServiceType = .responsiveData
|
||||||
// Allow up to 6 parallel HTTPS connections to the ABS host — matches the
|
|
||||||
// default for foreground sessions and helps multi-track books download
|
|
||||||
// faster instead of being serialized to 1 connection.
|
|
||||||
config.httpMaximumConnectionsPerHost = 6
|
config.httpMaximumConnectionsPerHost = 6
|
||||||
#else
|
#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
|
let config = URLSessionConfiguration.default
|
||||||
|
config.networkServiceType = .responsiveData
|
||||||
|
config.httpMaximumConnectionsPerHost = 6
|
||||||
#endif
|
#endif
|
||||||
config.allowsCellularAccess = true
|
config.allowsCellularAccess = true
|
||||||
config.waitsForConnectivity = true
|
config.waitsForConnectivity = true
|
||||||
config.timeoutIntervalForRequest = 60
|
// 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
|
config.timeoutIntervalForResource = 60 * 60 * 24 // 24h — big audiobooks
|
||||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
/// Cross-platform SwiftUI wrapper around `AVRoutePickerView`. Opens the system
|
||||||
|
/// route picker (AirPlay devices, Bluetooth, HomePod, Apple TV, etc.) when
|
||||||
|
/// tapped. Available on iOS 11+ and macOS 10.15+ via AppKit.
|
||||||
|
///
|
||||||
|
/// The actual audio routing requires `AVPlayer.allowsExternalPlayback = true`
|
||||||
|
/// — see `PlayerEngine.load(...)` where the queue player is configured.
|
||||||
|
#if os(iOS)
|
||||||
|
struct AirPlayPickerButton: UIViewRepresentable {
|
||||||
|
var tintColor: UIColor = .label
|
||||||
|
var activeTintColor: UIColor = .systemGreen
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let view = AVRoutePickerView()
|
||||||
|
view.tintColor = tintColor
|
||||||
|
view.activeTintColor = activeTintColor
|
||||||
|
view.prioritizesVideoDevices = false
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {
|
||||||
|
uiView.tintColor = tintColor
|
||||||
|
uiView.activeTintColor = activeTintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
struct AirPlayPickerButton: NSViewRepresentable {
|
||||||
|
func makeNSView(context: Context) -> AVRoutePickerView {
|
||||||
|
AVRoutePickerView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: AVRoutePickerView, context: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -3,20 +3,21 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
#if os(iOS)
|
|
||||||
@State private var splashVisible = true
|
/// Bootstrap fertig — Signal an den Splash, Phase 2 zu starten.
|
||||||
#endif
|
@State private var isReadyToDismiss = false
|
||||||
|
/// Splash hat seine Fade-Phase ausgespielt und kann aus dem View-Tree raus.
|
||||||
|
@State private var splashFinished = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
mainContent
|
mainContent
|
||||||
#if os(iOS)
|
if !splashFinished {
|
||||||
if splashVisible {
|
SplashScreenView(isReady: isReadyToDismiss) {
|
||||||
SplashView()
|
splashFinished = true
|
||||||
.zIndex(10)
|
}
|
||||||
.transition(.opacity.animation(.easeOut(duration: 0.55)))
|
.zIndex(10)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.task { await boot() }
|
.task { await boot() }
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
@@ -42,17 +43,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func boot() async {
|
private func boot() async {
|
||||||
#if os(iOS)
|
|
||||||
// Run bootstrap and minimum splash time in parallel;
|
|
||||||
// dismiss splash only after BOTH complete.
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask { await app.bootstrap() }
|
|
||||||
group.addTask { try? await Task.sleep(for: .seconds(1.2)) }
|
|
||||||
await group.waitForAll()
|
|
||||||
}
|
|
||||||
withAnimation { splashVisible = false }
|
|
||||||
#else
|
|
||||||
await app.bootstrap()
|
await app.bootstrap()
|
||||||
#endif
|
// SplashScreenView wartet selbst auf seine Mindest-Loop-Zeit, daher
|
||||||
|
// kein künstlicher Sleep mehr hier nötig. Sobald isReadyToDismiss
|
||||||
|
// gesetzt wird, fängt der Splash mit Phase 2 an und ruft am Ende
|
||||||
|
// seinen onComplete-Callback (splashFinished = true).
|
||||||
|
isReadyToDismiss = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Default landing screen showing "continue listening" rows, Netflix-style.
|
||||||
|
/// Data comes from `AppState.progressCache` (sorted by `updatedAt`) joined
|
||||||
|
/// with `AppState.recentItemsCache` for the cover/title/author metadata.
|
||||||
|
/// A row is only shown when the user actually has a library of that media
|
||||||
|
/// type — pure audiobook users don't see an empty "Podcasts" row and vice versa.
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(AppState.self) private var app
|
||||||
|
let libraries: [Library]
|
||||||
|
/// Shared with the library views via the same `libraryLayout` AppStorage
|
||||||
|
/// key — toggling grid/list in the toolbar affects both Home and Library.
|
||||||
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
|
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .grid }
|
||||||
|
|
||||||
|
private var hasBookLibrary: Bool {
|
||||||
|
libraries.contains { ($0.mediaType ?? "book") != "podcast" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasPodcastLibrary: Bool {
|
||||||
|
libraries.contains { $0.mediaType == "podcast" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything at 95 % or above is considered "done with the content" —
|
||||||
|
/// only credits left. Treats the item as finished even when the server
|
||||||
|
/// hasn't flipped `isFinished` yet.
|
||||||
|
private static let nearEndFraction: Double = 0.95
|
||||||
|
/// Minimum playback position to count as "actually started". Filters out
|
||||||
|
/// items the user only previewed for a few seconds or where the server
|
||||||
|
/// reset progress without removing the entry.
|
||||||
|
private static let minStartedSeconds: Double = 30
|
||||||
|
|
||||||
|
private func isStillListening(_ p: PlaybackProgress) -> Bool {
|
||||||
|
if p.isFinished { return false }
|
||||||
|
if p.currentTime < Self.minStartedSeconds { return false }
|
||||||
|
guard p.duration > 0 else { return true } // unknown duration → keep showing
|
||||||
|
return (p.currentTime / p.duration) < Self.nearEndFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audiobooksRecent: [LibraryItem] {
|
||||||
|
app.progressCache.values
|
||||||
|
.filter { isStillListening($0) && $0.episodeId == nil }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(20)
|
||||||
|
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var podcastsRecent: [LibraryItem] {
|
||||||
|
app.progressCache.values
|
||||||
|
.filter { isStillListening($0) && $0.episodeId != nil }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
.prefix(20)
|
||||||
|
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var anyContent: Bool {
|
||||||
|
(hasBookLibrary && !audiobooksRecent.isEmpty) || (hasPodcastLibrary && !podcastsRecent.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if anyContent {
|
||||||
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
|
if hasPodcastLibrary && !podcastsRecent.isEmpty {
|
||||||
|
sectionHeader("Zuletzt gehörte Podcasts")
|
||||||
|
sectionBody(items: podcastsRecent)
|
||||||
|
}
|
||||||
|
if hasBookLibrary && !audiobooksRecent.isEmpty {
|
||||||
|
sectionHeader("Zuletzt gehörte Hörbücher")
|
||||||
|
sectionBody(items: audiobooksRecent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Noch nichts gehört",
|
||||||
|
systemImage: "headphones",
|
||||||
|
description: Text("Sobald du ein Hörbuch oder einen Podcast startest, erscheinen die zuletzt gehörten Folgen hier.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.refreshable {
|
||||||
|
await app.refreshProgressCache()
|
||||||
|
await app.fillRecentItemsCache()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.task(id: app.progressCache.count) {
|
||||||
|
await app.fillRecentItemsCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionBody(items: [LibraryItem]) -> some View {
|
||||||
|
switch layout {
|
||||||
|
case .grid:
|
||||||
|
cardRow(items: items)
|
||||||
|
case .list:
|
||||||
|
listRows(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardRow(items: [LibraryItem]) -> some View {
|
||||||
|
HomeCardRow(items: items) { tapped in
|
||||||
|
Task { await app.playFromRecents(tapped) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func listRows(items: [LibraryItem]) -> some View {
|
||||||
|
// Reuses LibraryListRow so the Home list rows inherit the listened +
|
||||||
|
// download indicators automatically. Tapping calls playFromRecents
|
||||||
|
// (which handles podcasts correctly by looking up the episode), not
|
||||||
|
// the library's default play() path.
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
|
||||||
|
LibraryListRow(item: item, dimDownloading: false)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
Task { await app.playFromRecents(item) }
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
#endif
|
||||||
|
if idx < items.count - 1 {
|
||||||
|
Divider()
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.leading, 76)
|
||||||
|
#else
|
||||||
|
.padding(.leading, 84)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single card on the home screen: cover + progress bar + title + author.
|
||||||
|
/// Sized to roughly match the library grid covers so the visual rhythm is
|
||||||
|
/// consistent across screens.
|
||||||
|
struct HomeMediaCard: View {
|
||||||
|
@Environment(AppState.self) private var app
|
||||||
|
let item: LibraryItem
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private let cardWidth: CGFloat = 150
|
||||||
|
#else
|
||||||
|
private let cardWidth: CGFloat = 190
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
cover
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
if !isListened {
|
||||||
|
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
.padding(.bottom, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text(item.author)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(width: cardWidth)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var downloadBadge: some View {
|
||||||
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
|
switch state {
|
||||||
|
case .downloaded:
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
case .downloading(let p):
|
||||||
|
DownloadProgressRing(progress: p)
|
||||||
|
#if os(iOS)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
#else
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
#endif
|
||||||
|
case .failed:
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .red)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
case .notDownloaded:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cover: some View {
|
||||||
|
Rectangle()
|
||||||
|
#if os(iOS)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
#else
|
||||||
|
.fill(AnyShapeStyle(.quaternary))
|
||||||
|
#endif
|
||||||
|
// Explicit square frame — without this the Rectangle inside a
|
||||||
|
// LazyHStack stretches to fill the available vertical space and
|
||||||
|
// pushes siblings way off to the right.
|
||||||
|
.frame(width: cardWidth, height: cardWidth)
|
||||||
|
.overlay {
|
||||||
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().scaledToFit()
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
#if os(macOS)
|
||||||
|
.controlSize(.small)
|
||||||
|
#endif
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontally scrollable card row that measures its own content vs. the
|
||||||
|
/// available viewport. Scrolling is only enabled when items actually overflow,
|
||||||
|
/// and chevron buttons appear on macOS hover so the user knows there's more
|
||||||
|
/// off-screen and can page through it without trackpad/scrollwheel.
|
||||||
|
private struct HomeCardRow: View {
|
||||||
|
let items: [LibraryItem]
|
||||||
|
let onTap: (LibraryItem) -> Void
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private let cardWidth: CGFloat = 150
|
||||||
|
#else
|
||||||
|
private let cardWidth: CGFloat = 190
|
||||||
|
#endif
|
||||||
|
private let spacing: CGFloat = 14
|
||||||
|
private let horizontalPadding: CGFloat = 20
|
||||||
|
|
||||||
|
@State private var viewportWidth: CGFloat = 0
|
||||||
|
@State private var scrollIndex: Int = 0
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var isHovering = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var contentWidth: CGFloat {
|
||||||
|
guard !items.isEmpty else { return 0 }
|
||||||
|
return CGFloat(items.count) * cardWidth
|
||||||
|
+ CGFloat(items.count - 1) * spacing
|
||||||
|
+ horizontalPadding * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canScroll: Bool { contentWidth > viewportWidth + 1 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: spacing) {
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
|
HomeMediaCard(item: item) { onTap(item) }
|
||||||
|
.id(item.syncKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
}
|
||||||
|
.scrollDisabled(!canScroll)
|
||||||
|
.onAppear { viewportWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, newValue in viewportWidth = newValue }
|
||||||
|
#if os(macOS)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
chevron(systemName: "chevron.left", visible: isHovering && canScroll) {
|
||||||
|
scrollBy(-1, proxy: proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
chevron(systemName: "chevron.right", visible: isHovering && canScroll) {
|
||||||
|
scrollBy(1, proxy: proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onHover { isHovering = $0 }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GeometryReader has no intrinsic size — give the row an explicit height
|
||||||
|
// matching cardWidth (square cover) + ~50pt for title/author.
|
||||||
|
.frame(height: cardWidth + 52)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private func chevron(systemName: String, visible: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(.black.opacity(0.55)))
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.opacity(visible ? 1 : 0)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pages by roughly one viewport in the requested direction. The actual
|
||||||
|
/// scroll position is tracked via `scrollIndex` — the index of the card we
|
||||||
|
/// want pinned to the leading edge after the scroll.
|
||||||
|
private func scrollBy(_ direction: Int, proxy: ScrollViewProxy) {
|
||||||
|
let cardsPerView = max(1, Int(viewportWidth / (cardWidth + spacing)))
|
||||||
|
let next = max(0, min(items.count - 1, scrollIndex + direction * cardsPerView))
|
||||||
|
scrollIndex = next
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
proxy.scrollTo(items[next].syncKey, anchor: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -22,7 +22,11 @@ struct LibraryGridView: View {
|
|||||||
private var gridContent: some View {
|
private var gridContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
ForEach(items) { item in
|
// Identify by syncKey, not by `id`: a synthetic episode item
|
||||||
|
// shares the podcast container's `id`, so the default
|
||||||
|
// `Identifiable`-based ForEach would collide if both appear in
|
||||||
|
// a single search result list.
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,28 @@ 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 !(dimDownloading && isActivelyDownloading) {
|
if isListened {
|
||||||
downloadBadge.padding(4)
|
listenedBadge.padding(4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.overlay(alignment: .bottomTrailing) {
|
||||||
.overlay {
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
|
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
|
||||||
LargeDownloadOverlay(progress: p)
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
|
||||||
|
LargeDownloadOverlay(progress: p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isListened {
|
||||||
|
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
.padding(.bottom, 3)
|
||||||
}
|
}
|
||||||
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
|
||||||
.padding(.horizontal, 3)
|
|
||||||
.padding(.bottom, 3)
|
|
||||||
}
|
}
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -56,7 +63,7 @@ struct LibraryItemCell: View {
|
|||||||
}
|
}
|
||||||
// Ensure the cell fills its full grid column width
|
// Ensure the cell fills its full grid column width
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isActivelyDownloading: Bool {
|
private var isActivelyDownloading: Bool {
|
||||||
@@ -64,6 +71,14 @@ struct LibraryItemCell: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Already listened" — server's authoritative finished flag. The
|
||||||
|
/// Audiobookshelf server flips this once `markAsFinishedPercentComplete`
|
||||||
|
/// (set per library by the admin) is crossed, so we don't need our own
|
||||||
|
/// threshold here.
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cover
|
// MARK: - Cover
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
@@ -122,23 +137,37 @@ struct LibraryItemCell: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download badge
|
// MARK: - Badges
|
||||||
|
|
||||||
|
/// Top-right corner: green checkmark if the server has marked this item
|
||||||
|
/// as finished. Surfaces the "I've listened to this" status that used to
|
||||||
|
/// be tangled with the download checkmark.
|
||||||
|
@ViewBuilder
|
||||||
|
private var listenedBadge: some View {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom-right corner: download status. Was previously a checkmark in the
|
||||||
|
/// top-right; the checkmark now signals "listened", and the download icon
|
||||||
|
/// is a downward arrow so the two states are visually unambiguous.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var downloadBadge: some View {
|
private var downloadBadge: some View {
|
||||||
let state = app.downloads.state(for: item.downloadKey)
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
switch state {
|
switch state {
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.shadow(radius: 2)
|
.shadow(radius: 2)
|
||||||
case .downloading(let p):
|
case .downloading(let p):
|
||||||
DownloadProgressRing(progress: p)
|
DownloadProgressRing(progress: p)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.frame(width: 26, height: 26)
|
.frame(width: 22, height: 22)
|
||||||
#else
|
#else
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 26, height: 26)
|
||||||
#endif
|
#endif
|
||||||
case .failed:
|
case .failed:
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
@@ -150,37 +179,6 @@ struct LibraryItemCell: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Context menu
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var downloadMenuItems: some View {
|
|
||||||
let key = item.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
if item.isPodcastContainer {
|
|
||||||
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
|
||||||
} else {
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button {
|
|
||||||
app.downloads.startDownload(item: item)
|
|
||||||
} label: {
|
|
||||||
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button {
|
|
||||||
app.downloads.cancel(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shared components
|
// MARK: - Shared components
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ struct LibraryListView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
List {
|
List {
|
||||||
ForEach(items) { item in
|
// Identify by syncKey — see LibraryGridView comment.
|
||||||
|
ForEach(items, id: \.syncKey) { item in
|
||||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
@@ -32,7 +33,7 @@ struct LibraryListView: View {
|
|||||||
#else
|
#else
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
|
||||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
@@ -70,7 +71,7 @@ struct LibraryListRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
|
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
|
||||||
if fraction > 0 {
|
if fraction > 0 && !isListened {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||||
@@ -100,6 +101,11 @@ struct LibraryListRow: View {
|
|||||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
if !(dimDownloading && isActivelyDownloading) {
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
downloadStatus
|
downloadStatus
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -111,7 +117,7 @@ struct LibraryListRow: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
#endif
|
#endif
|
||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isActivelyDownloading: Bool {
|
private var isActivelyDownloading: Bool {
|
||||||
@@ -119,6 +125,10 @@ struct LibraryListRow: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
Group {
|
Group {
|
||||||
if let url = app.client.coverURL(itemId: item.id) {
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
@@ -153,7 +163,9 @@ struct LibraryListRow: View {
|
|||||||
let state = app.downloads.state(for: item.downloadKey)
|
let state = app.downloads.state(for: item.downloadKey)
|
||||||
switch state {
|
switch state {
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Down-arrow now signals "downloaded"; the checkmark is reserved
|
||||||
|
// for the listened indicator that sits next to it.
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
case .downloading(let p):
|
case .downloading(let p):
|
||||||
@@ -172,32 +184,6 @@ struct LibraryListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var downloadMenuItems: some View {
|
|
||||||
let key = item.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
if item.isPodcastContainer {
|
|
||||||
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
|
||||||
} else {
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button { app.downloads.startDownload(item: item) } label: {
|
|
||||||
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button { app.downloads.cancel(downloadKey: key) } label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func formatDuration(_ seconds: Double) -> String {
|
private func formatDuration(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct LoginView: View {
|
|||||||
Image(systemName: "books.vertical.fill")
|
Image(systemName: "books.vertical.fill")
|
||||||
.font(.system(size: 52, weight: .regular))
|
.font(.system(size: 52, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("ABS Client")
|
Text("Nook")
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
||||||
@@ -124,7 +124,7 @@ struct LoginView: View {
|
|||||||
Image(systemName: "books.vertical.fill")
|
Image(systemName: "books.vertical.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
Text("ABS Client")
|
Text("Nook")
|
||||||
.font(.largeTitle).bold()
|
.font(.largeTitle).bold()
|
||||||
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
Text("Verbinde dich mit deinem Audiobookshelf-Server")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum LibraryFilter: Hashable {
|
enum LibraryFilter: Hashable {
|
||||||
|
case home
|
||||||
case library(String)
|
case library(String)
|
||||||
case downloaded
|
case downloaded
|
||||||
case history
|
case history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One indexed (podcast, episode) pair used to make episodes searchable from the
|
||||||
|
/// library overview — without having to dive into each show first.
|
||||||
|
struct PodcastEpisodeMatch: Hashable {
|
||||||
|
let podcast: LibraryItem
|
||||||
|
let episode: PodcastEpisode
|
||||||
|
|
||||||
|
var syncKey: String { "\(podcast.id)|\(episode.id)" }
|
||||||
|
|
||||||
|
/// Synthesizes a `LibraryItem` so the cell/list views can render this
|
||||||
|
/// episode the same way they render any other item: episode title up top,
|
||||||
|
/// podcast name as the subtitle, podcast cover as the artwork.
|
||||||
|
var asLibraryItem: LibraryItem {
|
||||||
|
var item = LibraryItem(
|
||||||
|
id: podcast.id,
|
||||||
|
title: episode.title,
|
||||||
|
author: podcast.title,
|
||||||
|
durationSeconds: episode.durationSeconds > 0
|
||||||
|
? episode.durationSeconds
|
||||||
|
: episode.audioFile.durationSeconds,
|
||||||
|
audioFiles: [episode.audioFile]
|
||||||
|
)
|
||||||
|
item.mediaType = "podcast"
|
||||||
|
item.episodeId = episode.id
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LibraryViewModel {
|
final class LibraryViewModel {
|
||||||
@@ -15,21 +43,93 @@ final class LibraryViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var selection: LibraryFilter?
|
var selection: LibraryFilter?
|
||||||
|
|
||||||
|
/// Episode index per library, populated lazily the first time the user
|
||||||
|
/// types in the search bar of a podcast library. Episodes aren't part of
|
||||||
|
/// `items` (those only hold podcast containers) so we have to load them
|
||||||
|
/// explicitly. Kept in memory only — cleared whenever the library reloads.
|
||||||
|
var episodeIndex: [String: [PodcastEpisodeMatch]] = [:]
|
||||||
|
/// Library IDs currently being indexed. Prevents duplicate in-flight loads
|
||||||
|
/// when the user types rapidly while the first request is still running.
|
||||||
|
var episodeIndexLoading: Set<String> = []
|
||||||
|
|
||||||
|
var isLoadingEpisodeIndex: Bool {
|
||||||
|
!episodeIndexLoading.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
func loadLibraries(client: ABSClient) async {
|
func loadLibraries(client: ABSClient) async {
|
||||||
do {
|
do {
|
||||||
libraries = try await client.fetchLibraries()
|
libraries = try await client.fetchLibraries()
|
||||||
if selection == nil, let first = libraries.first {
|
// Home is the new default landing screen — first library is only
|
||||||
selection = .library(first.id)
|
// used as fallback when there are zero libraries.
|
||||||
|
if selection == nil {
|
||||||
|
selection = .home
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches every podcast's episodes in the given library and caches them
|
||||||
|
/// flat so the search field can match against episode titles too. No-ops
|
||||||
|
/// when the index is already populated for this library.
|
||||||
|
func loadEpisodeIndex(libraryId: String, client: ABSClient) async {
|
||||||
|
guard episodeIndex[libraryId] == nil,
|
||||||
|
!episodeIndexLoading.contains(libraryId) else { return }
|
||||||
|
let podcasts = items.filter { $0.isPodcastContainer }
|
||||||
|
guard !podcasts.isEmpty else {
|
||||||
|
episodeIndex[libraryId] = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
episodeIndexLoading.insert(libraryId)
|
||||||
|
defer { episodeIndexLoading.remove(libraryId) }
|
||||||
|
|
||||||
|
var collected: [PodcastEpisodeMatch] = []
|
||||||
|
var anySuccess = false
|
||||||
|
for p in podcasts {
|
||||||
|
// Bail out if the user already navigated away — keeps a slow scan
|
||||||
|
// from blocking later searches.
|
||||||
|
guard selection == .library(libraryId) else { return }
|
||||||
|
do {
|
||||||
|
let (_, eps) = try await client.fetchEpisodes(podcastItemId: p.id)
|
||||||
|
anySuccess = true
|
||||||
|
for ep in eps {
|
||||||
|
collected.append(PodcastEpisodeMatch(podcast: p, episode: ep))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Individual podcast fetch errors are ignored — the rest of
|
||||||
|
// the library is still searchable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bei totalem Offline-Failure (kein einziger Fetch erfolgreich) NICHT
|
||||||
|
// den Cache mit `[]` setzen — sonst wäre der Index "geladen aber leer"
|
||||||
|
// und ein erneuter Suchversuch würde nichts mehr tun. Nil-lassen
|
||||||
|
// erlaubt Retry beim nächsten Tippen.
|
||||||
|
if anySuccess {
|
||||||
|
episodeIndex[libraryId] = collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func episodeMatch(syncKey: String, in libraryId: String) -> PodcastEpisodeMatch? {
|
||||||
|
episodeIndex[libraryId]?.first(where: { $0.syncKey == syncKey })
|
||||||
|
}
|
||||||
|
|
||||||
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
||||||
guard let selection else { return }
|
guard let selection else { return }
|
||||||
switch selection {
|
switch selection {
|
||||||
|
case .home:
|
||||||
|
// HomeView reads progressCache + recentItemsCache directly; no
|
||||||
|
// library items to load into vm.items.
|
||||||
|
items = []
|
||||||
|
errorMessage = nil
|
||||||
case .library(let id):
|
case .library(let id):
|
||||||
|
// Reload of items invalidates the episode index — episodes are
|
||||||
|
// tied to the previous snapshot of `items`. Auch das Loading-Flag
|
||||||
|
// löschen damit ein nach-dem-Refresh neu getippter Suchbegriff
|
||||||
|
// sofort einen Neu-Aufbau triggern kann (eine ggf. noch laufende
|
||||||
|
// alte loadEpisodeIndex-Schleife läuft zu Ende und das Resultat
|
||||||
|
// wird vom nächsten Trigger ohnehin überschrieben).
|
||||||
|
episodeIndex.removeValue(forKey: id)
|
||||||
|
episodeIndexLoading.remove(id)
|
||||||
do {
|
do {
|
||||||
items = try await client.fetchItems(libraryId: id)
|
items = try await client.fetchItems(libraryId: id)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -77,6 +177,7 @@ struct MainView: View {
|
|||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
@State private var vm = LibraryViewModel()
|
@State private var vm = LibraryViewModel()
|
||||||
@State private var navPath: [LibraryItem] = []
|
@State private var navPath: [LibraryItem] = []
|
||||||
|
@State private var librarySearchText: String = ""
|
||||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||||
@State private var showFullHistory: Bool = false
|
@State private var showFullHistory: Bool = false
|
||||||
@@ -95,8 +196,19 @@ struct MainView: View {
|
|||||||
.task { await loadAll() }
|
.task { await loadAll() }
|
||||||
.onChange(of: vm.selection) { _, _ in
|
.onChange(of: vm.selection) { _, _ in
|
||||||
navPath.removeAll()
|
navPath.removeAll()
|
||||||
|
librarySearchText = ""
|
||||||
Task { await loadAll() }
|
Task { await loadAll() }
|
||||||
}
|
}
|
||||||
|
.onChange(of: librarySearchText) { _, newValue in
|
||||||
|
// First keystroke in a podcast library kicks off the episode
|
||||||
|
// index load (cached after that, so subsequent searches are
|
||||||
|
// instant). Skipped for audiobook libraries — those don't
|
||||||
|
// have episodes to scan.
|
||||||
|
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
currentLibraryIsPodcast,
|
||||||
|
let libId = currentLibraryId else { return }
|
||||||
|
Task { await vm.loadEpisodeIndex(libraryId: libId, client: app.client) }
|
||||||
|
}
|
||||||
.onChange(of: app.downloads.pendingItems.count) { _, _ in
|
.onChange(of: app.downloads.pendingItems.count) { _, _ in
|
||||||
if vm.selection == .downloaded {
|
if vm.selection == .downloaded {
|
||||||
Task { await vm.loadItems(client: app.client, downloads: app.downloads) }
|
Task { await vm.loadItems(client: app.client, downloads: app.downloads) }
|
||||||
@@ -207,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")
|
||||||
@@ -374,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
|
||||||
libraryContent
|
if vm.selection == .home {
|
||||||
|
HomeView(libraries: vm.libraries)
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
@@ -397,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 {
|
||||||
|
ContentUnavailableView.search(text: librarySearchText)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
libraryGridOrList.transition(.opacity)
|
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
|
||||||
@@ -411,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)
|
||||||
@@ -453,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")
|
||||||
@@ -494,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"
|
||||||
@@ -505,6 +736,8 @@ struct MainView: View {
|
|||||||
|
|
||||||
private var currentTitle: String {
|
private var currentTitle: String {
|
||||||
switch vm.selection {
|
switch vm.selection {
|
||||||
|
case .home:
|
||||||
|
return "Home"
|
||||||
case .library(let id):
|
case .library(let id):
|
||||||
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Unified right-click / long-press menu for any `LibraryItem` shown on the
|
||||||
|
/// Home, Library, or Heruntergeladen screens. Includes both download actions
|
||||||
|
/// (start / cancel / delete files) AND a "Fortschritt entfernen" action when
|
||||||
|
/// the user has any server-side progress on the item.
|
||||||
|
///
|
||||||
|
/// The "Fortschritt entfernen" row was previously Home-only; the download
|
||||||
|
/// rows were previously Library-only — combining them in one place means the
|
||||||
|
/// same menu shows up regardless of which screen the user is on.
|
||||||
|
@ViewBuilder
|
||||||
|
func mediaItemContextMenu(for item: LibraryItem, app: AppState) -> some View {
|
||||||
|
let key = item.downloadKey
|
||||||
|
let state = app.downloads.state(for: key)
|
||||||
|
if item.isPodcastContainer {
|
||||||
|
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
||||||
|
} else {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded, .failed:
|
||||||
|
Button {
|
||||||
|
app.downloads.startDownload(item: item)
|
||||||
|
} label: {
|
||||||
|
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
case .downloading:
|
||||||
|
Button {
|
||||||
|
app.downloads.cancel(downloadKey: key)
|
||||||
|
} label: {
|
||||||
|
Label("Download abbrechen", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
case .downloaded:
|
||||||
|
Button(role: .destructive) {
|
||||||
|
app.downloads.delete(downloadKey: key)
|
||||||
|
} label: {
|
||||||
|
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.progressCache[item.syncKey] != nil {
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await app.removeProgress(itemId: item.id, episodeId: item.episodeId) }
|
||||||
|
} label: {
|
||||||
|
Label("Fortschritt entfernen", systemImage: "arrow.uturn.backward.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,10 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
rateMenu
|
rateMenu
|
||||||
|
|
||||||
|
AirPlayPickerButton()
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.disabled(!app.player.isReady)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
app.stopPlayback()
|
app.stopPlayback()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -150,6 +154,11 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
sleepMenu
|
sleepMenu
|
||||||
|
|
||||||
|
AirPlayPickerButton()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.disabled(!app.player.isReady)
|
||||||
|
.help("Auf anderem Gerät abspielen (AirPlay)")
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
if historyEnabled {
|
if historyEnabled {
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ struct PodcastDetailView: View {
|
|||||||
@State private var podcastDetail: LibraryItem?
|
@State private var podcastDetail: LibraryItem?
|
||||||
@State private var isLoading: Bool = true
|
@State private var isLoading: Bool = true
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
/// Bevorzugt die nach `fetchEpisodes` aufgefrischte Detail-Version
|
||||||
|
/// des Podcasts (mit Cover, Description etc.), fällt auf den initial
|
||||||
|
/// übergebenen Container zurück solange der Fetch noch läuft oder
|
||||||
|
/// fehlgeschlagen ist.
|
||||||
|
private var resolvedPodcast: LibraryItem { podcastDetail ?? podcast }
|
||||||
|
|
||||||
|
private var filteredEpisodes: [PodcastEpisode] {
|
||||||
|
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !q.isEmpty else { return episodes }
|
||||||
|
return episodes.filter { ep in
|
||||||
|
ep.title.localizedCaseInsensitiveContains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -15,10 +30,11 @@ struct PodcastDetailView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.navigationTitle(podcastDetail?.title ?? podcast.title)
|
.navigationTitle(resolvedPodcast.title)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
.searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen")
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +75,16 @@ struct PodcastDetailView: View {
|
|||||||
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
||||||
} else if episodes.isEmpty {
|
} else if episodes.isEmpty {
|
||||||
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
||||||
|
} else if filteredEpisodes.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
} else {
|
} else {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
List {
|
List {
|
||||||
ForEach(episodes, id: \.id) { ep in
|
ForEach(filteredEpisodes, id: \.id) { ep in
|
||||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,13 +92,13 @@ struct PodcastDetailView: View {
|
|||||||
#else
|
#else
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
|
ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in
|
||||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||||
}
|
}
|
||||||
if idx < episodes.count - 1 {
|
if idx < filteredEpisodes.count - 1 {
|
||||||
Divider().padding(.leading, 16)
|
Divider().padding(.leading, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +140,10 @@ private struct EpisodeRow: View {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isListened: Bool {
|
||||||
|
app.progressCache[syntheticItem.syncKey]?.isFinished ?? false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "play.circle.fill")
|
Image(systemName: "play.circle.fill")
|
||||||
@@ -163,7 +185,7 @@ private struct EpisodeRow: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
||||||
if frac > 0 {
|
if frac > 0 && !isListened {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||||
@@ -179,6 +201,14 @@ private struct EpisodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
if isListened {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .green)
|
||||||
|
.font(.title3)
|
||||||
|
#if os(macOS)
|
||||||
|
.padding(.top, 4)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
downloadButton
|
downloadButton
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(width: 32)
|
.frame(width: 32)
|
||||||
@@ -191,7 +221,7 @@ private struct EpisodeRow: View {
|
|||||||
#else
|
#else
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
#endif
|
#endif
|
||||||
.contextMenu { contextMenuItems }
|
.contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -216,7 +246,9 @@ private struct EpisodeRow: View {
|
|||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Down-arrow = downloaded; checkmark is reserved for the listened
|
||||||
|
// indicator that may sit next to this in the row trailing area.
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.foregroundStyle(.white, .green)
|
.foregroundStyle(.white, .green)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
case .failed(let msg):
|
case .failed(let msg):
|
||||||
@@ -232,32 +264,6 @@ private struct EpisodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contextMenuItems: some View {
|
|
||||||
let key = syntheticItem.downloadKey
|
|
||||||
let state = app.downloads.state(for: key)
|
|
||||||
switch state {
|
|
||||||
case .notDownloaded, .failed:
|
|
||||||
Button {
|
|
||||||
app.downloads.startDownload(item: syntheticItem)
|
|
||||||
} label: {
|
|
||||||
Label("Folge herunterladen", systemImage: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
case .downloading:
|
|
||||||
Button {
|
|
||||||
app.downloads.cancel(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
|
||||||
}
|
|
||||||
case .downloaded:
|
|
||||||
Button(role: .destructive) {
|
|
||||||
app.downloads.delete(downloadKey: key)
|
|
||||||
} label: {
|
|
||||||
Label("Heruntergeladene Folge löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatDuration(_ seconds: Double) -> String {
|
private func formatDuration(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||||
let total = Int(seconds)
|
let total = Int(seconds)
|
||||||
|
|||||||
@@ -1,95 +1,306 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct SplashView: View {
|
// MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset)
|
||||||
@State private var appeared = false
|
|
||||||
|
extension Image {
|
||||||
|
/// Lädt das echte AppIcon zur Laufzeit:
|
||||||
|
/// iOS via `CFBundleIcons` aus der Info.plist, macOS via
|
||||||
|
/// `NSApp.applicationIconImage`. Fällt auf ein SF-Symbol zurück wenn die
|
||||||
|
/// Plattform-spezifische Auflösung leer ist (z.B. Preview ohne Bundle).
|
||||||
|
static var appIcon: Image {
|
||||||
|
#if os(iOS)
|
||||||
|
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
|
||||||
|
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||||
|
let files = primary["CFBundleIconFiles"] as? [String],
|
||||||
|
let name = files.last,
|
||||||
|
let ui = UIImage(named: name) {
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
}
|
||||||
|
return Image(systemName: "books.vertical.fill")
|
||||||
|
#else
|
||||||
|
if let ns = NSApp?.applicationIconImage {
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
}
|
||||||
|
return Image(systemName: "books.vertical.fill")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color-Helper für hellere/dunklere Akzent-Töne
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
func lighter(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: amount) }
|
||||||
|
func darker(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: -amount) }
|
||||||
|
|
||||||
|
private func adjustBrightness(by amount: CGFloat) -> Color {
|
||||||
|
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
#if os(iOS)
|
||||||
|
UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||||
|
#else
|
||||||
|
(NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self))
|
||||||
|
.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||||
|
#endif
|
||||||
|
return Color(hue: Double(h),
|
||||||
|
saturation: Double(s),
|
||||||
|
brightness: Double(min(1.0, max(0.0, b + amount))),
|
||||||
|
opacity: Double(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Partikel-Konfiguration
|
||||||
|
|
||||||
|
private struct ParticleConfig: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let startAngle: Double // 0…2π — Position auf der Umlaufbahn bei t=0
|
||||||
|
let orbitRadius: CGFloat // 80…150pt — Radius der Umlaufbahn
|
||||||
|
let size: CGFloat // 4 / 7 / 10pt
|
||||||
|
let appearDelay: Double // 0…0.9s — Stagger-Fade-In
|
||||||
|
let angularVelocity: Double // rad/s — Umlaufgeschwindigkeit, Vorzeichen = Richtung
|
||||||
|
let radialWobble: CGFloat // 0…5pt — sanftes Atmen des Radius
|
||||||
|
let wobbleFrequency: Double // 0.4…0.8 Hz
|
||||||
|
let wobblePhase: Double // 0…2π
|
||||||
|
let shade: ParticleShade
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ParticleShade {
|
||||||
|
case light, accent, dark
|
||||||
|
|
||||||
|
func color(base: Color) -> Color {
|
||||||
|
switch self {
|
||||||
|
case .light: return base.lighter(by: 0.25)
|
||||||
|
case .accent: return base
|
||||||
|
case .dark: return base.darker(by: 0.20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acht Partikel auf unterschiedlichen Umlaufbahnen. Reproduzierbar bei
|
||||||
|
/// jedem App-Start (kein Random). Drei Bahnen-Schichten (innen/mitte/außen)
|
||||||
|
/// plus gemischte Drehrichtungen (Vorzeichen von `angularVelocity`) damit
|
||||||
|
/// das Bild nicht wie ein gleichförmiges Karussell wirkt.
|
||||||
|
private let particleConfigs: [ParticleConfig] = [
|
||||||
|
.init(id: 0, startAngle: 0.45, orbitRadius: 88, size: 7, appearDelay: 0.05, angularVelocity: 0.32, radialWobble: 3, wobbleFrequency: 0.7, wobblePhase: 0.3, shade: .light),
|
||||||
|
.init(id: 1, startAngle: 1.20, orbitRadius: 130, size: 4, appearDelay: 0.30, angularVelocity: -0.22, radialWobble: 4, wobbleFrequency: 0.5, wobblePhase: 1.1, shade: .accent),
|
||||||
|
.init(id: 2, startAngle: 2.00, orbitRadius: 85, size: 10, appearDelay: 0.55, angularVelocity: 0.18, radialWobble: 2, wobbleFrequency: 0.4, wobblePhase: 2.4, shade: .dark),
|
||||||
|
.init(id: 3, startAngle: 2.80, orbitRadius: 115, size: 7, appearDelay: 0.20, angularVelocity: 0.28, radialWobble: 5, wobbleFrequency: 0.6, wobblePhase: 0.8, shade: .accent),
|
||||||
|
.init(id: 4, startAngle: 3.65, orbitRadius: 95, size: 4, appearDelay: 0.70, angularVelocity: -0.35, radialWobble: 3, wobbleFrequency: 0.55, wobblePhase: 3.2, shade: .light),
|
||||||
|
.init(id: 5, startAngle: 4.40, orbitRadius: 140, size: 10, appearDelay: 0.10, angularVelocity: -0.16, radialWobble: 4, wobbleFrequency: 0.35, wobblePhase: 4.0, shade: .dark),
|
||||||
|
.init(id: 6, startAngle: 5.20, orbitRadius: 100, size: 7, appearDelay: 0.45, angularVelocity: 0.24, radialWobble: 3, wobbleFrequency: 0.65, wobblePhase: 5.1, shade: .light),
|
||||||
|
.init(id: 7, startAngle: 5.80, orbitRadius: 120, size: 4, appearDelay: 0.85, angularVelocity: -0.30, radialWobble: 5, wobbleFrequency: 0.45, wobblePhase: 1.6, shade: .accent),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - SplashScreenView
|
||||||
|
|
||||||
|
/// Vierphasiger Splash:
|
||||||
|
/// 1. Partikel erscheinen gestaffelt um das Icon (~1.2s)
|
||||||
|
/// 2. Partikel driften organisch in Loop bis `isReady` true wird (min 0.5s)
|
||||||
|
/// 3. Alle Partikel werden ins Icon gesogen (0.6s) mit kleinem Icon-Wobble
|
||||||
|
/// 4. Flash-Ring + Icon-Scale-Pulse (0.4s, iOS auch Haptic)
|
||||||
|
/// 5. Fade-Out (0.4s) → `onComplete()`
|
||||||
|
struct SplashScreenView: View {
|
||||||
|
let isReady: Bool
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
private enum Phase { case appearing, idle, attracting, flash, fading }
|
||||||
|
|
||||||
|
@State private var phase: Phase = .appearing
|
||||||
|
@State private var particlesShown: Bool = false
|
||||||
|
@State private var particlesAttracted: Bool = false
|
||||||
|
@State private var iconScale: CGFloat = 1.0
|
||||||
|
@State private var iconWobble: CGFloat = 1.0
|
||||||
|
@State private var iconBrightness: Double = 0
|
||||||
|
@State private var ringScale: CGFloat = 1.0
|
||||||
|
@State private var ringOpacity: Double = 0
|
||||||
|
@State private var splashOpacity: Double = 1.0
|
||||||
|
/// Spiegel von `isReady` mit `@State`-Storage. Nötig weil die `.task`-
|
||||||
|
/// Closure die View-Struct beim ersten Erscheinen capturet und das
|
||||||
|
/// `let isReady` aus dem alten Snapshot liest — das bleibt forever false
|
||||||
|
/// auch wenn der Parent neu rendert. `@State`-Storage dagegen ist
|
||||||
|
/// shared über Reconstructions hinweg, also sieht der laufende Task die
|
||||||
|
/// Aktualisierung sobald `onChange` sie hier reinpiped.
|
||||||
|
@State private var readySignaled: Bool = false
|
||||||
|
|
||||||
|
/// Einmalig festgelegt — Basis für die TimelineView-Zeit. Als `@State`,
|
||||||
|
/// damit der Wert bei View-Re-Renders aus ContentView nicht versehentlich
|
||||||
|
/// auf „jetzt" zurückgesetzt wird und die Drift-Bewegung dadurch springt.
|
||||||
|
@State private var referenceDate: Date = .init()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
#if os(iOS)
|
background
|
||||||
Color(.systemBackground).ignoresSafeArea()
|
ZStack {
|
||||||
#else
|
particleLayer
|
||||||
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
|
iconLayer
|
||||||
#endif
|
flashRing
|
||||||
|
|
||||||
VStack(spacing: 36) {
|
|
||||||
|
|
||||||
// ── Animated icon ────────────────────────────────────────
|
|
||||||
ZStack {
|
|
||||||
// Outer glow pulse
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(0.15))
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.scaleEffect(appeared ? 1.0 : 0.2)
|
|
||||||
.blur(radius: appeared ? 12 : 40)
|
|
||||||
.animation(.easeOut(duration: 1.1), value: appeared)
|
|
||||||
|
|
||||||
// Ring border
|
|
||||||
Circle()
|
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
||||||
.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 ─────────────────────────────────────────────────
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Text("ABS Client")
|
|
||||||
.font(.title2.bold())
|
|
||||||
.opacity(appeared ? 1 : 0)
|
|
||||||
.offset(y: appeared ? 0 : 18)
|
|
||||||
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
|
|
||||||
|
|
||||||
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 ────────────────────────────────
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
LoadingDots()
|
|
||||||
.opacity(appeared ? 1 : 0)
|
|
||||||
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
|
|
||||||
.padding(.bottom, 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear { appeared = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LoadingDots: View {
|
|
||||||
@State private var phase: Int = 0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 7) {
|
|
||||||
ForEach(0..<3, id: \.self) { i in
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
|
|
||||||
.frame(width: 7, height: 7)
|
|
||||||
.scaleEffect(phase == i ? 1.25 : 1.0)
|
|
||||||
.animation(.easeInOut(duration: 0.35), value: phase)
|
|
||||||
}
|
}
|
||||||
|
.frame(width: 320, height: 320)
|
||||||
}
|
}
|
||||||
|
.opacity(splashOpacity)
|
||||||
|
.task { await runSplashAnimation() }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
|
// Falls der Parent isReady bereits beim ersten Render gesetzt hat
|
||||||
phase = (phase + 1) % 3
|
// (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)
|
||||||
|
Color(.systemBackground).ignoresSafeArea()
|
||||||
|
#else
|
||||||
|
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 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 {
|
||||||
|
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()
|
||||||
|
.fill(cfg.shade.color(base: Color("AccentColor")))
|
||||||
|
.frame(width: cfg.size, height: cfg.size)
|
||||||
|
.opacity(particlesShown ? 1.0 : 0.0)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(x: position.x, y: position.y)
|
||||||
|
// Stagger-Fade-In: jeder Partikel mit eigener Verzögerung
|
||||||
|
.animation(.easeOut(duration: 0.5).delay(cfg.appearDelay), value: particlesShown)
|
||||||
|
// Attract: 0.6s easeIn ins Zentrum, gleichzeitig auf scale=0
|
||||||
|
.animation(.easeIn(duration: 0.6), value: particlesAttracted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func idlePosition(for cfg: ParticleConfig, t: TimeInterval) -> CGPoint {
|
||||||
|
// Echte Umlaufbahn: Winkel wächst mit t (Vorzeichen = Drehrichtung).
|
||||||
|
// Radius "atmet" leicht über sinusförmiges radialWobble — damit die
|
||||||
|
// Bahn nicht maschinell perfekt-kreisförmig wirkt.
|
||||||
|
let angle = cfg.startAngle + t * cfg.angularVelocity
|
||||||
|
let wobble = sin(t * cfg.wobbleFrequency + cfg.wobblePhase) * Double(cfg.radialWobble)
|
||||||
|
let radius = Double(cfg.orbitRadius) + wobble
|
||||||
|
return CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Animation-Orchestrierung
|
||||||
|
|
||||||
|
private func runSplashAnimation() async {
|
||||||
|
// Phase 1a — Partikel erscheinen gestaffelt
|
||||||
|
particlesShown = true
|
||||||
|
// Warten bis alle Partikel sichtbar sind (max appearDelay 0.85 + fade 0.5)
|
||||||
|
try? await Task.sleep(for: .milliseconds(1400))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
phase = .idle
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — Sog ins Icon-Zentrum
|
||||||
|
phase = .attracting
|
||||||
|
withAnimation(.easeIn(duration: 0.6)) {
|
||||||
|
particlesAttracted = true
|
||||||
|
}
|
||||||
|
// Bei ~400ms in die Attract-Phase ein kleiner Icon-Wobble als die
|
||||||
|
// Partikel ankommen — fühlt sich an wie der Impact.
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.1)) { iconWobble = 1.06 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
withAnimation(.easeIn(duration: 0.1)) { iconWobble = 1.0 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
// Phase 3 — Flash + Scale-Pulse + Haptic
|
||||||
|
phase = .flash
|
||||||
|
#if os(iOS)
|
||||||
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
|
#endif
|
||||||
|
// Ring: 1.0 → 2.5×, opacity 1 → 0 (0.4s easeOut)
|
||||||
|
ringScale = 1.0
|
||||||
|
ringOpacity = 1.0
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
ringScale = 2.5
|
||||||
|
ringOpacity = 0
|
||||||
|
}
|
||||||
|
// Icon-Brightness: schnell hoch, langsamer runter
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) { iconBrightness = 0.6 }
|
||||||
|
// Parallel: Icon-Scale-Pulse 1.0 → 1.15 → 1.0
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { iconScale = 1.15 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
withAnimation(.easeIn(duration: 0.25)) { iconBrightness = 0 }
|
||||||
|
withAnimation(.easeIn(duration: 0.2)) { iconScale = 1.0 }
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
// Phase 4 — Fade
|
||||||
|
phase = .fading
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
splashOpacity = 0
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .milliseconds(420))
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
"library.settings" = "Einstellungen";
|
"library.settings" = "Einstellungen";
|
||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
"login.title" = "ABS Client";
|
"login.title" = "Nook";
|
||||||
"login.server_placeholder" = "https://mein-server.de";
|
"login.server_placeholder" = "https://mein-server.de";
|
||||||
"login.username" = "Benutzername";
|
"login.username" = "Benutzername";
|
||||||
"login.password" = "Passwort";
|
"login.password" = "Passwort";
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"sidebar.offline" = "Offline";
|
"sidebar.offline" = "Offline";
|
||||||
"sidebar.history" = "Verlauf";
|
"sidebar.history" = "Verlauf";
|
||||||
"sidebar.logout" = "Abmelden";
|
"sidebar.logout" = "Abmelden";
|
||||||
"sidebar.app_title" = "ABS Client";
|
"sidebar.app_title" = "Nook";
|
||||||
"sidebar.status_online" = "Online";
|
"sidebar.status_online" = "Online";
|
||||||
"sidebar.status_offline" = "Offline";
|
"sidebar.status_offline" = "Offline";
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
"library.settings" = "Settings";
|
"library.settings" = "Settings";
|
||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
"login.title" = "ABS Client";
|
"login.title" = "Nook";
|
||||||
"login.server_placeholder" = "https://my-server.com";
|
"login.server_placeholder" = "https://my-server.com";
|
||||||
"login.username" = "Username";
|
"login.username" = "Username";
|
||||||
"login.password" = "Password";
|
"login.password" = "Password";
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"sidebar.offline" = "Offline";
|
"sidebar.offline" = "Offline";
|
||||||
"sidebar.history" = "History";
|
"sidebar.history" = "History";
|
||||||
"sidebar.logout" = "Sign out";
|
"sidebar.logout" = "Sign out";
|
||||||
"sidebar.app_title" = "ABS Client";
|
"sidebar.app_title" = "Nook";
|
||||||
"sidebar.status_online" = "Online";
|
"sidebar.status_online" = "Online";
|
||||||
"sidebar.status_offline" = "Offline";
|
"sidebar.status_offline" = "Offline";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -1,4 +1,4 @@
|
|||||||
# ABS-Client
|
# Nook
|
||||||
|
|
||||||
> Ein nativer iOS- und macOS-Client für deine selbst gehostete [Audiobookshelf](https://www.audiobookshelf.org/)-Instanz.
|
> Ein nativer iOS- und macOS-Client für deine selbst gehostete [Audiobookshelf](https://www.audiobookshelf.org/)-Instanz.
|
||||||
|
|
||||||
@@ -6,37 +6,70 @@
|
|||||||
|
|
||||||
## 📖 Über die App
|
## 📖 Über die App
|
||||||
|
|
||||||
**ABS-Client** ist eine native App für iPhone, iPad und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek – ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
|
**Nook** ist eine native App für iPhone und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek – ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
|
||||||
|
|
||||||
> ⚠️ **Hinweis:** Die App befindet sich noch in aktiver Entwicklung und ist aktuell noch nicht im App Store verfügbar. Der Release ist in Kürze geplant.
|
---
|
||||||
|
|
||||||
|
## 📲 Im App Store erhältlich
|
||||||
|
|
||||||
|
Nook ist sowohl für **iPhone** als auch für **Mac** kostenlos im App Store verfügbar:
|
||||||
|
|
||||||
|
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### Aktuell verfügbar
|
### Bibliothek & Inhalte
|
||||||
- 🔗 **Serververbindung** – Verbinde die App mit deiner eigenen Audiobookshelf-Instanz
|
- 🔗 **Serververbindung** – Verbinde die App mit deiner eigenen Audiobookshelf-Instanz
|
||||||
- 📚 **Bibliotheks-Browser** – Durchsuche und verwalte deine Hörbücher
|
- 📚 **Bibliotheks-Browser** – Durchsuche deine Hörbücher in Kachel- oder Listenansicht
|
||||||
- ⬇️ **Download** – Lade Hörbücher für die Offline-Nutzung herunter
|
- 🎙️ **Podcast-Unterstützung** – Episoden direkt aus deiner ABS-Bibliothek wiedergeben
|
||||||
- 🎧 **Wiedergabe** – Höre deine Hörbücher direkt in der App
|
- 🖼️ **Cover-Cache** – Cover werden lokal zwischengespeichert, kein erneutes Laden bei jedem App-Start
|
||||||
|
|
||||||
### Geplant
|
### Wiedergabe
|
||||||
- ⏱️ **Einschlaftimer** – Automatisches Stoppen der Wiedergabe nach einer festgelegten Zeit
|
- 🎧 **Vollwertiger Player** – Wiedergabe mit Scrubber, Skip-Buttons (15s / 30s / 45s / 60s / 90s konfigurierbar) und Geschwindigkeitsregler (0.75× bis 2.0×)
|
||||||
- 🌍 **Mehrsprachigkeit** – Die App ist aktuell nur auf Deutsch verfügbar; weitere Sprachen sind in Planung
|
- 📑 **Kapitel-Navigation** – Springe direkt zu Kapiteln; aktuelles Kapitel wird hervorgehoben
|
||||||
|
- 🔖 **Lesezeichen** – Markiere beliebige Stellen mit eigenem Namen, synchronisiert mit deinem ABS-Server
|
||||||
|
- ⏱️ **Einschlaftimer** – Mehrere Modi: Festgelegte Minuten, „Bis Ende des Kapitels" oder „Bis Ende des Hörbuchs"
|
||||||
|
- 🎚️ **Lockscreen- & Control-Center-Steuerung** – Vollständige Wiedergabesteuerung von außerhalb der App
|
||||||
|
|
||||||
|
### Downloads & Offline
|
||||||
|
- ⬇️ **Offline-Downloads** – Lade Hörbücher und Podcast-Episoden für die Offline-Wiedergabe
|
||||||
|
- 📊 **Live-Fortschritt** – Echtzeit-Anzeige der heruntergeladenen MB pro Hörbuch
|
||||||
|
- 🔄 **Hintergrund-Downloads** *(iOS)* – Downloads laufen weiter, auch wenn die App geschlossen oder das Gerät gesperrt ist
|
||||||
|
- 🎛️ **Parallel oder sequenziell** – Wähle in den Einstellungen ob mehrere Downloads gleichzeitig oder nacheinander laufen sollen
|
||||||
|
- 📶 **Mobilnetz-Schutz** – Optional Downloads nur über WLAN
|
||||||
|
- 🔔 **Live Activities** *(iOS)* – Download-Fortschritt direkt im Lockscreen und Dynamic Island
|
||||||
|
|
||||||
|
### Hörfortschritt & Verlauf
|
||||||
|
- 🔄 **Bidirektionale Synchronisation** – Hörfortschritt wird zum Server gepusht und periodisch nachgefragt; bei Konflikten (z. B. anderes Gerät war voraus) wird beim Fortsetzen gefragt
|
||||||
|
- 📜 **Hörverlauf** *(opt-in)* – Lokales Protokoll deiner Hörpositionen, ideal um nach versehentlichem Verspulen schnell zurückzuspringen
|
||||||
|
- 💾 **XML-Export** – Hörverlauf als XML exportieren und teilen
|
||||||
|
- 🌐 **Cross-Device Foreground-Sync** – Beim Zurückkehren in die App wird der Server sofort um aktuelle Daten gefragt
|
||||||
|
|
||||||
|
### Sprache & Plattform
|
||||||
|
- 🇩🇪 **Deutsch** und 🇬🇧 **Englisch** – Vollständig lokalisierte Oberfläche
|
||||||
|
- 🖥️ **macOS-Optimierung** – Eigene Sidebar-Navigation, Tastatursteuerung und Multi-Window-Support
|
||||||
|
- 📱 **iOS-Optimierung** – Liquid-Glass-Design, native Sheets und kontextuelle Menüs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
- **iOS / iPadOS:** 26.0 oder neuer
|
- **iOS:** 26.0 oder neuer (iPhone)
|
||||||
- **macOS:** 26.0 (Tahoe) oder neuer
|
- **macOS:** 26.0 (Tahoe) oder neuer
|
||||||
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz
|
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz (Version 2.x)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 Installation
|
||||||
|
|
||||||
Die App ist noch nicht im App Store verfügbar. Wer die App bereits jetzt nutzen oder testen möchte, kann das Projekt selbst aus dem Quellcode bauen:
|
### Aus dem App Store
|
||||||
|
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
|
||||||
|
|
||||||
|
Der Link funktioniert sowohl für iPhone als auch für Mac — der App Store leitet automatisch zur passenden Version weiter.
|
||||||
|
|
||||||
|
### Aus dem Quellcode bauen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.scarriffle.com/Scarriffle/ABS-Client.git
|
git clone https://git.scarriffle.com/Scarriffle/ABS-Client.git
|
||||||
@@ -72,6 +105,7 @@ Dieses Projekt ist lizenziert unter der **GNU General Public License v3.0**.
|
|||||||
Das bedeutet: Du kannst den Code frei nutzen, verändern und weitergeben – aber abgeleitete Projekte müssen ebenfalls unter der GPL v3 veröffentlicht werden.
|
Das bedeutet: Du kannst den Code frei nutzen, verändern und weitergeben – aber abgeleitete Projekte müssen ebenfalls unter der GPL v3 veröffentlicht werden.
|
||||||
|
|
||||||
Den vollständigen Lizenztext findest du in der [LICENSE](LICENSE)-Datei.
|
Den vollständigen Lizenztext findest du in der [LICENSE](LICENSE)-Datei.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📬 Kontakt
|
## 📬 Kontakt
|
||||||
@@ -82,4 +116,4 @@ Bei Fragen, Feedback oder Problemen:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ABS-Client ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).
|
> Nook ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).
|
||||||
|
|||||||
Reference in New Issue
Block a user