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

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