Restructure project folders

This commit is contained in:
Scarriffle
2026-05-17 08:45:37 +02:00
parent 652cfc4cf4
commit 069f8bac2d
80 changed files with 6102 additions and 547 deletions

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
var body: some View {
Group {
if app.auth.isLoggedIn {
MainView()
} else {
LoginView()
}
}
.frame(minWidth: 900, minHeight: 600)
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
struct LibraryGridView: View {
let items: [LibraryItem]
let onSelect: (LibraryItem) -> Void
private let columns = [GridItem(.adaptive(minimum: 180), spacing: 20)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(items) { item in
LibraryItemCell(item: item)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
}
}
.padding(20)
}
}
}

View File

@@ -0,0 +1,158 @@
import SwiftUI
struct LibraryItemCell: View {
@Environment(AppState.self) private var app
let item: LibraryItem
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
cover
downloadBadge
.padding(8)
}
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 6)
.padding(.bottom, 6)
}
Text(item.title)
.font(.headline)
.lineLimit(2, reservesSpace: true)
.multilineTextAlignment(.leading)
Text(item.author)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1, reservesSpace: true)
}
.contextMenu {
downloadMenuItems
}
}
private var cover: some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
Rectangle().fill(.quaternary)
.overlay(ProgressView().controlSize(.small))
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
case .failure:
Rectangle().fill(.quaternary)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
@unknown default:
Rectangle().fill(.quaternary)
}
}
} else {
Rectangle().fill(.quaternary)
}
}
.frame(width: 180, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var downloadBadge: some View {
let state = app.downloads.state(for: item.syncKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
case .downloading(let p):
DownloadProgressRing(progress: p)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
.shadow(radius: 2)
case .notDownloaded:
EmptyView()
}
}
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.syncKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
// Whole-podcast downloads aren't supported; instructions only.
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: item)
} label: {
Label("Für Offline 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 Dateien löschen", systemImage: "trash")
}
}
}
}
}
/// Green progress bar drawn at the bottom of a cover (grid view).
/// Hidden completely when there's no known progress.
struct CoverProgressBar: View {
let fraction: Double
var body: some View {
if fraction > 0 {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.black.opacity(0.55))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.green)
.frame(width: max(2, geo.size.width * fraction), height: 4)
}
}
.frame(height: 4)
.shadow(color: .black.opacity(0.35), radius: 1, y: 1)
}
}
}
struct DownloadProgressRing: View {
let progress: Double
var body: some View {
ZStack {
Circle()
.fill(Color.black.opacity(0.75))
Circle()
.stroke(Color.white.opacity(0.25), lineWidth: 3)
.padding(4)
Circle()
.trim(from: 0, to: max(0.03, min(progress, 1)))
.stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(4)
.animation(.easeInOut(duration: 0.25), value: progress)
Image(systemName: "arrow.down")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 32, height: 32)
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
.help("Wird heruntergeladen … \(Int(progress * 100)) %")
}
}

View File

