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:
Scarriffle
2026-06-23 08:54:43 +02:00
parent 1dbd133b75
commit a17a61ad9a
20 changed files with 1423 additions and 267 deletions

View File

@@ -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: