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:
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal file
368
ABS Client/Audiobookshelf swift/Views/HomeView.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user