Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library - Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library - Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail - Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished - Add AirPlay picker in PlayerBar - Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup - Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s - Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id) - Bump CFBundleVersion to 8 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)" }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
/// Cross-platform SwiftUI wrapper around `AVRoutePickerView`. Opens the system
|
||||
/// route picker (AirPlay devices, Bluetooth, HomePod, Apple TV, etc.) when
|
||||
/// tapped. Available on iOS 11+ and macOS 10.15+ via AppKit.
|
||||
///
|
||||
/// The actual audio routing requires `AVPlayer.allowsExternalPlayback = true`
|
||||
/// — see `PlayerEngine.load(...)` where the queue player is configured.
|
||||
#if os(iOS)
|
||||
struct AirPlayPickerButton: UIViewRepresentable {
|
||||
var tintColor: UIColor = .label
|
||||
var activeTintColor: UIColor = .systemGreen
|
||||
|
||||
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.tintColor = tintColor
|
||||
view.activeTintColor = activeTintColor
|
||||
view.prioritizesVideoDevices = false
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {
|
||||
uiView.tintColor = tintColor
|
||||
uiView.activeTintColor = activeTintColor
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AirPlayPickerButton: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> AVRoutePickerView {
|
||||
AVRoutePickerView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: AVRoutePickerView, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
@@ -3,20 +3,21 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Default landing screen showing "continue listening" rows, Netflix-style.
|
||||
/// Data comes from `AppState.progressCache` (sorted by `updatedAt`) joined
|
||||
/// with `AppState.recentItemsCache` for the cover/title/author metadata.
|
||||
/// A row is only shown when the user actually has a library of that media
|
||||
/// type — pure audiobook users don't see an empty "Podcasts" row and vice versa.
|
||||
struct HomeView: View {
|
||||
@Environment(AppState.self) private var app
|
||||
let libraries: [Library]
|
||||
/// Shared with the library views via the same `libraryLayout` AppStorage
|
||||
/// key — toggling grid/list in the toolbar affects both Home and Library.
|
||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .grid }
|
||||
|
||||
private var hasBookLibrary: Bool {
|
||||
libraries.contains { ($0.mediaType ?? "book") != "podcast" }
|
||||
}
|
||||
|
||||
private var hasPodcastLibrary: Bool {
|
||||
libraries.contains { $0.mediaType == "podcast" }
|
||||
}
|
||||
|
||||
/// Anything at 95 % or above is considered "done with the content" —
|
||||
/// only credits left. Treats the item as finished even when the server
|
||||
/// hasn't flipped `isFinished` yet.
|
||||
private static let nearEndFraction: Double = 0.95
|
||||
/// Minimum playback position to count as "actually started". Filters out
|
||||
/// items the user only previewed for a few seconds or where the server
|
||||
/// reset progress without removing the entry.
|
||||
private static let minStartedSeconds: Double = 30
|
||||
|
||||
private func isStillListening(_ p: PlaybackProgress) -> Bool {
|
||||
if p.isFinished { return false }
|
||||
if p.currentTime < Self.minStartedSeconds { return false }
|
||||
guard p.duration > 0 else { return true } // unknown duration → keep showing
|
||||
return (p.currentTime / p.duration) < Self.nearEndFraction
|
||||
}
|
||||
|
||||
private var audiobooksRecent: [LibraryItem] {
|
||||
app.progressCache.values
|
||||
.filter { isStillListening($0) && $0.episodeId == nil }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.prefix(20)
|
||||
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||
}
|
||||
|
||||
private var podcastsRecent: [LibraryItem] {
|
||||
app.progressCache.values
|
||||
.filter { isStillListening($0) && $0.episodeId != nil }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.prefix(20)
|
||||
.compactMap { app.recentItemsCache[$0.syncKey] }
|
||||
}
|
||||
|
||||
private var anyContent: Bool {
|
||||
(hasBookLibrary && !audiobooksRecent.isEmpty) || (hasPodcastLibrary && !podcastsRecent.isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if anyContent {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
if hasPodcastLibrary && !podcastsRecent.isEmpty {
|
||||
sectionHeader("Zuletzt gehörte Podcasts")
|
||||
sectionBody(items: podcastsRecent)
|
||||
}
|
||||
if hasBookLibrary && !audiobooksRecent.isEmpty {
|
||||
sectionHeader("Zuletzt gehörte Hörbücher")
|
||||
sectionBody(items: audiobooksRecent)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Noch nichts gehört",
|
||||
systemImage: "headphones",
|
||||
description: Text("Sobald du ein Hörbuch oder einen Podcast startest, erscheinen die zuletzt gehörten Folgen hier.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 400)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.refreshable {
|
||||
await app.refreshProgressCache()
|
||||
await app.fillRecentItemsCache()
|
||||
}
|
||||
#endif
|
||||
.task(id: app.progressCache.count) {
|
||||
await app.fillRecentItemsCache()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBody(items: [LibraryItem]) -> some View {
|
||||
switch layout {
|
||||
case .grid:
|
||||
cardRow(items: items)
|
||||
case .list:
|
||||
listRows(items: items)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cardRow(items: [LibraryItem]) -> some View {
|
||||
HomeCardRow(items: items) { tapped in
|
||||
Task { await app.playFromRecents(tapped) }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func listRows(items: [LibraryItem]) -> some View {
|
||||
// Reuses LibraryListRow so the Home list rows inherit the listened +
|
||||
// download indicators automatically. Tapping calls playFromRecents
|
||||
// (which handles podcasts correctly by looking up the episode), not
|
||||
// the library's default play() path.
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
|
||||
LibraryListRow(item: item, dimDownloading: false)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task { await app.playFromRecents(item) }
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
if idx < items.count - 1 {
|
||||
Divider()
|
||||
#if os(macOS)
|
||||
.padding(.leading, 76)
|
||||
#else
|
||||
.padding(.leading, 84)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal, 4)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A single card on the home screen: cover + progress bar + title + author.
|
||||
/// Sized to roughly match the library grid covers so the visual rhythm is
|
||||
/// consistent across screens.
|
||||
struct HomeMediaCard: View {
|
||||
@Environment(AppState.self) private var app
|
||||
let item: LibraryItem
|
||||
let onTap: () -> Void
|
||||
|
||||
#if os(iOS)
|
||||
private let cardWidth: CGFloat = 150
|
||||
#else
|
||||
private let cardWidth: CGFloat = 190
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottom) {
|
||||
cover
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isListened {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.title3)
|
||||
.shadow(radius: 2)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
|
||||
}
|
||||
if !isListened {
|
||||
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||
.padding(.horizontal, 3)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
Text(item.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(item.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(width: cardWidth)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu { mediaItemContextMenu(for: item, app: app) }
|
||||
}
|
||||
|
||||
private var isListened: Bool {
|
||||
app.progressCache[item.syncKey]?.isFinished ?? false
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadBadge: some View {
|
||||
let state = app.downloads.state(for: item.downloadKey)
|
||||
switch state {
|
||||
case .downloaded:
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.title3)
|
||||
.shadow(radius: 2)
|
||||
case .downloading(let p):
|
||||
DownloadProgressRing(progress: p)
|
||||
#if os(iOS)
|
||||
.frame(width: 22, height: 22)
|
||||
#else
|
||||
.frame(width: 26, height: 26)
|
||||
#endif
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.white, .red)
|
||||
.font(.title3)
|
||||
.shadow(radius: 2)
|
||||
case .notDownloaded:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var cover: some View {
|
||||
Rectangle()
|
||||
#if os(iOS)
|
||||
.fill(Color(.systemGray6))
|
||||
#else
|
||||
.fill(AnyShapeStyle(.quaternary))
|
||||
#endif
|
||||
// Explicit square frame — without this the Rectangle inside a
|
||||
// LazyHStack stretches to fill the available vertical space and
|
||||
// pushes siblings way off to the right.
|
||||
.frame(width: cardWidth, height: cardWidth)
|
||||
.overlay {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFit()
|
||||
case .empty:
|
||||
ProgressView()
|
||||
#if os(macOS)
|
||||
.controlSize(.small)
|
||||
#endif
|
||||
case .failure:
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontally scrollable card row that measures its own content vs. the
|
||||
/// available viewport. Scrolling is only enabled when items actually overflow,
|
||||
/// and chevron buttons appear on macOS hover so the user knows there's more
|
||||
/// off-screen and can page through it without trackpad/scrollwheel.
|
||||
private struct HomeCardRow: View {
|
||||
let items: [LibraryItem]
|
||||
let onTap: (LibraryItem) -> Void
|
||||
|
||||
#if os(iOS)
|
||||
private let cardWidth: CGFloat = 150
|
||||
#else
|
||||
private let cardWidth: CGFloat = 190
|
||||
#endif
|
||||
private let spacing: CGFloat = 14
|
||||
private let horizontalPadding: CGFloat = 20
|
||||
|
||||
@State private var viewportWidth: CGFloat = 0
|
||||
@State private var scrollIndex: Int = 0
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
#endif
|
||||
|
||||
private var contentWidth: CGFloat {
|
||||
guard !items.isEmpty else { return 0 }
|
||||
return CGFloat(items.count) * cardWidth
|
||||
+ CGFloat(items.count - 1) * spacing
|
||||
+ horizontalPadding * 2
|
||||
}
|
||||
|
||||
private var canScroll: Bool { contentWidth > viewportWidth + 1 }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: spacing) {
|
||||
ForEach(items, id: \.syncKey) { item in
|
||||
HomeMediaCard(item: item) { onTap(item) }
|
||||
.id(item.syncKey)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.scrollDisabled(!canScroll)
|
||||
.onAppear { viewportWidth = geo.size.width }
|
||||
.onChange(of: geo.size.width) { _, newValue in viewportWidth = newValue }
|
||||
#if os(macOS)
|
||||
.overlay(alignment: .leading) {
|
||||
chevron(systemName: "chevron.left", visible: isHovering && canScroll) {
|
||||
scrollBy(-1, proxy: proxy)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
chevron(systemName: "chevron.right", visible: isHovering && canScroll) {
|
||||
scrollBy(1, proxy: proxy)
|
||||
}
|
||||
}
|
||||
.onHover { isHovering = $0 }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// GeometryReader has no intrinsic size — give the row an explicit height
|
||||
// matching cardWidth (square cover) + ~50pt for title/author.
|
||||
.frame(height: cardWidth + 52)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@ViewBuilder
|
||||
private func chevron(systemName: String, visible: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(.black.opacity(0.55)))
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 8)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.animation(.easeInOut(duration: 0.15), value: visible)
|
||||
}
|
||||
|
||||
/// Pages by roughly one viewport in the requested direction. The actual
|
||||
/// scroll position is tracked via `scrollIndex` — the index of the card we
|
||||
/// want pinned to the leading edge after the scroll.
|
||||
private func scrollBy(_ direction: Int, proxy: ScrollViewProxy) {
|
||||
let cardsPerView = max(1, Int(viewportWidth / (cardWidth + spacing)))
|
||||
let next = max(0, min(items.count - 1, scrollIndex + direction * cardsPerView))
|
||||
scrollIndex = next
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
proxy.scrollTo(items[next].syncKey, anchor: .leading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -22,7 +22,11 @@ struct LibraryGridView: View {
|
||||
private var gridContent: some View {
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "" }
|
||||
|
||||
@@ -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<String> = []
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Unified right-click / long-press menu for any `LibraryItem` shown on the
|
||||
/// Home, Library, or Heruntergeladen screens. Includes both download actions
|
||||
/// (start / cancel / delete files) AND a "Fortschritt entfernen" action when
|
||||
/// the user has any server-side progress on the item.
|
||||
///
|
||||
/// The "Fortschritt entfernen" row was previously Home-only; the download
|
||||
/// rows were previously Library-only — combining them in one place means the
|
||||
/// same menu shows up regardless of which screen the user is on.
|
||||
@ViewBuilder
|
||||
func mediaItemContextMenu(for item: LibraryItem, app: AppState) -> some View {
|
||||
let key = item.downloadKey
|
||||
let state = app.downloads.state(for: key)
|
||||
if item.isPodcastContainer {
|
||||
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
|
||||
} else {
|
||||
switch state {
|
||||
case .notDownloaded, .failed:
|
||||
Button {
|
||||
app.downloads.startDownload(item: item)
|
||||
} label: {
|
||||
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
|
||||
}
|
||||
case .downloading:
|
||||
Button {
|
||||
app.downloads.cancel(downloadKey: key)
|
||||
} label: {
|
||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
||||
}
|
||||
case .downloaded:
|
||||
Button(role: .destructive) {
|
||||
app.downloads.delete(downloadKey: key)
|
||||
} label: {
|
||||
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.progressCache[item.syncKey] != nil {
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
Task { await app.removeProgress(itemId: item.id, episodeId: item.episodeId) }
|
||||
} label: {
|
||||
Label("Fortschritt entfernen", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,10 @@ struct PlayerBar: View {
|
||||
|
||||
rateMenu
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user