- 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>
369 lines
14 KiB
Swift
369 lines
14 KiB
Swift
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
|
|
}
|