- 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>
276 lines
10 KiB
Swift
276 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct PodcastDetailView: View {
|
|
@Environment(AppState.self) private var app
|
|
let podcast: LibraryItem
|
|
|
|
@State private var episodes: [PodcastEpisode] = []
|
|
@State private var podcastDetail: LibraryItem?
|
|
@State private var isLoading: Bool = true
|
|
@State private var errorMessage: String?
|
|
@State private var searchText: String = ""
|
|
|
|
/// Bevorzugt die nach `fetchEpisodes` aufgefrischte Detail-Version
|
|
/// des Podcasts (mit Cover, Description etc.), fällt auf den initial
|
|
/// übergebenen Container zurück solange der Fetch noch läuft oder
|
|
/// fehlgeschlagen ist.
|
|
private var resolvedPodcast: LibraryItem { podcastDetail ?? podcast }
|
|
|
|
private var filteredEpisodes: [PodcastEpisode] {
|
|
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !q.isEmpty else { return episodes }
|
|
return episodes.filter { ep in
|
|
ep.title.localizedCaseInsensitiveContains(q)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
content
|
|
}
|
|
.navigationTitle(resolvedPodcast.title)
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen")
|
|
.task { await load() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 14) {
|
|
if let url = app.client.coverURL(itemId: podcast.id) {
|
|
AsyncImage(url: url) { phase in
|
|
if let img = phase.image { img.resizable().aspectRatio(contentMode: .fill) }
|
|
else { Color.gray.opacity(0.3) }
|
|
}
|
|
.frame(width: 72, height: 72)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
#if os(iOS)
|
|
Text(podcast.title).font(.headline).lineLimit(2)
|
|
#else
|
|
Text(podcast.title).font(.title3).bold().lineLimit(2)
|
|
#endif
|
|
Text(podcast.author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
|
|
if !episodes.isEmpty {
|
|
Text("\(episodes.count) Folge\(episodes.count == 1 ? "" : "n")")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if isLoading {
|
|
ProgressView("Lade Folgen …")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let err = errorMessage {
|
|
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
|
} else if episodes.isEmpty {
|
|
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
|
} else if filteredEpisodes.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
} else {
|
|
#if os(iOS)
|
|
List {
|
|
ForEach(filteredEpisodes, id: \.id) { ep in
|
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
#else
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in
|
|
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
|
}
|
|
if idx < filteredEpisodes.count - 1 {
|
|
Divider().padding(.leading, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
isLoading = true
|
|
do {
|
|
let (detail, eps) = try await app.client.fetchEpisodes(podcastItemId: podcast.id)
|
|
podcastDetail = detail
|
|
episodes = eps
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private struct EpisodeRow: View {
|
|
@Environment(AppState.self) private var app
|
|
let podcast: LibraryItem
|
|
let episode: PodcastEpisode
|
|
|
|
private var syntheticItem: LibraryItem {
|
|
var item = LibraryItem(
|
|
id: podcast.id,
|
|
title: episode.title,
|
|
author: podcast.title,
|
|
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
|
|
audioFiles: [episode.audioFile]
|
|
)
|
|
item.mediaType = "podcast"
|
|
item.episodeId = episode.id
|
|
return item
|
|
}
|
|
|
|
private var isListened: Bool {
|
|
app.progressCache[syntheticItem.syncKey]?.isFinished ?? false
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.title)
|
|
.foregroundStyle(.tint)
|
|
#if os(macOS)
|
|
.frame(width: 28)
|
|
#endif
|
|
.padding(.top, 2)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(episode.title)
|
|
#if os(iOS)
|
|
.font(.subheadline).bold()
|
|
#else
|
|
.font(.headline)
|
|
#endif
|
|
.lineLimit(2)
|
|
HStack(spacing: 10) {
|
|
if let date = episode.formattedDate {
|
|
Label(date, systemImage: "calendar")
|
|
.labelStyle(.titleAndIcon)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if episode.durationSeconds > 0 {
|
|
Label(formatDuration(episode.durationSeconds), systemImage: "clock")
|
|
.labelStyle(.titleAndIcon)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
#if os(macOS)
|
|
if let season = episode.season, !season.isEmpty {
|
|
Text("S\(season)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if let ep = episode.episode, !ep.isEmpty {
|
|
Text("F\(ep)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
#endif
|
|
}
|
|
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
|
if frac > 0 && !isListened {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
|
RoundedRectangle(cornerRadius: 1.5).fill(Color.green)
|
|
.frame(width: max(2, geo.size.width * frac))
|
|
}
|
|
}
|
|
.frame(height: 3)
|
|
.padding(.top, 2)
|
|
#if os(macOS)
|
|
.padding(.trailing, 40)
|
|
#endif
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
if isListened {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.white, .green)
|
|
.font(.title3)
|
|
#if os(macOS)
|
|
.padding(.top, 4)
|
|
#endif
|
|
}
|
|
downloadButton
|
|
#if os(macOS)
|
|
.frame(width: 32)
|
|
.padding(.top, 4)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
#else
|
|
.padding(.vertical, 4)
|
|
#endif
|
|
.contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var downloadButton: some View {
|
|
let key = syntheticItem.downloadKey
|
|
let state = app.downloads.state(for: key)
|
|
switch state {
|
|
case .notDownloaded:
|
|
Button {
|
|
app.downloads.startDownload(item: syntheticItem)
|
|
} label: {
|
|
Image(systemName: "arrow.down.circle")
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
#if os(macOS)
|
|
.help("Episode für Offline herunterladen")
|
|
#endif
|
|
case .downloading(let p):
|
|
DownloadProgressRing(progress: p)
|
|
.frame(width: 22, height: 22)
|
|
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
|
case .downloaded:
|
|
// Down-arrow = downloaded; checkmark is reserved for the listened
|
|
// indicator that may sit next to this in the row trailing area.
|
|
Image(systemName: "arrow.down.circle.fill")
|
|
.foregroundStyle(.white, .green)
|
|
.font(.title3)
|
|
case .failed(let msg):
|
|
Button {
|
|
app.downloads.startDownload(item: syntheticItem)
|
|
} label: {
|
|
Image(systemName: "exclamationmark.arrow.circlepath")
|
|
.font(.title3)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Fehlgeschlagen: \(msg)")
|
|
}
|
|
}
|
|
|
|
private func formatDuration(_ seconds: Double) -> String {
|
|
guard seconds.isFinite, seconds > 0 else { return "" }
|
|
let total = Int(seconds)
|
|
let h = total / 3600
|
|
let m = (total % 3600) / 60
|
|
if h > 0 { return "\(h) h \(m) min" }
|
|
return "\(m) min"
|
|
}
|
|
}
|