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 }