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

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