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

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