Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
2026-05-17 21:06:59 +02:00

270 lines
9.6 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?
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.navigationTitle(podcastDetail?.title ?? podcast.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.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 os(iOS)
List {
ForEach(episodes, id: \.id) { ep in
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
.contentShape(Rectangle())
.onTapGesture {
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
}
}
}
.listStyle(.plain)
#else
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
.contentShape(Rectangle())
.onTapGesture {
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
}
if idx < episodes.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
}
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 {
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)
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 { contextMenuItems }
}
@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:
Image(systemName: "checkmark.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)")
}
}
@ViewBuilder
private var contextMenuItems: some View {
let key = syntheticItem.downloadKey
let state = app.downloads.state(for: key)
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: syntheticItem)
} label: {
Label("Folge herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button {
app.downloads.cancel(downloadKey: key)
} label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Folge löschen", systemImage: "trash")
}
}
}
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"
}
}