From f053f1d320c2a57da907c6f28050b46fa4bceaac Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 8 Jun 2026 19:32:18 +0200 Subject: [PATCH] Neues Icon, parallele Downloads --- .../project.pbxproj | 14 ++ .../Services/DownloadActivityAttributes.swift | 19 +++ .../Services/DownloadActivityManager.swift | 63 +++++++++ .../Services/NotificationManager.swift | 42 ++++++ .../DownloadActivityAttributes.swift | 17 +++ .../DownloadLiveActivityWidget.swift | 130 ++++++++++++++++++ .../DownloadWidgetBundle.swift | 31 +++++ 7 files changed, 316 insertions(+) create mode 100644 ABS Client/Audiobookshelf swift/Services/DownloadActivityAttributes.swift create mode 100644 ABS Client/Audiobookshelf swift/Services/DownloadActivityManager.swift create mode 100644 ABS Client/Audiobookshelf swift/Services/NotificationManager.swift create mode 100644 ABS Client/DownloadWidgetExtension/DownloadActivityAttributes.swift create mode 100644 ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift create mode 100644 ABS Client/DownloadWidgetExtension/DownloadWidgetBundle.swift diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj index c041529..fe240a1 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXFileReference section */ 39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActivityAttributes.swift; sourceTree = ""; }; + 39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadWidgetBundle.swift; sourceTree = ""; }; + 39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -34,6 +37,7 @@ children = ( 39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */, 39614D0C2FB4D44500DBEF5E /* Products */, + 39C427032FD180A90033EE46 /* DownloadWidgetExtension */, ); sourceTree = ""; }; @@ -45,6 +49,16 @@ name = Products; sourceTree = ""; }; + 39C427032FD180A90033EE46 /* DownloadWidgetExtension */ = { + isa = PBXGroup; + children = ( + 39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */, + 39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */, + 39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */, + ); + path = DownloadWidgetExtension; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadActivityAttributes.swift b/ABS Client/Audiobookshelf swift/Services/DownloadActivityAttributes.swift new file mode 100644 index 0000000..ae1b5aa --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Services/DownloadActivityAttributes.swift @@ -0,0 +1,19 @@ +#if os(iOS) +import ActivityKit +import Foundation + +/// Shared between the main app (to drive the activity lifecycle) and the +/// DownloadWidgetExtension (to render the Lock Screen / Dynamic Island UI). +/// Both targets must compile this exact definition — keep them in sync. +struct DownloadActivityAttributes: ActivityAttributes { + /// Item title set once at activity start; never changes during the download. + let itemTitle: String + + struct ContentState: Codable, Hashable { + /// Overall fraction across all tracks (0.0 – 1.0). + var progress: Double + /// Total bytes written to disk so far, shown as "X MB downloaded". + var bytesDownloaded: Int64 + } +} +#endif diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadActivityManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadActivityManager.swift new file mode 100644 index 0000000..a2ea7e8 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Services/DownloadActivityManager.swift @@ -0,0 +1,63 @@ +#if os(iOS) +import ActivityKit +import Foundation +import os.log + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ABS", category: "LiveActivity") + +/// Manages one `Activity` per active download key. +/// All methods must be called from the MainActor (same isolation as DownloadManager). +@MainActor +final class DownloadActivityManager { + + // Keyed by downloadKey (matches DownloadManager's key scheme). + private var activities: [String: Activity] = [:] + + /// Requests a new Live Activity for a download that is starting. + /// Logs the reason to the console (visible in Xcode) if the request fails. + func startActivity(downloadKey: String, itemTitle: String) { + let authInfo = ActivityAuthorizationInfo() + guard authInfo.areActivitiesEnabled else { + logger.warning("Live Activities disabled (areActivitiesEnabled=false) — skipping for '\(itemTitle)'") + return + } + + let attributes = DownloadActivityAttributes(itemTitle: itemTitle) + let initial = DownloadActivityAttributes.ContentState( + progress: 0, + bytesDownloaded: 0 + ) + let content = ActivityContent(state: initial, staleDate: nil) + + do { + let activity = try Activity.request(attributes: attributes, content: content) + activities[downloadKey] = activity + logger.info("Live Activity started: id=\(activity.id) title='\(itemTitle)'") + } catch { + // Most common reason: no widget extension is installed/embedded that + // provides an ActivityConfiguration for DownloadActivityAttributes. + logger.error("Activity.request() failed: \(error.localizedDescription)") + } + } + + /// Pushes an incremental progress update to the running Live Activity. + func updateActivity(downloadKey: String, progress: Double, bytesDownloaded: Int64) { + guard let activity = activities[downloadKey] else { return } + let state = DownloadActivityAttributes.ContentState( + progress: progress, + bytesDownloaded: bytesDownloaded + ) + let content = ActivityContent(state: state, staleDate: nil) + // activity.update() is async; fire-and-forget is intentional — a skipped + // frame doesn't hurt the download and avoids blocking the download loop. + Task { await activity.update(content) } + } + + /// Ends the Live Activity with `.immediate` dismissal so the Lock Screen + /// badge disappears right away instead of lingering for the default 4 hours. + func endActivity(downloadKey: String) { + guard let activity = activities.removeValue(forKey: downloadKey) else { return } + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } +} +#endif diff --git a/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift b/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift new file mode 100644 index 0000000..915558d --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Services/NotificationManager.swift @@ -0,0 +1,42 @@ +import Foundation +import UserNotifications +#if canImport(UIKit) +import UIKit +#endif + +/// Manages local UNUserNotificationCenter notifications for download events. +@MainActor +final class NotificationManager { + static let shared = NotificationManager() + private init() {} + + /// Requests notification authorization the first time the app launches. + /// Skips the system prompt if permission has already been granted or denied. + func requestPermissionIfNeeded() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .notDetermined else { return } + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + } + + /// Fires a local notification when a download fails. + /// Suppressed while the app is in the foreground — an in-app alert already + /// surfaces the error there, so a notification would be redundant. + func sendDownloadFailedNotification(itemTitle: String) async { + #if canImport(UIKit) + guard UIApplication.shared.applicationState != .active else { return } + #endif + + let content = UNMutableNotificationContent() + content.title = "Download fehlgeschlagen" + content.body = "„\(itemTitle)" konnte nicht heruntergeladen werden." + content.sound = .default + + let request = UNNotificationRequest( + identifier: "download-failed-\(UUID().uuidString)", + content: content, + trigger: nil // deliver immediately + ) + try? await UNUserNotificationCenter.current().add(request) + } +} diff --git a/ABS Client/DownloadWidgetExtension/DownloadActivityAttributes.swift b/ABS Client/DownloadWidgetExtension/DownloadActivityAttributes.swift new file mode 100644 index 0000000..53be270 --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/DownloadActivityAttributes.swift @@ -0,0 +1,17 @@ +import ActivityKit +import Foundation + +/// Shared between the main app (to drive the activity lifecycle) and the +/// DownloadWidgetExtension (to render the Lock Screen / Dynamic Island UI). +/// Both targets must compile this exact definition — keep them in sync. +struct DownloadActivityAttributes: ActivityAttributes { + /// Item title set once at activity start; never changes during the download. + let itemTitle: String + + struct ContentState: Codable, Hashable { + /// Overall fraction across all tracks (0.0 – 1.0). + var progress: Double + /// Total bytes written to disk so far, shown as "X MB downloaded". + var bytesDownloaded: Int64 + } +} diff --git a/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift b/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift new file mode 100644 index 0000000..1fa4c34 --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/DownloadLiveActivityWidget.swift @@ -0,0 +1,130 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +// MARK: - Widget configuration + +struct DownloadLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: DownloadActivityAttributes.self) { context in + // Lock Screen / Notification Center banner + DownloadLockScreenView(context: context) + .activityBackgroundTint(Color(.systemBackground).opacity(0.6)) + } dynamicIsland: { context in + DynamicIsland { + // Expanded region shown when the user long-presses the island + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + .font(.title2) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.itemTitle) + .font(.caption.bold()) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(spacing: 4) { + ProgressView(value: context.state.progress) + .tint(.blue) + HStack { + Text(bytesFormatted(context.state.bytesDownloaded)) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(percentFormatted(context.state.progress)) + .font(.caption2.bold()) + .monospacedDigit() + } + } + } + } compactLeading: { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + .font(.caption) + } compactTrailing: { + Text(percentFormatted(context.state.progress)) + .font(.caption2.bold()) + .monospacedDigit() + } minimal: { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + .font(.caption2) + } + } + } +} + +// MARK: - Lock Screen view + +struct DownloadLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + Text(context.attributes.itemTitle) + .font(.subheadline.bold()) + .lineLimit(1) + Spacer() + Text(percentFormatted(context.state.progress)) + .font(.subheadline.bold()) + .monospacedDigit() + } + ProgressView(value: context.state.progress) + .tint(.blue) + Text(bytesFormatted(context.state.bytesDownloaded) + " heruntergeladen") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } +} + +// MARK: - Helpers + +private func percentFormatted(_ fraction: Double) -> String { + String(format: "%.0f%%", fraction * 100) +} + +private func bytesFormatted(_ bytes: Int64) -> String { + let mb = Double(bytes) / (1_024 * 1_024) + if mb >= 1 { return String(format: "%.1f MB", mb) } + let kb = Double(bytes) / 1_024 + if kb >= 1 { return String(format: "%.0f KB", kb) } + return "\(bytes) B" +} + +// MARK: - Previews + +private let previewAttributes = DownloadActivityAttributes(itemTitle: "The Name of the Wind") + +#Preview("Lock Screen", as: .content, using: previewAttributes) { + DownloadLiveActivityWidget() +} contentStates: { + DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800) + DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304) +} + +#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: previewAttributes) { + DownloadLiveActivityWidget() +} contentStates: { + DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800) + DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304) +} + +#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: previewAttributes) { + DownloadLiveActivityWidget() +} contentStates: { + DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800) + DownloadActivityAttributes.ContentState(progress: 0.9, bytesDownloaded: 135_266_304) +} + +#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: previewAttributes) { + DownloadLiveActivityWidget() +} contentStates: { + DownloadActivityAttributes.ContentState(progress: 0.35, bytesDownloaded: 52_428_800) +} diff --git a/ABS Client/DownloadWidgetExtension/DownloadWidgetBundle.swift b/ABS Client/DownloadWidgetExtension/DownloadWidgetBundle.swift new file mode 100644 index 0000000..f85672f --- /dev/null +++ b/ABS Client/DownloadWidgetExtension/DownloadWidgetBundle.swift @@ -0,0 +1,31 @@ +// MARK: - Widget Extension Entry Point +// +// HOW TO WIRE THIS UP IN XCODE (one-time setup): +// +// 1. File > New > Target > Widget Extension +// - Product Name: DownloadWidgetExtension +// - Bundle ID: com.local.ABS-Client.DownloadWidgetExtension +// - Uncheck "Include Configuration App Intent" +// - Minimum deployment: iOS 16.2 (first stable ActivityKit release) +// +// 2. In the new target's Build Phases > Compile Sources, add: +// - DownloadWidgetBundle.swift (this file) +// - DownloadLiveActivityWidget.swift +// - DownloadActivityAttributes.swift (the copy in this folder) +// +// 3. In the main app target's Build Phases > Compile Sources, confirm: +// - DownloadActivityAttributes.swift (the copy in Services/) +// is included. The two copies must stay identical. +// +// 4. In the main app's Build Phases > Embed App Extensions, add the +// DownloadWidgetExtension.appex product. + +import SwiftUI +import WidgetKit + +@main +struct DownloadWidgetBundle: WidgetBundle { + var body: some Widget { + DownloadLiveActivityWidget() + } +}