Merge iOS and Mac app into one

This commit is contained in:
Scarriffle
2026-05-17 21:06:59 +02:00
parent 069f8bac2d
commit ac7906f0cf
72 changed files with 1277 additions and 3408 deletions

View File

@@ -0,0 +1,53 @@
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
#if os(iOS)
@State private var splashVisible = true
#endif
var body: some View {
ZStack {
mainContent
#if os(iOS)
if splashVisible {
SplashView()
.zIndex(10)
.transition(.opacity.animation(.easeOut(duration: 0.55)))
}
#endif
}
.task { await boot() }
}
@ViewBuilder
private var mainContent: some View {
Group {
if app.auth.isLoggedIn {
MainView()
} else {
LoginView()
}
}
#if os(iOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#else
.frame(minWidth: 900, minHeight: 600)
#endif
}
private func boot() async {
#if os(iOS)
// Run bootstrap and minimum splash time in parallel;
// dismiss splash only after BOTH complete.
await withTaskGroup(of: Void.self) { group in
group.addTask { await app.bootstrap() }
group.addTask { try? await Task.sleep(for: .seconds(1.2)) }
await group.waitForAll()
}
withAnimation { splashVisible = false }
#else
await app.bootstrap()
#endif
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct LibraryGridView: View {
let items: [LibraryItem]
var onRefresh: (() async -> Void)? = nil
var onSelect: (LibraryItem) -> Void
var body: some View {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: 8) {
ForEach(items) { item in
LibraryItemCell(item: item)
.onTapGesture { onSelect(item) }
}
}
#if os(iOS)
.padding(.horizontal, 8)
.padding(.vertical, 4)
#else
.padding(20)
#endif
}
#if os(iOS)
.refreshable { await onRefresh?() }
#endif
}
private var gridColumns: [GridItem] {
#if os(iOS)
// 3 equal columns compact spacing for full height utilization
[GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible())]
#else
[GridItem(.adaptive(minimum: 180), spacing: 20)]
#endif
}
}

View File

@@ -0,0 +1,201 @@
import SwiftUI
struct LibraryItemCell: View {
@Environment(AppState.self) private var app
let item: LibraryItem
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
cover
downloadBadge.padding(4)
}
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 3)
.padding(.bottom, 3)
}
Text(item.title)
#if os(iOS)
.font(.system(size: 11, weight: .bold))
#else
.font(.headline)
#endif
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(item.author)
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
}
// Ensure the cell fills its full grid column width
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { downloadMenuItems }
}
// MARK: - Cover
private var cover: some View {
#if os(iOS)
iOSCover
#else
macosCover
#endif
}
#if os(iOS)
private var iOSCover: some View {
Color(.systemGray6) // neutral bg for PNG transparent areas
.frame(maxWidth: .infinity) // explicitly fill the column width
.aspectRatio(1, contentMode: .fit)
.overlay {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView().tint(.accentColor)
}
.clipped() // clip image overflow before rounding
} else {
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
#endif
#if os(macOS)
private var macosCover: 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))
}
#endif
// MARK: - Download badge
@ViewBuilder
private var downloadBadge: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
case .downloading(let p):
DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 26, height: 26)
#else
.frame(width: 32, height: 32)
#endif
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
.shadow(radius: 2)
case .notDownloaded:
EmptyView()
}
}
// MARK: - Context menu
@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")
}
}
}
}
}
// MARK: - Shared components
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.accentColor)
.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.accentColor, 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: 10, weight: .bold))
.foregroundStyle(.white)
}
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
}
}

View File