@@ -0,0 +1,154 @@
import SwiftUI
enum LibraryLayout: String, CaseIterable, Identifiable {
case grid
case list
var id: String { rawValue }
var label: String { self == .grid ? "Kachelansicht" : "Listenansicht" }
var systemImage: String { self == .grid ? "square.grid.2x2" : "list.bullet" }
}
struct LibraryListView: View {
let items: [LibraryItem]
let onSelect: (LibraryItem) -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
LibraryListRow(item: item)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
if idx < items.count - 1 {
Divider().padding(.leading, 76)
}
}
}
.padding(.vertical, 4)
}
}
}
struct LibraryListRow: View {
@Environment(AppState.self) private var app
let item: LibraryItem
var body: some View {
HStack(spacing: 12) {
cover
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.headline)
.lineLimit(1)
Text(item.author)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
if fraction > 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 * fraction))
}
}
.frame(height: 3)
.padding(.top, 2)
.padding(.trailing, 40)
}
}
Spacer(minLength: 8)
if item.durationSeconds > 0 {
Text(formatDuration(item.durationSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
downloadStatus
.frame(width: 28)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.contextMenu { downloadMenuItems }
}
private var cover: some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
Rectangle().fill(.quaternary)
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
case .failure:
Rectangle().fill(.quaternary)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
@unknown default:
Rectangle().fill(.quaternary)
}
}
} else {
Rectangle().fill(.quaternary)
}
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
@ViewBuilder
private var downloadStatus: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
case .downloading(let p):
DownloadProgressRing(progress: p)
.frame(width: 24, height: 24)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
case .notDownloaded:
EmptyView()
}
}
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.downloadKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button { app.downloads.startDownload(item: item) } label: {
Label("Für Offline 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 Dateien 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"
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
struct LoginView: View {
@Environment(AppState.self) private var app
@State private var serverURL: String = ""
@State private var username: String = ""
@State private var password: String = ""
@State private var remember: Bool = true
@State private var isLoading: Bool = false
var body: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "books.vertical.fill")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("ABS Client")
.font(.largeTitle).bold()
Text("Verbinde dich mit deinem Audiobookshelf-Server")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12) {
LabeledField(label: "Server-URL", placeholder: "https://abs.example.com") {
TextField("", text: $serverURL)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
LabeledField(label: "Benutzername", placeholder: "user") {
TextField("", text: $username)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
LabeledField(label: "Passwort", placeholder: "••••••") {
SecureField("", text: $password)
.textFieldStyle(.roundedBorder)
}
Toggle("Anmeldung merken", isOn: $remember)
.toggleStyle(.checkbox)
}
.frame(maxWidth: 380)
if let err = app.auth.errorMessage {
Text(err)
.foregroundStyle(.red)
.font(.callout)
.multilineTextAlignment(.center)
.frame(maxWidth: 380)
}
Button(action: doLogin) {
if isLoading {
ProgressView().controlSize(.small)
} else {
Text("Einloggen")
.frame(maxWidth: 200)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isLoading || serverURL.isEmpty || username.isEmpty || password.isEmpty)
.keyboardShortcut(.defaultAction)
Spacer()
}
.padding(32)
}
private func doLogin() {
isLoading = true
Task {
await app.auth.login(
serverURL: serverURL,
username: username,
password: password,
remember: remember
)
isLoading = false
}
}
}
private struct LabeledField<Content: View>: View {
let label: String
let placeholder: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
content
}
}
}

View File

