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