@@ -0,0 +1,189 @@
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]
var onRefresh: (() async -> Void)? = nil
let onSelect: (LibraryItem) -> Void
var body: some View {
#if os(iOS)
List {
ForEach(items) { item in
LibraryListRow(item: item)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
.listStyle(.plain)
.refreshable {
await onRefresh?()
}
#else
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)
}
#endif
}
}
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)
#if os(macOS)
.padding(.trailing, 40)
#endif
}
}
Spacer(minLength: 8)
#if os(macOS)
if item.durationSeconds > 0 {
Text(formatDuration(item.durationSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
#endif
downloadStatus
#if os(macOS)
.frame(width: 28)
#endif
}
#if os(macOS)
.padding(.horizontal, 16)
.padding(.vertical, 8)
#endif
.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)
}
}
#if os(iOS)
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 6))
#else
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
}
@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)
#if os(iOS)
.frame(width: 22, height: 22)
#else
.frame(width: 24, height: 24)
#endif
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")
}
}
}
}
#if os(macOS)
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"
}
#endif
}

View File

@@ -0,0 +1,200 @@
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 {
#if os(iOS)
iOSBody
#else
macOSBody
#endif
}
// MARK: - iOS
#if os(iOS)
private var iOSBody: some View {
VStack(spacing: 0) {
// Header with green gradient background
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.85), Color.accentColor.opacity(0.55)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea(edges: .top)
VStack(spacing: 10) {
Image(systemName: "books.vertical.fill")
.font(.system(size: 52, weight: .regular))
.foregroundStyle(.white)
Text("ABS Client")
.font(.title.bold())
.foregroundStyle(.white)
Text("Verbinde dich mit deinem Audiobookshelf-Server")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
}
.padding(.top, 56)
.padding(.bottom, 32)
.padding(.horizontal, 24)
}
.fixedSize(horizontal: false, vertical: true)
// Form fields uses native iOS Form appearance
Form {
Section {
TextField("https://abs.example.com", text: $serverURL)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.next)
} header: {
Text("Server-URL")
}
Section {
TextField("Benutzername", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.next)
SecureField("Passwort", text: $password)
.textContentType(.password)
.submitLabel(.go)
.onSubmit { if canLogin { doLogin() } }
} header: {
Text("Anmeldedaten")
}
Section {
Toggle("Anmeldung merken", isOn: $remember)
}
if let err = app.auth.errorMessage {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.callout)
}
}
Section {
Button(action: doLogin) {
HStack {
Spacer()
if isLoading {
ProgressView()
} else {
Text("Einloggen").bold()
}
Spacer()
}
}
.disabled(!canLogin)
}
}
.scrollContentBackground(.hidden)
.background(Color(.systemGroupedBackground))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground).ignoresSafeArea())
}
private var canLogin: Bool {
!isLoading && !serverURL.isEmpty && !username.isEmpty && !password.isEmpty
}
#endif
// MARK: - macOS
#if os(macOS)
private var macOSBody: 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") {
TextField("", text: $serverURL)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
labeledField(label: "Benutzername") {
TextField("", text: $username)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
labeledField(label: "Passwort") {
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)
}
@ViewBuilder
private func labeledField<C: View>(label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
content()
}
}
#endif
// MARK: - Shared
private func doLogin() {
isLoading = true
Task {
await app.auth.login(
serverURL: serverURL,
username: username,
password: password,
remember: remember
)
isLoading = false
}
}
}

View File

