diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj index 7a8290c..6a61a12 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -395,7 +395,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = PP34X97WS3; "ENABLE_APP_SANDBOX[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -423,7 +423,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 3.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; @@ -449,7 +449,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = PP34X97WS3; "ENABLE_APP_SANDBOX[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; @@ -477,7 +477,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 3.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; @@ -500,7 +500,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DownloadWidgetExtension/Info.plist; @@ -512,13 +512,15 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 2.6; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -533,7 +535,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DownloadWidgetExtension/Info.plist; @@ -545,13 +547,15 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 2.6; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme b/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme index deb5d2b..31e9da7 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme +++ b/ABS Client/Audiobookshelf swift.xcodeproj/xcshareddata/xcschemes/DownloadWidgetExtensionExtension.xcscheme @@ -9,9 +9,9 @@ buildArchitectures = "Automatic"> - + - + Audiobookshelf swift.xcscheme_^#shared#^_ orderHint - 0 + 1 DownloadWidgetExtensionExtension.xcscheme_^#shared#^_ + isShown + orderHint 0 SuppressBuildableAutocreation - 39614D0A2FB4D44400DBEF5E - - primary - - 39C4270A2FD189220033EE46 primary diff --git a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift index 2b78b02..bc6293c 100644 --- a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift +++ b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift @@ -56,9 +56,18 @@ struct Audiobookshelf_swiftApp: App { #if os(iOS) private func configureAudioSession() { - // Nur die Kategorie registrieren — setActive(true) passiert erst in play(), - // damit beim App-Start keine laufende Fremd-Wiedergabe unterbrochen wird. - try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + // `.spokenAudio` + `.longFormAudio` policy: iOS-Hinweis dass es sich um + // ernsthafte Audio-Wiedergabe handelt (nicht Tasten-Sounds). Aktiviert + // 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 } diff --git a/ABS Client/Audiobookshelf swift/Models/Models.swift b/ABS Client/Audiobookshelf swift/Models/Models.swift index 7f9c93f..a9e81ef 100644 --- a/ABS Client/Audiobookshelf swift/Models/Models.swift +++ b/ABS Client/Audiobookshelf swift/Models/Models.swift @@ -13,6 +13,11 @@ struct Library: Codable, Identifiable, Hashable { 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 { let id: String let title: String @@ -74,6 +79,10 @@ struct PlaybackProgress: Codable, Hashable { var duration: Double var isFinished: Bool 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 { if let episodeId { return "\(itemId)|\(episodeId)" } diff --git a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift index 419fe7f..bb83a03 100644 --- a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift +++ b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift @@ -75,7 +75,7 @@ final class ABSClient { func fetchLibraries() async throws -> [Library] { let req = try makeRequest(path: "/api/libraries") 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] { @@ -84,6 +84,62 @@ final class ABSClient { 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 { let meta = raw.media?.metadata let mediaType = raw.mediaType ?? "book" @@ -99,16 +155,16 @@ final class ABSClient { let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor") var item = LibraryItem( id: raw.id, - title: meta?.title ?? "Unbekannt", - author: authorString, + title: decodeHTMLEntities(meta?.title ?? "Unbekannt"), + author: decodeHTMLEntities(authorString), durationSeconds: raw.media?.duration ?? 0, audioFiles: files ) item.mediaType = mediaType - item.description = meta?.description + item.description = meta?.description.map { decodeHTMLEntities($0) } 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 } - return Chapter(id: id, start: start, end: end, title: title) + return Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title)) } return item } @@ -129,7 +185,7 @@ final class ABSClient { ) return PodcastEpisode( id: ep.id, - title: ep.title ?? "Folge", + title: Self.decodeHTMLEntities(ep.title ?? "Folge"), pubDate: ep.pubDate, publishedAtMillis: ep.publishedAt, season: ep.season, @@ -167,7 +223,8 @@ final class ABSClient { currentTime: dto.currentTime ?? 0, duration: dto.duration ?? 0, 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] { let req = try makeRequest(path: "/api/me") let dto = try await perform(req, as: MeResponseDTO.self) @@ -205,7 +282,8 @@ final class ABSClient { currentTime: p.currentTime ?? 0, duration: p.duration ?? 0, isFinished: p.isFinished ?? false, - updatedAt: Self.parseLastUpdate(p.lastUpdate) + updatedAt: Self.parseLastUpdate(p.lastUpdate), + id: p.id ) } } diff --git a/ABS Client/Audiobookshelf swift/Services/AppState.swift b/ABS Client/Audiobookshelf swift/Services/AppState.swift index d5842b9..3534c9a 100644 --- a/ABS Client/Audiobookshelf swift/Services/AppState.swift +++ b/ABS Client/Audiobookshelf swift/Services/AppState.swift @@ -23,6 +23,12 @@ final class AppState { /// Used to show progress bars on covers in the library views. 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 /// playback is active/paused. Offered via alert on next play; cleared on /// item change. @@ -70,6 +76,7 @@ final class AppState { await NotificationManager.shared.requestPermissionIfNeeded() #endif auth.restoreSession() + loadRecentItemsCache() network.start { [weak self] online in guard let self else { return } if online { @@ -220,6 +227,10 @@ final class AppState { } 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) if player.errorMessage == nil { player.play() @@ -244,6 +255,52 @@ final class AppState { 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. func play(podcast: LibraryItem, episode: PodcastEpisode) async { var synthetic = LibraryItem( @@ -323,6 +380,105 @@ final class AppState { 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) { guard let item = currentItem else { return } let t = player.absoluteCurrentTime diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift index 27351cd..61f04ec 100644 --- a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift @@ -146,20 +146,23 @@ final class DownloadManager: NSObject, @unchecked Sendable { let config = URLSessionConfiguration.background(withIdentifier: identifier) config.sessionSendsLaunchEvents = true 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 - // 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 #else + // macOS hat keinen Background-Session-Daemon mit Auto-Retry — + // gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein + // parallel laufender Download bei geteilter Bandbreite zu lange ohne + // Bytes auf und failed mit Timeout. let config = URLSessionConfiguration.default + config.networkServiceType = .responsiveData + config.httpMaximumConnectionsPerHost = 6 #endif config.allowsCellularAccess = true config.waitsForConnectivity = true - 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 return URLSession(configuration: config, delegate: self, delegateQueue: nil) } diff --git a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift index 01ce982..58e5d76 100644 --- a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift +++ b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift @@ -112,6 +112,13 @@ final class PlayerEngine { let queue = AVQueuePlayer(items: trackPlayerItems) 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 let center = NotificationCenter.default diff --git a/ABS Client/Audiobookshelf swift/Views/AirPlayPickerButton.swift b/ABS Client/Audiobookshelf swift/Views/AirPlayPickerButton.swift new file mode 100644 index 0000000..d8daf6f --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Views/AirPlayPickerButton.swift @@ -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 diff --git a/ABS Client/Audiobookshelf swift/Views/ContentView.swift b/ABS Client/Audiobookshelf swift/Views/ContentView.swift index 0962277..9a1ee62 100644 --- a/ABS Client/Audiobookshelf swift/Views/ContentView.swift +++ b/ABS Client/Audiobookshelf swift/Views/ContentView.swift @@ -3,20 +3,21 @@ import SwiftUI struct ContentView: View { @Environment(AppState.self) private var app @Environment(\.scenePhase) private var scenePhase - #if os(iOS) - @State private var splashVisible = true - #endif + + /// Bootstrap fertig — Signal an den Splash, Phase 2 zu starten. + @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 { ZStack { mainContent - #if os(iOS) - if splashVisible { - SplashView() - .zIndex(10) - .transition(.opacity.animation(.easeOut(duration: 0.55))) + if !splashFinished { + SplashScreenView(isReady: isReadyToDismiss) { + splashFinished = true + } + .zIndex(10) } - #endif } .task { await boot() } .onChange(of: scenePhase) { _, newPhase in @@ -42,17 +43,11 @@ struct ContentView: View { } 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() - #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 } } diff --git a/ABS Client/Audiobookshelf swift/Views/HomeView.swift b/ABS Client/Audiobookshelf swift/Views/HomeView.swift new file mode 100644 index 0000000..bc2514a --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Views/HomeView.swift @@ -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 +} diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift index b7aa33b..e5d475f 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift @@ -22,7 +22,11 @@ struct LibraryGridView: View { private var gridContent: some View { ScrollView { 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) .onTapGesture { onSelect(item) } } diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift index 582934b..efa469a 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift @@ -8,21 +8,28 @@ struct LibraryItemCell: View { var body: some View { VStack(alignment: .leading, spacing: 2) { ZStack(alignment: .bottom) { - ZStack(alignment: .topTrailing) { - cover - .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) - if !(dimDownloading && isActivelyDownloading) { - downloadBadge.padding(4) + cover + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) + .overlay(alignment: .topTrailing) { + if isListened { + listenedBadge.padding(4) + } } - } - .overlay { - if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) { - LargeDownloadOverlay(progress: p) + .overlay(alignment: .bottomTrailing) { + if !(dimDownloading && isActivelyDownloading) { + downloadBadge.padding(.trailing, 4).padding(.bottom, 10) + } } + .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) #if os(iOS) @@ -56,7 +63,7 @@ struct LibraryItemCell: View { } // Ensure the cell fills its full grid column width .frame(maxWidth: .infinity, alignment: .leading) - .contextMenu { downloadMenuItems } + .contextMenu { mediaItemContextMenu(for: item, app: app) } } private var isActivelyDownloading: Bool { @@ -64,6 +71,14 @@ struct LibraryItemCell: View { 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 private var cover: some View { @@ -122,23 +137,37 @@ struct LibraryItemCell: View { .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 private var downloadBadge: some View { let state = app.downloads.state(for: item.downloadKey) switch state { case .downloaded: - Image(systemName: "checkmark.circle.fill") + 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: 26, height: 26) + .frame(width: 22, height: 22) #else - .frame(width: 32, height: 32) + .frame(width: 26, height: 26) #endif case .failed: 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 diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift index beb25af..4d1fade 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift @@ -18,7 +18,8 @@ struct LibraryListView: View { var body: some View { #if os(iOS) List { - ForEach(items) { item in + // Identify by syncKey — see LibraryGridView comment. + ForEach(items, id: \.syncKey) { item in LibraryListRow(item: item, dimDownloading: dimDownloading) .contentShape(Rectangle()) .onTapGesture { onSelect(item) } @@ -32,7 +33,7 @@ struct LibraryListView: View { #else ScrollView { 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) .contentShape(Rectangle()) .onTapGesture { onSelect(item) } @@ -70,7 +71,7 @@ struct LibraryListRow: View { .foregroundStyle(.secondary) .lineLimit(1) let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId) - if fraction > 0 { + if fraction > 0 && !isListened { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3)) @@ -100,6 +101,11 @@ struct LibraryListRow: View { .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) } #endif + if isListened { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + } if !(dimDownloading && isActivelyDownloading) { downloadStatus #if os(macOS) @@ -111,7 +117,7 @@ struct LibraryListRow: View { .padding(.horizontal, 16) .padding(.vertical, 8) #endif - .contextMenu { downloadMenuItems } + .contextMenu { mediaItemContextMenu(for: item, app: app) } } private var isActivelyDownloading: Bool { @@ -119,6 +125,10 @@ struct LibraryListRow: View { return false } + private var isListened: Bool { + app.progressCache[item.syncKey]?.isFinished ?? false + } + private var cover: some View { Group { if let url = app.client.coverURL(itemId: item.id) { @@ -153,7 +163,9 @@ struct LibraryListRow: View { let state = app.downloads.state(for: item.downloadKey) switch state { 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) .font(.title3) 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) private func formatDuration(_ seconds: Double) -> String { guard seconds.isFinite, seconds > 0 else { return "" } diff --git a/ABS Client/Audiobookshelf swift/Views/MainView.swift b/ABS Client/Audiobookshelf swift/Views/MainView.swift index f228181..32633db 100644 --- a/ABS Client/Audiobookshelf swift/Views/MainView.swift +++ b/ABS Client/Audiobookshelf swift/Views/MainView.swift @@ -1,11 +1,39 @@ import SwiftUI enum LibraryFilter: Hashable { + case home case library(String) case downloaded 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 @MainActor final class LibraryViewModel { @@ -15,21 +43,93 @@ final class LibraryViewModel { var errorMessage: String? 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 = [] + + var isLoadingEpisodeIndex: Bool { + !episodeIndexLoading.isEmpty + } + func loadLibraries(client: ABSClient) async { do { libraries = try await client.fetchLibraries() - if selection == nil, let first = libraries.first { - selection = .library(first.id) + // Home is the new default landing screen — first library is only + // used as fallback when there are zero libraries. + if selection == nil { + selection = .home } } catch { 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 { guard let selection else { return } switch selection { + case .home: + // HomeView reads progressCache + recentItemsCache directly; no + // library items to load into vm.items. + items = [] + errorMessage = nil 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 { items = try await client.fetchItems(libraryId: id) errorMessage = nil @@ -77,6 +177,7 @@ struct MainView: View { @Environment(AppState.self) private var app @State private var vm = LibraryViewModel() @State private var navPath: [LibraryItem] = [] + @State private var librarySearchText: String = "" @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue @AppStorage("historyEnabled") private var historyEnabled: Bool = false @State private var showFullHistory: Bool = false @@ -95,8 +196,19 @@ struct MainView: View { .task { await loadAll() } .onChange(of: vm.selection) { _, _ in navPath.removeAll() + librarySearchText = "" 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 if vm.selection == .downloaded { Task { await vm.loadItems(client: app.client, downloads: app.downloads) } @@ -207,18 +319,49 @@ struct MainView: View { } 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) } else { 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 #if os(macOS) private var sidebar: some View { List(selection: $vm.selection) { + Section { + Label("Home", systemImage: "house.fill") + .tag(LibraryFilter.home) + } Section(String(localized: "sidebar.libraries")) { ForEach(vm.libraries) { lib in Label(lib.name, systemImage: "books.vertical") @@ -374,17 +517,60 @@ struct MainView: View { @ViewBuilder private var detail: some View { #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 .navigationTitle(String(localized: "sidebar.history")) } else { libraryContent } #else - libraryContent + if vm.selection == .home { + HomeView(libraries: vm.libraries) + } else { + libraryContent + } #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 { ZStack { if vm.isLoading && vm.items.isEmpty { @@ -397,12 +583,54 @@ struct MainView: View { } else if vm.items.isEmpty { ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher.")) .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 { - 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.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 @@ -411,9 +639,9 @@ struct MainView: View { let isDownloaded = vm.selection == .downloaded switch layout { case .grid: - LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } + LibraryGridView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } case .list: - LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } + LibraryListView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } } } #if os(macOS) @@ -453,9 +681,11 @@ struct MainView: View { private var libraryMenu: some View { Menu { Picker("Bibliothek", selection: Binding( - get: { vm.selection ?? .library("") }, + get: { vm.selection ?? .home }, set: { vm.selection = $0 } )) { + Label("Home", systemImage: "house.fill") + .tag(LibraryFilter.home) Section("Bibliotheken") { ForEach(vm.libraries) { lib in Label(lib.name, systemImage: "books.vertical") @@ -494,6 +724,7 @@ struct MainView: View { private var selectionIcon: String { switch vm.selection { + case .home: return "house.fill" case .downloaded: return "arrow.down.circle.fill" case .history: return "clock.arrow.circlepath" default: return "books.vertical" @@ -505,6 +736,8 @@ struct MainView: View { private var currentTitle: String { switch vm.selection { + case .home: + return "Home" case .library(let id): return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek" case .downloaded: diff --git a/ABS Client/Audiobookshelf swift/Views/MediaItemContextMenu.swift b/ABS Client/Audiobookshelf swift/Views/MediaItemContextMenu.swift new file mode 100644 index 0000000..42fc781 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Views/MediaItemContextMenu.swift @@ -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") + } + } +} diff --git a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift index e44c488..01d0a94 100644 --- a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift +++ b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift @@ -114,6 +114,10 @@ struct PlayerBar: View { rateMenu + AirPlayPickerButton() + .frame(width: 26, height: 26) + .disabled(!app.player.isReady) + Button { app.stopPlayback() } label: { @@ -150,6 +154,11 @@ struct PlayerBar: View { sleepMenu + AirPlayPickerButton() + .frame(width: 24, height: 24) + .disabled(!app.player.isReady) + .help("Auf anderem Gerät abspielen (AirPlay)") + Spacer(minLength: 0) if historyEnabled { diff --git a/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift b/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift index 31351dd..638fab3 100644 --- a/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift +++ b/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift @@ -8,6 +8,21 @@ struct PodcastDetailView: View { @State private var podcastDetail: LibraryItem? @State private var isLoading: Bool = true @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 { VStack(spacing: 0) { @@ -15,10 +30,11 @@ struct PodcastDetailView: View { Divider() content } - .navigationTitle(podcastDetail?.title ?? podcast.title) + .navigationTitle(resolvedPodcast.title) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen") .task { await load() } } @@ -59,14 +75,16 @@ struct PodcastDetailView: View { ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) } else if episodes.isEmpty { 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 { #if os(iOS) List { - ForEach(episodes, id: \.id) { ep in - EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep) + ForEach(filteredEpisodes, id: \.id) { ep in + EpisodeRow(podcast: resolvedPodcast, episode: ep) .contentShape(Rectangle()) .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 ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in - EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep) + ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in + EpisodeRow(podcast: resolvedPodcast, episode: ep) .contentShape(Rectangle()) .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) } } @@ -122,6 +140,10 @@ private struct EpisodeRow: View { return item } + private var isListened: Bool { + app.progressCache[syntheticItem.syncKey]?.isFinished ?? false + } + var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "play.circle.fill") @@ -163,7 +185,7 @@ private struct EpisodeRow: View { #endif } let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id) - if frac > 0 { + if frac > 0 && !isListened { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3)) @@ -179,6 +201,14 @@ private struct EpisodeRow: View { } } Spacer(minLength: 0) + if isListened { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + #if os(macOS) + .padding(.top, 4) + #endif + } downloadButton #if os(macOS) .frame(width: 32) @@ -191,7 +221,7 @@ private struct EpisodeRow: View { #else .padding(.vertical, 4) #endif - .contextMenu { contextMenuItems } + .contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) } } @ViewBuilder @@ -216,7 +246,9 @@ private struct EpisodeRow: View { .frame(width: 22, height: 22) .onTapGesture { app.downloads.cancel(downloadKey: key) } 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) .font(.title3) 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 { guard seconds.isFinite, seconds > 0 else { return "" } let total = Int(seconds) diff --git a/ABS Client/Audiobookshelf swift/Views/SplashView.swift b/ABS Client/Audiobookshelf swift/Views/SplashView.swift index 680aee5..cb7c67e 100644 --- a/ABS Client/Audiobookshelf swift/Views/SplashView.swift +++ b/ABS Client/Audiobookshelf swift/Views/SplashView.swift @@ -1,95 +1,306 @@ import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif -struct SplashView: View { - @State private var appeared = false +// MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset) + +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 { ZStack { - #if os(iOS) - Color(.systemBackground).ignoresSafeArea() - #else - Color(NSColor.windowBackgroundColor).ignoresSafeArea() - #endif - - 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) + background + ZStack { + particleLayer + iconLayer + flashRing } + .frame(width: 320, height: 320) } + .opacity(splashOpacity) + .task { await runSplashAnimation() } .onAppear { - Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in - phase = (phase + 1) % 3 + // Falls der Parent isReady bereits beim ersten Render gesetzt hat + // (z.B. weil bootstrap super-schnell durchlief), spiegeln wir den + // Anfangswert direkt — ohne dass eine onChange-Flanke nötig wäre. + if isReady { readySignaled = true } + } + .onChange(of: isReady) { _, newValue in + if newValue { readySignaled = true } + } + } + + // MARK: Background + + @ViewBuilder + private var background: some View { + #if os(iOS) + 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() + } }