Files
ABS-Client/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift
2026-05-17 08:45:37 +02:00

234 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
.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) {
Text(podcast.title).font(.title3).bold().lineLimit(2)
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 {
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)
}
}
}
}
}
}
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)
.frame(width: 28)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(episode.title)
.font(.headline)
.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 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)
}
}
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)
.padding(.trailing, 40)
}
}
Spacer(minLength: 0)
downloadButton
.frame(width: 32)
.padding(.top, 4)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.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)
.help("Episode für Offline herunterladen")
case .downloading(let p):
DownloadProgressRing(progress: p)
.frame(width: 24, height: 24)
.onTapGesture { app.downloads.cancel(downloadKey: key) }
.help("\(Int(p * 100)) % zum Abbrechen klicken")
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.help("Heruntergeladen")
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) zum Wiederholen klicken")
}
}
@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"
}
}