@@ -0,0 +1,236 @@
import SwiftUI
enum LibraryFilter: Hashable {
case library(String)
case downloaded
}
@Observable
@MainActor
final class LibraryViewModel {
var libraries: [Library] = []
var items: [LibraryItem] = []
var isLoading: Bool = false
var errorMessage: String?
var selection: LibraryFilter?
func loadLibraries(client: ABSClient) async {
isLoading = true
defer { isLoading = false }
do {
libraries = try await client.fetchLibraries()
if selection == nil, let first = libraries.first {
selection = .library(first.id)
}
} catch {
errorMessage = error.localizedDescription
}
}
func loadItems(client: ABSClient, downloads: DownloadManager) async {
guard let selection else { return }
isLoading = true
defer { isLoading = false }
switch selection {
case .library(let id):
do {
items = try await client.fetchItems(libraryId: id)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
case .downloaded:
items = downloads.downloadedItems.values.map { di in
let files: [AudioFile] = di.tracks.enumerated().map { idx, t in
AudioFile(
ino: t.ino,
filename: t.filename,
ext: "",
durationSeconds: t.durationSeconds,
index: idx
)
}
var li = LibraryItem(
id: di.itemId,
title: di.title,
author: di.author,
durationSeconds: di.durationSeconds,
audioFiles: files
)
if let episodeId = di.episodeId {
li.mediaType = "podcast"
li.episodeId = episodeId
}
return li
}.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
errorMessage = nil
}
}
}
struct MainView: View {
@Environment(AppState.self) private var app
@State private var vm = LibraryViewModel()
@State private var navPath: [LibraryItem] = []
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
private var layout: LibraryLayout {
LibraryLayout(rawValue: layoutRaw) ?? .grid
}
var body: some View {
NavigationSplitView {
sidebar
} detail: {
NavigationStack(path: $navPath) {
detail
.navigationDestination(for: LibraryItem.self) { podcast in
PodcastDetailView(podcast: podcast)
}
}
}
.task {
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
.onChange(of: vm.selection) { _, _ in
navPath.removeAll()
Task {
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
}
}
private func handleSelect(_ item: LibraryItem) {
if item.isPodcastContainer {
navPath.append(item)
} else {
Task { await app.play(item: item) }
}
}
private var sidebar: some View {
List(selection: $vm.selection) {
Section("Bibliotheken") {
ForEach(vm.libraries) { lib in
Label(lib.name, systemImage: "books.vertical")
.tag(LibraryFilter.library(lib.id))
}
}
Section("Offline") {
Label("Heruntergeladen", systemImage: "arrow.down.circle.fill")
.tag(LibraryFilter.downloaded)
}
}
.listStyle(.sidebar)
.navigationTitle("ABS Client")
.safeAreaInset(edge: .bottom) {
sidebarFooter
}
}
private var sidebarFooter: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
HStack(spacing: 8) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 8, height: 8)
Text(app.network.isOnline ? "Online" : "Offline")
.font(.caption)
if app.sync.queuedCount > 0 {
Text("(\(app.sync.queuedCount) wartend)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack {
Text(app.auth.username).font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Abmelden") {
app.stopPlayback()
app.auth.logout()
}
.buttonStyle(.borderless)
.font(.caption)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
@ViewBuilder
private var detail: some View {
if vm.isLoading && vm.items.isEmpty {
ProgressView("Lade Bibliothek …")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let err = vm.errorMessage, vm.items.isEmpty {
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
} else if vm.items.isEmpty {
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
} else {
Group {
switch layout {
case .grid:
LibraryGridView(items: vm.items) { item in
handleSelect(item)
}
case .list:
LibraryListView(items: vm.items) { item in
handleSelect(item)
}
}
}
.navigationTitle(currentTitle)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task {
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
} label: {
if vm.isLoading {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.clockwise")
}
}
.help("Bibliothek, Cover und Hörfortschritte neu laden")
.disabled(vm.isLoading)
}
ToolbarItem(placement: .primaryAction) {
Picker("Ansicht", selection: $layoutRaw) {
ForEach(LibraryLayout.allCases) { l in
Image(systemName: l.systemImage)
.help(l.label)
.tag(l.rawValue)
}
}
.pickerStyle(.segmented)
.help("Zwischen Kachel- und Listenansicht wechseln")
}
}
}
}
private var currentTitle: String {
switch vm.selection {
case .library(let id):
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
case .downloaded:
return "Heruntergeladen"
case .none:
return "Bibliothek"
}
}
}

View File

@@ -0,0 +1,212 @@
import SwiftUI
struct PlayerBar: View {
@Environment(AppState.self) private var app
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@State private var scrubbing: Bool = false
@State private var scrubValue: Double = 0
var body: some View {
if let item = app.currentItem {
VStack(spacing: 0) {
Divider()
content(item: item)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else if app.isPreparingPlayback {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
ProgressView().controlSize(.small)
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
}
}
}
@ViewBuilder
private func content(item: LibraryItem) -> some View {
HStack(spacing: 14) {
cover(item: item)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.subheadline).bold().lineLimit(1)
Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let err = app.player.errorMessage {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
}
.frame(minWidth: 160, idealWidth: 200, maxWidth: 240, alignment: .leading)
transportControls
scrubber
.frame(minWidth: 200)
rateMenu
Spacer(minLength: 0)
statusIndicator
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Wiedergabe beenden")
}
}
private func cover(item: LibraryItem) -> some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
if let img = phase.image {
img.resizable().aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.3)
}
}
} else {
Color.gray.opacity(0.3)
}
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var transportControls: some View {
HStack(spacing: 14) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.togglePlay() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 34))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
.keyboardShortcut(.space, modifiers: [])
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
}
private var skipForwardImage: String {
switch skipSeconds {
case ...10: return "goforward.10"
case 11...15: return "goforward.15"
case 16...30: return "goforward.30"
case 31...45: return "goforward.45"
case 46...60: return "goforward.60"
default: return "goforward.90"
}
}
private var skipBackImage: String {
switch skipSeconds {
case ...10: return "gobackward.10"
case 11...15: return "gobackward.15"
case 16...30: return "gobackward.30"
case 31...45: return "gobackward.45"
case 46...60: return "gobackward.60"
default: return "gobackward.90"
}
}
private var scrubber: some View {
VStack(spacing: 2) {
Slider(
value: Binding(
get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime },
set: { scrubValue = $0; scrubbing = true }
),
in: 0...max(app.player.totalDuration, 1),
onEditingChanged: { editing in
if !editing {
app.seekAbsolute(scrubValue)
scrubbing = false
}
}
)
.disabled(!app.player.isReady)
HStack {
Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(formatTime(app.player.totalDuration))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
private var rateMenu: some View {
Menu {
ForEach([0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { r in
Button {
app.setRate(Float(r))
} label: {
HStack {
Text(String(format: "%.2g×", r))
if abs(Double(app.player.rate) - r) < 0.01 {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Text(String(format: "%.2g×", Double(app.player.rate)))
.font(.caption.monospacedDigit())
.padding(.horizontal, 8).padding(.vertical, 4)
.overlay(Capsule().stroke(Color.secondary.opacity(0.4)))
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Geschwindigkeit")
}
private var statusIndicator: some View {
HStack(spacing: 4) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 6, height: 6)
if app.sync.queuedCount > 0 {
Text("\(app.sync.queuedCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.help(app.network.isOnline ? "Online Fortschritt wird synchronisiert" : "Offline \(app.sync.queuedCount) Eintrag/Einträge wartend")
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let total = Int(seconds)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 {
return String(format: "%d:%02d:%02d", h, m, s)
}
return String(format: "%d:%02d", m, s)
}
}

View File

@@ -0,0 +1,233 @@
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"
}
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
struct SettingsView: View {
@Environment(AppState.self) private var app
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
@State private var showLogoutConfirm: Bool = false
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
var body: some View {
TabView {
connectionPane
.tabItem { Label("Verbindung", systemImage: "server.rack") }
playbackPane
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
appearancePane
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
aboutPane
.tabItem { Label("Über", systemImage: "info.circle") }
}
.padding(20)
.frame(width: 480, height: 320)
.confirmationDialog(
"Mit Server abmelden?",
isPresented: $showLogoutConfirm,
titleVisibility: .visible
) {
Button("Abmelden", role: .destructive) {
app.stopPlayback()
app.auth.logout()
}
Button("Abbrechen", role: .cancel) { }
} message: {
Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.")
}
}
private var connectionPane: some View {
Form {
LabeledContent("Server") {
Text(app.auth.serverURL.isEmpty ? "" : app.auth.serverURL)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Benutzer") {
Text(app.auth.username.isEmpty ? "" : app.auth.username)
.foregroundStyle(.secondary)
}
LabeledContent("Status") {
HStack(spacing: 6) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 8, height: 8)
Text(app.network.isOnline ? "Online" : "Offline")
if app.sync.queuedCount > 0 {
Text("(\(app.sync.queuedCount) wartend)")
.foregroundStyle(.secondary)
}
}
}
HStack {
Spacer()
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.formStyle(.grouped)
}
private var playbackPane: some View {
Form {
Picker("Sprung-Dauer", selection: $skipSeconds) {
ForEach(Self.skipOptions, id: \.self) { sec in
Text("\(sec) s").tag(sec)
}
}
Text("Gilt für die Skip-Knöpfe in der Player-Leiste und Medientasten.")
.font(.caption)
.foregroundStyle(.secondary)
}
.formStyle(.grouped)
}
private var appearancePane: some View {
Form {
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
ForEach(LibraryLayout.allCases) { l in
Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
}
}
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
}
.formStyle(.grouped)
}
private var aboutPane: some View {
Form {
LabeledContent("Version", value: appVersion)
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
}
.formStyle(.grouped)
}
private var appVersion: String {
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
return "\(v) (\(b))"
}
}