@@ -0,0 +1,339 @@
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
#if os(iOS)
@State private var showSettings: Bool = false
#endif
private var layout: LibraryLayout {
LibraryLayout(rawValue: layoutRaw) ?? .grid
}
var body: some View {
// Modifiers like .task and .onChange cannot chain after a #if/#endif block
// in a @ViewBuilder wrap the conditional nav in a separate property instead.
navigationRoot
.task { await loadAll() }
.onChange(of: vm.selection) { _, _ in
navPath.removeAll()
Task { await loadAll() }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
}
}
@ViewBuilder
private var navigationRoot: some View {
#if os(iOS)
NavigationStack(path: $navPath) {
detail
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
libraryMenu
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Ansicht", selection: $layoutRaw) {
ForEach(LibraryLayout.allCases) { l in
Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
}
}
Divider()
Button {
showSettings = true
} label: {
Label("Einstellungen", systemImage: "gearshape")
}
Divider()
statusMenuSection
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.navigationDestination(for: LibraryItem.self) { podcast in
PodcastDetailView(podcast: podcast)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.environment(app)
}
#else
NavigationSplitView {
sidebar
} detail: {
NavigationStack(path: $navPath) {
detail
.navigationDestination(for: LibraryItem.self) { podcast in
PodcastDetailView(podcast: podcast)
}
}
}
#endif
}
private func loadAll() async {
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
private func handleSelect(_ item: LibraryItem) {
if item.isPodcastContainer {
navPath.append(item)
} else {
Task { await app.play(item: item) }
}
}
// MARK: - macOS sidebar
#if os(macOS)
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)
}
#endif
// MARK: - Detail content (shared)
@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, onRefresh: loadAll) { handleSelect($0) }
case .list:
LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
}
}
#if os(macOS)
.navigationTitle(currentTitle)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await loadAll() }
} 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")
}
}
#endif
}
}
// MARK: - iOS-only helpers
#if os(iOS)
private var libraryMenu: some View {
Menu {
Picker("Bibliothek", selection: Binding(
get: { vm.selection ?? .library("") },
set: { vm.selection = $0 }
)) {
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)
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: selectionIcon)
Text(currentTitle)
.lineLimit(1)
.font(.headline)
Image(systemName: "chevron.down")
.font(.caption)
}
.foregroundStyle(.primary)
}
}
@ViewBuilder
private var statusMenuSection: some View {
Section(app.auth.username.isEmpty ? "Status" : "Angemeldet als \(app.auth.username)") {
Label(app.network.isOnline ? "Online" : "Offline",
systemImage: app.network.isOnline ? "wifi" : "wifi.slash")
if app.sync.queuedCount > 0 {
Label("\(app.sync.queuedCount) Synchronisationen wartend",
systemImage: "arrow.triangle.2.circlepath")
}
}
}
private var selectionIcon: String {
switch vm.selection {
case .downloaded: return "arrow.down.circle.fill"
default: return "books.vertical"
}
}
#endif
// MARK: - Shared helpers
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,284 @@
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)
#if os(iOS)
.padding(.top, 8)
.padding(.bottom, 10)
#else
.padding(.vertical, 10)
#endif
.background(.bar)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else if app.isPreparingPlayback {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
ProgressView()
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
}
}
}
// MARK: - Platform layouts
#if os(iOS)
@ViewBuilder
private func content(item: LibraryItem) -> some View {
VStack(spacing: 8) {
// Header row: cover, title/author, play button
HStack(spacing: 12) {
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)
}
}
Spacer(minLength: 0)
Button { app.togglePlay() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
scrubber
HStack(spacing: 24) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Spacer()
rateMenu
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.top, 2)
}
}
#else
@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 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 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")
}
#endif
// MARK: - Shared subviews
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)
}
}
#if os(iOS)
.frame(width: 44, height: 44)
#else
.frame(width: 48, height: 48)
#endif
.clipShape(RoundedRectangle(cornerRadius: 6))
}
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, 10).padding(.vertical, 5)
.overlay(Capsule().stroke(Color.secondary.opacity(0.4)))
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
.help("Geschwindigkeit")
#endif
}
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 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,269 @@
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"
}
}

View File

