Neues Icon, parallele Downloads

This commit is contained in:
Scarriffle
2026-06-08 19:32:18 +02:00
parent 9497c6e315
commit f053f1d320
7 changed files with 316 additions and 0 deletions

View File

@@ -8,6 +8,9 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActivityAttributes.swift; sourceTree = "<group>"; };
39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadWidgetBundle.swift; sourceTree = "<group>"; };
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -34,6 +37,7 @@
children = ( children = (
39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */, 39614D0D2FB4D44500DBEF5E /* Audiobookshelf swift */,
39614D0C2FB4D44500DBEF5E /* Products */, 39614D0C2FB4D44500DBEF5E /* Products */,
39C427032FD180A90033EE46 /* DownloadWidgetExtension */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -45,6 +49,16 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
39C427032FD180A90033EE46 /* DownloadWidgetExtension */ = {
isa = PBXGroup;
children = (
39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */,
39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */,
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */,
);
path = DownloadWidgetExtension;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<DownloadActivityAttributes>
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)
}

View File

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