Merge iOS and Mac app into one
This commit is contained in:
53
ABS Client/Audiobookshelf swift/Views/ContentView.swift
Normal file
53
ABS Client/Audiobookshelf swift/Views/ContentView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
38
ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift
Normal file
38
ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
201
ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Normal file
201
ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
189
ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
Normal file
189
ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
Normal 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
|
||||
}
|
||||
200
ABS Client/Audiobookshelf swift/Views/LoginView.swift
Normal file
200
ABS Client/Audiobookshelf swift/Views/LoginView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
339
ABS Client/Audiobookshelf swift/Views/MainView.swift
Normal file
339
ABS Client/Audiobookshelf swift/Views/MainView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
284
ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
Normal file
284
ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
269
ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
Normal file
269
ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
250
ABS Client/Audiobookshelf swift/Views/SettingsView.swift
Normal file
250
ABS Client/Audiobookshelf swift/Views/SettingsView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
95
ABS Client/Audiobookshelf swift/Views/SplashView.swift
Normal file
95
ABS Client/Audiobookshelf swift/Views/SplashView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user