@@ -0,0 +1,250 @@
import SwiftUI
struct SettingsView: View {
@Environment(AppState.self) private var app
#if os(iOS)
@Environment(\.dismiss) private var dismiss
#endif
@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 {
#if os(iOS)
NavigationStack {
Form {
connectionSection
playbackSection
appearanceSection
downloadsSection
aboutSection
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Fertig") { dismiss() }
}
}
.confirmationDialog(
"Mit Server abmelden?",
isPresented: $showLogoutConfirm,
titleVisibility: .visible
) {
Button("Abmelden", role: .destructive) {
app.stopPlayback()
app.auth.logout()
dismiss()
}
Button("Abbrechen", role: .cancel) { }
} message: {
Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.")
}
}
#else
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.")
}
#endif
}
// MARK: - iOS Form sections
#if os(iOS)
private var connectionSection: some View {
Section {
LabeledContent("Server") {
Text(app.auth.serverURL.isEmpty ? "" : app.auth.serverURL)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
LabeledContent("Benutzer") {
Text(app.auth.username.isEmpty ? "" : app.auth.username)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
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)
.font(.subheadline)
}
}
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right")
}
} header: {
Text("Verbindung")
} footer: {
Text("Abmelden setzt die gespeicherten Anmeldedaten zurück. Heruntergeladene Inhalte bleiben.")
}
}
private var playbackSection: some View {
Section {
Picker("Sprung-Dauer", selection: $skipSeconds) {
ForEach(Self.skipOptions, id: \.self) { sec in
Text("\(sec) s").tag(sec)
}
}
} header: {
Text("Wiedergabe")
} footer: {
Text("Gilt für die Skip-Knöpfe in der Player-Leiste und auf dem Sperrbildschirm.")
}
}
private var appearanceSection: some View {
Section {
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)
} header: {
Text("Darstellung")
}
}
private var downloadsSection: some View {
Section {
LabeledContent("Heruntergeladen") {
Text("\(app.downloads.downloadedItems.count) Einträge")
.foregroundStyle(.secondary)
}
} header: {
Text("Downloads")
} footer: {
Text("Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden.")
}
}
private var aboutSection: some View {
Section {
LabeledContent("Version") {
Text(appVersion).foregroundStyle(.secondary)
}
} header: {
Text("Über")
}
}
#endif
// MARK: - macOS TabView panes
#if os(macOS)
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)
}
#endif
// MARK: - Shared
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))"
}
}

View File

@@ -0,0 +1,95 @@
import SwiftUI
struct SplashView: View {
@State private var appeared = false
var body: some View {
ZStack {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
VStack(spacing: 36) {
// Animated icon
ZStack {
// Outer glow pulse
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 180, height: 180)
.scaleEffect(appeared ? 1.0 : 0.2)
.blur(radius: appeared ? 12 : 40)
.animation(.easeOut(duration: 1.1), value: appeared)
// Ring border
Circle()
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
.frame(width: 130, height: 130)
.scaleEffect(appeared ? 1.0 : 0.4)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.8).delay(0.1), value: appeared)
// Book icon springs into place
Image(systemName: "books.vertical.fill")
.font(.system(size: 58, weight: .regular))
.foregroundStyle(Color.accentColor)
.scaleEffect(appeared ? 1.0 : 0.1)
.opacity(appeared ? 1 : 0)
.animation(.spring(duration: 0.65, bounce: 0.55), value: appeared)
.symbolEffect(.pulse.byLayer,
options: .speed(0.5).repeating,
value: appeared)
}
// Text
VStack(spacing: 6) {
Text("ABS Client")
.font(.title2.bold())
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 18)
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
Text("Audiobookshelf")
.font(.subheadline)
.foregroundStyle(.secondary)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.45).delay(0.42), value: appeared)
}
}
// Loading dots at bottom
VStack {
Spacer()
LoadingDots()
.opacity(appeared ? 1 : 0)
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
.padding(.bottom, 60)
}
}
.onAppear { appeared = true }
}
}
private struct LoadingDots: View {
@State private var phase: Int = 0
var body: some View {
HStack(spacing: 7) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
.frame(width: 7, height: 7)
.scaleEffect(phase == i ? 1.25 : 1.0)
.animation(.easeInOut(duration: 0.35), value: phase)
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
phase = (phase + 1) % 3
}
}
}
}