diff --git a/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
deleted file mode 100644
index 38c8462..0000000
--- a/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json
deleted file mode 100644
index eb87897..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "colors" : [
- {
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Bild.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Bild.png
deleted file mode 100644
index 4f335a0..0000000
Binary files a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Bild.png and /dev/null differ
diff --git a/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift
deleted file mode 100644
index 470b266..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift
+++ /dev/null
@@ -1,20 +0,0 @@
-import SwiftUI
-
-@main
-struct Audiobookshelf_swiftApp: App {
- @State private var appState = AppState()
-
- var body: some Scene {
- WindowGroup {
- ContentView()
- .environment(appState)
- .task { await appState.bootstrap() }
- }
- .windowResizability(.contentSize)
-
- Settings {
- SettingsView()
- .environment(appState)
- }
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift b/ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift
deleted file mode 100644
index 8bb4c25..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift
+++ /dev/null
@@ -1,209 +0,0 @@
-import Foundation
-
-enum ABSClientError: LocalizedError {
- case noAuth
- case invalidURL
- case httpStatus(Int)
- case decoding(Error)
-
- var errorDescription: String? {
- switch self {
- case .noAuth: return "Nicht angemeldet."
- case .invalidURL: return "Ungültige URL."
- case .httpStatus(let code): return "HTTP-Status \(code)."
- case .decoding(let err): return "Antwort konnte nicht gelesen werden: \(err.localizedDescription)"
- }
- }
-}
-
-@MainActor
-final class ABSClient {
- private let auth: AuthStore
- private let session: URLSession
-
- init(auth: AuthStore) {
- self.auth = auth
- let config = URLSessionConfiguration.default
- config.requestCachePolicy = .reloadIgnoringLocalCacheData
- config.waitsForConnectivity = false
- self.session = URLSession(configuration: config)
- }
-
- private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {
- guard !auth.token.isEmpty, !auth.serverURL.isEmpty else { throw ABSClientError.noAuth }
- guard let url = URL(string: auth.serverURL + path) else { throw ABSClientError.invalidURL }
- var req = URLRequest(url: url)
- req.httpMethod = method
- req.setValue("Bearer \(auth.token)", forHTTPHeaderField: "Authorization")
- if let body {
- req.setValue("application/json", forHTTPHeaderField: "Content-Type")
- req.httpBody = body
- }
- return req
- }
-
- private func perform(_ req: URLRequest, as: T.Type) async throws -> T {
- let (data, response) = try await session.data(for: req)
- guard let http = response as? HTTPURLResponse else { throw ABSClientError.httpStatus(0) }
- guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
- do {
- return try JSONDecoder().decode(T.self, from: data)
- } catch {
- throw ABSClientError.decoding(error)
- }
- }
-
- func fetchLibraries() async throws -> [Library] {
- let req = try makeRequest(path: "/api/libraries")
- let dto = try await perform(req, as: LibrariesResponseDTO.self)
- return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
- }
-
- func fetchItems(libraryId: String) async throws -> [LibraryItem] {
- let req = try makeRequest(path: "/api/libraries/\(libraryId)/items?limit=500&sort=media.metadata.title")
- let dto = try await perform(req, as: LibraryItemsResponseDTO.self)
- return dto.results.map { Self.toLibraryItem(from: $0) }
- }
-
- private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
- let meta = raw.media?.metadata
- let mediaType = raw.mediaType ?? "book"
- let files: [AudioFile] = (raw.media?.audioFiles ?? []).enumerated().map { idx, f in
- AudioFile(
- ino: f.ino,
- filename: f.metadata?.filename ?? "track-\(idx).mp3",
- ext: (f.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: ".")),
- durationSeconds: f.duration ?? 0,
- index: f.index ?? idx
- )
- }.sorted { $0.index < $1.index }
- let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
- var item = LibraryItem(
- id: raw.id,
- title: meta?.title ?? "Unbekannt",
- author: authorString,
- durationSeconds: raw.media?.duration ?? 0,
- audioFiles: files
- )
- item.mediaType = mediaType
- item.description = meta?.description
- return item
- }
-
- func fetchEpisodes(podcastItemId: String) async throws -> (LibraryItem, [PodcastEpisode]) {
- let req = try makeRequest(path: "/api/items/\(podcastItemId)?expanded=1")
- let raw = try await perform(req, as: LibraryItemDTO.self)
- let item = Self.toLibraryItem(from: raw)
- let episodes: [PodcastEpisode] = (raw.media?.episodes ?? []).compactMap { ep in
- guard let af = ep.audioFile else { return nil }
- let ext = (af.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: "."))
- let audioFile = AudioFile(
- ino: af.ino,
- filename: af.metadata?.filename ?? "episode.\(ext)",
- ext: ext,
- durationSeconds: af.duration ?? ep.duration ?? 0,
- index: 0
- )
- return PodcastEpisode(
- id: ep.id,
- title: ep.title ?? "Folge",
- pubDate: ep.pubDate,
- publishedAtMillis: ep.publishedAt,
- season: ep.season,
- episode: ep.episode,
- durationSeconds: ep.duration ?? af.duration ?? 0,
- audioFile: audioFile
- )
- }.sorted { lhs, rhs in
- (lhs.publishedAtMillis ?? 0) > (rhs.publishedAtMillis ?? 0)
- }
- return (item, episodes)
- }
-
- func fetchItemDetail(itemId: String) async throws -> LibraryItem {
- let req = try makeRequest(path: "/api/items/\(itemId)?expanded=1")
- let raw = try await perform(req, as: LibraryItemDTO.self)
- return Self.toLibraryItem(from: raw)
- }
-
- private func progressPath(itemId: String, episodeId: String?) -> String {
- if let episodeId { return "/api/me/progress/\(itemId)/\(episodeId)" }
- return "/api/me/progress/\(itemId)"
- }
-
- func fetchProgress(itemId: String, episodeId: String? = nil) async throws -> PlaybackProgress? {
- let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
- let (data, response) = try await session.data(for: req)
- guard let http = response as? HTTPURLResponse else { return nil }
- if http.statusCode == 404 { return nil }
- guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
- let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
- return PlaybackProgress(
- itemId: itemId,
- episodeId: episodeId,
- currentTime: dto.currentTime ?? 0,
- duration: dto.duration ?? 0,
- isFinished: dto.isFinished ?? false,
- updatedAt: Date()
- )
- }
-
- func saveProgress(_ progress: PlaybackProgress) async throws {
- let body: [String: Any] = [
- "currentTime": progress.currentTime,
- "duration": progress.duration,
- "isFinished": progress.isFinished,
- ]
- let data = try JSONSerialization.data(withJSONObject: body)
- let req = try makeRequest(
- path: progressPath(itemId: progress.itemId, episodeId: progress.episodeId),
- method: "PATCH",
- body: data
- )
- let (_, response) = try await session.data(for: req)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
- }
- }
-
- func fetchAllProgress() async throws -> [PlaybackProgress] {
- let req = try makeRequest(path: "/api/me")
- let dto = try await perform(req, as: MeResponseDTO.self)
- return (dto.mediaProgress ?? []).compactMap { p in
- guard let itemId = p.libraryItemId else { return nil }
- return PlaybackProgress(
- itemId: itemId,
- episodeId: p.episodeId,
- currentTime: p.currentTime ?? 0,
- duration: p.duration ?? 0,
- isFinished: p.isFinished ?? false,
- updatedAt: Date()
- )
- }
- }
-
- func validateToken() async -> Bool {
- guard let req = try? makeRequest(path: "/api/me") else { return false }
- do {
- let (_, response) = try await session.data(for: req)
- return ((response as? HTTPURLResponse)?.statusCode ?? 0) < 400
- } catch {
- return false
- }
- }
-
- func coverURL(itemId: String) -> URL? {
- guard !auth.serverURL.isEmpty else { return nil }
- var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/cover")
- comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
- return comps?.url
- }
-
- func audioFileURL(itemId: String, ino: String) -> URL? {
- var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/file/\(ino)")
- comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
- return comps?.url
- }
-
- var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift b/ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift
deleted file mode 100644
index d48720b..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-import SwiftUI
-
-struct ContentView: View {
- @Environment(AppState.self) private var app
-
- var body: some View {
- Group {
- if app.auth.isLoggedIn {
- MainView()
- } else {
- LoginView()
- }
- }
- .frame(minWidth: 900, minHeight: 600)
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift b/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift
deleted file mode 100644
index 6dc6a3a..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-import SwiftUI
-
-struct LibraryGridView: View {
- let items: [LibraryItem]
- let onSelect: (LibraryItem) -> Void
-
- private let columns = [GridItem(.adaptive(minimum: 180), spacing: 20)]
-
- var body: some View {
- ScrollView {
- LazyVGrid(columns: columns, spacing: 20) {
- ForEach(items) { item in
- LibraryItemCell(item: item)
- .contentShape(Rectangle())
- .onTapGesture { onSelect(item) }
- }
- }
- .padding(20)
- }
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift b/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift
deleted file mode 100644
index ee97a8c..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift
+++ /dev/null
@@ -1,158 +0,0 @@
-import SwiftUI
-
-struct LibraryItemCell: View {
- @Environment(AppState.self) private var app
- let item: LibraryItem
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- ZStack(alignment: .bottom) {
- ZStack(alignment: .topTrailing) {
- cover
- downloadBadge
- .padding(8)
- }
- CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
- .padding(.horizontal, 6)
- .padding(.bottom, 6)
- }
- Text(item.title)
- .font(.headline)
- .lineLimit(2, reservesSpace: true)
- .multilineTextAlignment(.leading)
- Text(item.author)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .lineLimit(1, reservesSpace: true)
- }
- .contextMenu {
- downloadMenuItems
- }
- }
-
- private var cover: some View {
- Group {
- if let url = app.client.coverURL(itemId: item.id) {
- AsyncImage(url: url) { phase in
- switch phase {
- case .empty:
- Rectangle().fill(.quaternary)
- .overlay(ProgressView().controlSize(.small))
- case .success(let img):
- img.resizable().aspectRatio(contentMode: .fill)
- case .failure:
- Rectangle().fill(.quaternary)
- .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
- @unknown default:
- Rectangle().fill(.quaternary)
- }
- }
- } else {
- Rectangle().fill(.quaternary)
- }
- }
- .frame(width: 180, height: 180)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- }
-
- @ViewBuilder
- private var downloadBadge: some View {
- let state = app.downloads.state(for: item.syncKey)
- switch state {
- case .downloaded:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.white, .green)
- .font(.title3)
- .shadow(radius: 2)
- case .downloading(let p):
- DownloadProgressRing(progress: p)
- case .failed:
- Image(systemName: "exclamationmark.circle.fill")
- .foregroundStyle(.white, .red)
- .font(.title3)
- .shadow(radius: 2)
- case .notDownloaded:
- EmptyView()
- }
- }
-
- @ViewBuilder
- private var downloadMenuItems: some View {
- let key = item.syncKey
- let state = app.downloads.state(for: key)
- if item.isPodcastContainer {
- // Whole-podcast downloads aren't supported; instructions only.
- Text("Episoden zum Download in der Podcast-Ansicht auswählen")
- } else {
- switch state {
- case .notDownloaded, .failed:
- Button {
- app.downloads.startDownload(item: item)
- } label: {
- Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
- }
- case .downloading:
- Button {
- app.downloads.cancel(downloadKey: key)
- } label: {
- Label("Download abbrechen", systemImage: "xmark.circle")
- }
- case .downloaded:
- Button(role: .destructive) {
- app.downloads.delete(downloadKey: key)
- } label: {
- Label("Heruntergeladene Dateien löschen", systemImage: "trash")
- }
- }
- }
- }
-}
-
-/// Green progress bar drawn at the bottom of a cover (grid view).
-/// Hidden completely when there's no known progress.
-struct CoverProgressBar: View {
- let fraction: Double
-
- var body: some View {
- if fraction > 0 {
- GeometryReader { geo in
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 2, style: .continuous)
- .fill(Color.black.opacity(0.55))
- .frame(height: 4)
- RoundedRectangle(cornerRadius: 2, style: .continuous)
- .fill(Color.green)
- .frame(width: max(2, geo.size.width * fraction), height: 4)
- }
- }
- .frame(height: 4)
- .shadow(color: .black.opacity(0.35), radius: 1, y: 1)
- }
- }
-}
-
-struct DownloadProgressRing: View {
- let progress: Double
-
- var body: some View {
- ZStack {
- Circle()
- .fill(Color.black.opacity(0.75))
- Circle()
- .stroke(Color.white.opacity(0.25), lineWidth: 3)
- .padding(4)
- Circle()
- .trim(from: 0, to: max(0.03, min(progress, 1)))
- .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
- .rotationEffect(.degrees(-90))
- .padding(4)
- .animation(.easeInOut(duration: 0.25), value: progress)
- Image(systemName: "arrow.down")
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(.white)
- }
- .frame(width: 32, height: 32)
- .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
- .help("Wird heruntergeladen … \(Int(progress * 100)) %")
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift b/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift
deleted file mode 100644
index 8a3d91a..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift
+++ /dev/null
@@ -1,94 +0,0 @@
-import SwiftUI
-
-struct LoginView: View {
- @Environment(AppState.self) private var app
-
- @State private var serverURL: String = ""
- @State private var username: String = ""
- @State private var password: String = ""
- @State private var remember: Bool = true
- @State private var isLoading: Bool = false
-
- var body: some View {
- VStack(spacing: 16) {
- Spacer()
- Image(systemName: "books.vertical.fill")
- .font(.system(size: 48))
- .foregroundStyle(.tint)
- Text("ABS Client")
- .font(.largeTitle).bold()
- Text("Verbinde dich mit deinem Audiobookshelf-Server")
- .foregroundStyle(.secondary)
-
- VStack(alignment: .leading, spacing: 12) {
- LabeledField(label: "Server-URL", placeholder: "https://abs.example.com") {
- TextField("", text: $serverURL)
- .textFieldStyle(.roundedBorder)
- .disableAutocorrection(true)
- }
- LabeledField(label: "Benutzername", placeholder: "user") {
- TextField("", text: $username)
- .textFieldStyle(.roundedBorder)
- .disableAutocorrection(true)
- }
- LabeledField(label: "Passwort", placeholder: "••••••") {
- SecureField("", text: $password)
- .textFieldStyle(.roundedBorder)
- }
- Toggle("Anmeldung merken", isOn: $remember)
- .toggleStyle(.checkbox)
- }
- .frame(maxWidth: 380)
-
- if let err = app.auth.errorMessage {
- Text(err)
- .foregroundStyle(.red)
- .font(.callout)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 380)
- }
-
- Button(action: doLogin) {
- if isLoading {
- ProgressView().controlSize(.small)
- } else {
- Text("Einloggen")
- .frame(maxWidth: 200)
- }
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(isLoading || serverURL.isEmpty || username.isEmpty || password.isEmpty)
- .keyboardShortcut(.defaultAction)
-
- Spacer()
- }
- .padding(32)
- }
-
- private func doLogin() {
- isLoading = true
- Task {
- await app.auth.login(
- serverURL: serverURL,
- username: username,
- password: password,
- remember: remember
- )
- isLoading = false
- }
- }
-}
-
-private struct LabeledField: View {
- let label: String
- let placeholder: String
- @ViewBuilder let content: Content
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(label).font(.subheadline).foregroundStyle(.secondary)
- content
- }
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift b/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift
deleted file mode 100644
index 6a461c6..0000000
--- a/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift
+++ /dev/null
@@ -1,119 +0,0 @@
-import SwiftUI
-
-struct SettingsView: View {
- @Environment(AppState.self) private var app
-
- @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
- @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
- @AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
-
- @State private var showLogoutConfirm: Bool = false
-
- private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
-
- var body: some View {
- TabView {
- connectionPane
- .tabItem { Label("Verbindung", systemImage: "server.rack") }
-
- playbackPane
- .tabItem { Label("Wiedergabe", systemImage: "play.circle") }
-
- appearancePane
- .tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
-
- aboutPane
- .tabItem { Label("Über", systemImage: "info.circle") }
- }
- .padding(20)
- .frame(width: 480, height: 320)
- .confirmationDialog(
- "Mit Server abmelden?",
- isPresented: $showLogoutConfirm,
- titleVisibility: .visible
- ) {
- Button("Abmelden", role: .destructive) {
- app.stopPlayback()
- app.auth.logout()
- }
- Button("Abbrechen", role: .cancel) { }
- } message: {
- Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.")
- }
- }
-
- private var connectionPane: some View {
- Form {
- LabeledContent("Server") {
- Text(app.auth.serverURL.isEmpty ? "—" : app.auth.serverURL)
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- }
- LabeledContent("Benutzer") {
- Text(app.auth.username.isEmpty ? "—" : app.auth.username)
- .foregroundStyle(.secondary)
- }
- LabeledContent("Status") {
- HStack(spacing: 6) {
- Circle()
- .fill(app.network.isOnline ? .green : .orange)
- .frame(width: 8, height: 8)
- Text(app.network.isOnline ? "Online" : "Offline")
- if app.sync.queuedCount > 0 {
- Text("(\(app.sync.queuedCount) wartend)")
- .foregroundStyle(.secondary)
- }
- }
- }
- HStack {
- Spacer()
- Button(role: .destructive) {
- showLogoutConfirm = true
- } label: {
- Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right")
- }
- }
- }
- .formStyle(.grouped)
- }
-
- private var playbackPane: some View {
- Form {
- Picker("Sprung-Dauer", selection: $skipSeconds) {
- ForEach(Self.skipOptions, id: \.self) { sec in
- Text("\(sec) s").tag(sec)
- }
- }
- Text("Gilt für die Skip-Knöpfe in der Player-Leiste und Medientasten.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .formStyle(.grouped)
- }
-
- private var appearancePane: some View {
- Form {
- Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
- ForEach(LibraryLayout.allCases) { l in
- Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
- }
- }
- Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
- }
- .formStyle(.grouped)
- }
-
- private var aboutPane: some View {
- Form {
- LabeledContent("Version", value: appVersion)
- LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
- }
- .formStyle(.grouped)
- }
-
- private var appVersion: String {
- let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
- let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
- return "\(v) (\(b))"
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj b/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj
deleted file mode 100644
index 8695dd9..0000000
--- a/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj
+++ /dev/null
@@ -1,341 +0,0 @@
-// !$*UTF8*$!
-{
- archiveVersion = 1;
- classes = {
- };
- objectVersion = 77;
- objects = {
-
-/* Begin PBXFileReference section */
- A0000B012FB4E10100AB5001 /* ABS Client iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = "ABS Client iOS.app"; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
-/* End PBXFileReference section */
-
-/* Begin PBXFileSystemSynchronizedRootGroup section */
- A0000D012FB4E10100AB5001 /* ABS Client iOS */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- path = "ABS Client iOS";
- sourceTree = "";
- };
-/* End PBXFileSystemSynchronizedRootGroup section */
-
-/* Begin PBXFrameworksBuildPhase section */
- A0000801FB4E10100AB5001 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXFrameworksBuildPhase section */
-
-/* Begin PBXGroup section */
- A0000201FB4E10100AB5001 = {
- isa = PBXGroup;
- children = (
- A0000D012FB4E10100AB5001 /* ABS Client iOS */,
- A0000C012FB4E10100AB5001 /* Products */,
- );
- sourceTree = "";
- };
- A0000C012FB4E10100AB5001 /* Products */ = {
- isa = PBXGroup;
- children = (
- A0000B012FB4E10100AB5001 /* ABS Client iOS.app */,
- );
- name = Products;
- sourceTree = "";
- };
-/* End PBXGroup section */
-
-/* Begin PBXNativeTarget section */
- A0000A01FB4E10100AB5001 /* ABS Client iOS */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = A0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "ABS Client iOS" */;
- buildPhases = (
- A0000701FB4E10100AB5001 /* Sources */,
- A0000801FB4E10100AB5001 /* Frameworks */,
- A0000901FB4E10100AB5001 /* Resources */,
- );
- buildRules = (
- );
- dependencies = (
- );
- fileSystemSynchronizedGroups = (
- A0000D012FB4E10100AB5001 /* ABS Client iOS */,
- );
- name = "ABS Client iOS";
- packageProductDependencies = (
- );
- productName = "ABS Client iOS";
- productReference = A0000B012FB4E10100AB5001 /* ABS Client iOS.app */;
- productType = "com.apple.product-type.application";
- };
-/* End PBXNativeTarget section */
-
-/* Begin PBXProject section */
- A0000301FB4E10100AB5001 /* Project object */ = {
- isa = PBXProject;
- attributes = {
- BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 2640;
- LastUpgradeCheck = 2640;
- TargetAttributes = {
- A0000A01FB4E10100AB5001 = {
- CreatedOnToolsVersion = 26.4.1;
- };
- };
- };
- buildConfigurationList = A0000601FB4E10100AB5001 /* Build configuration list for PBXProject "ABS Client iOS" */;
- developmentRegion = en;
- hasScannedForEncodings = 0;
- knownRegions = (
- en,
- Base,
- );
- mainGroup = A0000201FB4E10100AB5001;
- minimizedProjectReferenceProxies = 1;
- preferredProjectObjectVersion = 77;
- productRefGroup = A0000C012FB4E10100AB5001 /* Products */;
- projectDirPath = "";
- projectRoot = "";
- targets = (
- A0000A01FB4E10100AB5001 /* ABS Client iOS */,
- );
- };
-/* End PBXProject section */
-
-/* Begin PBXResourcesBuildPhase section */
- A0000901FB4E10100AB5001 /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXResourcesBuildPhase section */
-
-/* Begin PBXSourcesBuildPhase section */
- A0000701FB4E10100AB5001 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXSourcesBuildPhase section */
-
-/* Begin XCBuildConfiguration section */
- A0001401FB4E10100AB5001 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = dwarf;
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_TESTABILITY = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GCC_DYNAMIC_NO_PIC = NO;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_OPTIMIZATION_LEVEL = 0;
- GCC_PREPROCESSOR_DEFINITIONS = (
- "DEBUG=1",
- "$(inherited)",
- );
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
- MTL_FAST_MATH = YES;
- ONLY_ACTIVE_ARCH = YES;
- SDKROOT = iphoneos;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
- SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- TARGETED_DEVICE_FAMILY = 1;
- };
- name = Debug;
- };
- A0001501FB4E10100AB5001 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- ENABLE_NS_ASSERTIONS = NO;
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MTL_ENABLE_DEBUG_INFO = NO;
- MTL_FAST_MATH = YES;
- SDKROOT = iphoneos;
- SWIFT_COMPILATION_MODE = wholemodule;
- TARGETED_DEVICE_FAMILY = 1;
- VALIDATE_PRODUCT = YES;
- };
- name = Release;
- };
- A0001701FB4E10100AB5001 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
- INFOPLIST_KEY_CFBundleName = "ABS Client";
- INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
- INFOPLIST_KEY_NSHumanReadableCopyright = "";
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UIBackgroundModes = audio;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.local.absclient.ios;
- PRODUCT_NAME = "ABS Client";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- };
- name = Debug;
- };
- A0001801FB4E10100AB5001 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
- INFOPLIST_KEY_CFBundleName = "ABS Client";
- INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
- INFOPLIST_KEY_NSHumanReadableCopyright = "";
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UIBackgroundModes = audio;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.local.absclient.ios;
- PRODUCT_NAME = "ABS Client";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- };
- name = Release;
- };
-/* End XCBuildConfiguration section */
-
-/* Begin XCConfigurationList section */
- A0000601FB4E10100AB5001 /* Build configuration list for PBXProject "ABS Client iOS" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- A0001401FB4E10100AB5001 /* Debug */,
- A0001501FB4E10100AB5001 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
- A0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "ABS Client iOS" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- A0001701FB4E10100AB5001 /* Debug */,
- A0001801FB4E10100AB5001 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
-/* End XCConfigurationList section */
- };
- rootObject = A0000301FB4E10100AB5001 /* Project object */;
-}
diff --git a/ABS Client iOS/ABS Client iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/ABS Client iOS/ABS Client iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
deleted file mode 100644
index e4db99a..0000000
--- a/ABS Client iOS/ABS Client iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- SchemeUserState
-
- ABS Client iOS.xcscheme_^#shared#^_
-
- orderHint
- 0
-
-
-
-
diff --git a/ABS Client iOS/ABS Client iOS/ABS_Client_iOSApp.swift b/ABS Client iOS/ABS Client iOS/ABS_Client_iOSApp.swift
deleted file mode 100644
index 388b9e2..0000000
--- a/ABS Client iOS/ABS Client iOS/ABS_Client_iOSApp.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import SwiftUI
-import AVFAudio
-
-@main
-struct ABS_Client_iOSApp: App {
- @State private var appState = AppState()
-
- init() {
- configureAudioSession()
- }
-
- var body: some Scene {
- WindowGroup {
- ContentView()
- .environment(appState)
- .task { await appState.bootstrap() }
- }
- }
-
- /// Allow background audio + obey the route picker.
- /// Without this audiobooks pause when the iPhone is locked or muted.
- private func configureAudioSession() {
- do {
- let session = AVAudioSession.sharedInstance()
- try session.setCategory(.playback, mode: .spokenAudio, options: [])
- try session.setActive(true)
- } catch {
- // non-fatal: user can still play audio while app is foregrounded
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AccentColor.colorset/Contents.json
deleted file mode 100644
index eb87897..0000000
--- a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AccentColor.colorset/Contents.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "colors" : [
- {
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
deleted file mode 100644
index f27760d..0000000
--- a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "images" : [
- {
- "filename" : "icon_1024.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/icon_1024.png
deleted file mode 100644
index f760ef8..0000000
Binary files a/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/icon_1024.png and /dev/null differ
diff --git a/ABS Client iOS/ABS Client iOS/Assets.xcassets/Contents.json b/ABS Client iOS/ABS Client iOS/Assets.xcassets/Contents.json
deleted file mode 100644
index 73c0059..0000000
--- a/ABS Client iOS/ABS Client iOS/Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Models/APIResponses.swift b/ABS Client iOS/ABS Client iOS/Models/APIResponses.swift
deleted file mode 100644
index 43c4d08..0000000
--- a/ABS Client iOS/ABS Client iOS/Models/APIResponses.swift
+++ /dev/null
@@ -1,86 +0,0 @@
-import Foundation
-
-struct LoginResponseDTO: Decodable {
- let user: UserDTO
-}
-
-struct UserDTO: Decodable {
- let id: String
- let username: String?
- let token: String
-}
-
-struct LibrariesResponseDTO: Decodable {
- let libraries: [LibraryDTO]
-}
-
-struct LibraryDTO: Decodable {
- let id: String
- let name: String
- let mediaType: String?
-}
-
-struct LibraryItemsResponseDTO: Decodable {
- let results: [LibraryItemDTO]
- let total: Int?
-}
-
-struct LibraryItemDTO: Decodable {
- let id: String
- let mediaType: String?
- let media: MediaDTO?
-}
-
-struct MediaDTO: Decodable {
- let metadata: MetadataDTO?
- let duration: Double?
- let audioFiles: [AudioFileDTO]?
- let episodes: [EpisodeDTO]?
-}
-
-struct MetadataDTO: Decodable {
- let title: String?
- let authorName: String?
- let author: String?
- let description: String?
-}
-
-struct EpisodeDTO: Decodable {
- let id: String
- let title: String?
- let subtitle: String?
- let pubDate: String?
- let publishedAt: Double?
- let season: String?
- let episode: String?
- let duration: Double?
- let audioFile: AudioFileDTO?
-}
-
-struct AudioFileDTO: Decodable {
- let ino: String
- let index: Int?
- let metadata: AudioFileMetadataDTO?
- let duration: Double?
-
- struct AudioFileMetadataDTO: Decodable {
- let filename: String?
- let ext: String?
- }
-}
-
-struct ProgressResponseDTO: Decodable {
- let id: String?
- let libraryItemId: String?
- let episodeId: String?
- let currentTime: Double?
- let duration: Double?
- let isFinished: Bool?
- let lastUpdate: Double?
-}
-
-struct MeResponseDTO: Decodable {
- let id: String?
- let username: String?
- let mediaProgress: [ProgressResponseDTO]?
-}
diff --git a/ABS Client iOS/ABS Client iOS/Models/Models.swift b/ABS Client iOS/ABS Client iOS/Models/Models.swift
deleted file mode 100644
index 236ec7a..0000000
--- a/ABS Client iOS/ABS Client iOS/Models/Models.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-import Foundation
-
-struct Library: Codable, Identifiable, Hashable {
- let id: String
- let name: String
- let mediaType: String?
-}
-
-struct LibraryItem: Codable, Identifiable, Hashable {
- let id: String
- let title: String
- let author: String
- let durationSeconds: Double
- let audioFiles: [AudioFile]
- var mediaType: String = "book"
- var episodeId: String? = nil
- var description: String? = nil
-
- var isPodcast: Bool { mediaType == "podcast" }
- var isPodcastContainer: Bool { isPodcast && episodeId == nil }
- var isPodcastEpisode: Bool { isPodcast && episodeId != nil }
-
- static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool {
- lhs.id == rhs.id && lhs.episodeId == rhs.episodeId
- }
- func hash(into hasher: inout Hasher) {
- hasher.combine(id)
- hasher.combine(episodeId)
- }
-}
-
-struct PodcastEpisode: Identifiable, Hashable, Codable {
- let id: String
- let title: String
- let pubDate: String?
- let publishedAtMillis: Double?
- let season: String?
- let episode: String?
- let durationSeconds: Double
- let audioFile: AudioFile
-
- var formattedDate: String? {
- if let ms = publishedAtMillis, ms > 0 {
- let date = Date(timeIntervalSince1970: ms / 1000.0)
- let df = DateFormatter()
- df.dateStyle = .medium
- df.timeStyle = .none
- return df.string(from: date)
- }
- return pubDate
- }
-}
-
-struct AudioFile: Codable, Hashable {
- let ino: String
- let filename: String
- let ext: String
- let durationSeconds: Double
- let index: Int
-}
-
-struct PlaybackProgress: Codable, Hashable {
- let itemId: String
- var episodeId: String?
- var currentTime: Double
- var duration: Double
- var isFinished: Bool
- var updatedAt: Date
-
- var syncKey: String {
- if let episodeId { return "\(itemId)|\(episodeId)" }
- return itemId
- }
-}
-
-enum DownloadState: Equatable {
- case notDownloaded
- case downloading(progress: Double)
- case downloaded
- case failed(message: String)
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/AppState.swift b/ABS Client iOS/ABS Client iOS/Services/AppState.swift
deleted file mode 100644
index cb0b748..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/AppState.swift
+++ /dev/null
@@ -1,224 +0,0 @@
-import Foundation
-import Observation
-
-@Observable
-@MainActor
-final class AppState {
- let auth: AuthStore
- let client: ABSClient
- let network: NetworkMonitor
- let downloads: DownloadManager
- let sync: ProgressSyncManager
- let player: PlayerEngine
-
- var currentItem: LibraryItem?
- var isPreparingPlayback: Bool = false
-
- /// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
- /// Used to show progress bars on covers in the library views.
- var progressCache: [String: PlaybackProgress] = [:]
-
- private var syncTimer: Timer?
- private var lastReportedSecond: Double = -10
-
- init() {
- let auth = AuthStore()
- let client = ABSClient(auth: auth)
- self.auth = auth
- self.client = client
- self.network = NetworkMonitor()
- self.downloads = DownloadManager(client: client)
- self.sync = ProgressSyncManager(client: client)
- self.player = PlayerEngine()
- }
-
- func bootstrap() async {
- auth.restoreSession()
- network.start { [weak self] online in
- guard let self else { return }
- if online {
- Task { [weak self] in
- await self?.sync.drain()
- await self?.refreshProgressCache()
- }
- }
- }
- if auth.isLoggedIn {
- let ok = await client.validateToken()
- if !ok {
- auth.logout()
- } else {
- await sync.drain()
- await refreshProgressCache()
- }
- }
- }
-
- /// Pulls the entire progress map from the server (via /api/me).
- func refreshProgressCache() async {
- guard network.isOnline, auth.isLoggedIn else { return }
- do {
- let all = try await client.fetchAllProgress()
- progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
- } catch {
- // non-fatal
- }
- }
-
- /// Local update for the cache while we're actively playing.
- func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
- let p = PlaybackProgress(
- itemId: itemId, episodeId: episodeId,
- currentTime: currentTime, duration: duration,
- isFinished: isFinished, updatedAt: Date()
- )
- progressCache[p.syncKey] = p
- }
-
- func progress(for item: LibraryItem) -> PlaybackProgress? {
- progressCache[item.syncKey]
- }
-
- func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
- let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
- guard let p = progressCache[key], p.duration > 0 else { return 0 }
- if p.isFinished { return 1.0 }
- return min(1, max(0, p.currentTime / p.duration))
- }
-
- func play(item: LibraryItem) async {
- if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
- player.play()
- return
- }
- stopPlayback(reportFinal: true)
- isPreparingPlayback = true
- defer { isPreparingPlayback = false }
-
- var workItem = item
- // Only fetch detail for books with empty audioFiles (podcast episodes
- // arrive with their single audioFile already populated by the caller).
- if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
- let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
- if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
- workItem = detail
- }
- }
-
- var startAt: Double = 0
- if network.isOnline {
- if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
- // Replaying a finished item (or one with progress essentially at the end)
- // should start from the beginning, not drop the user at the last few seconds.
- let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
- if !p.isFinished && !nearEnd {
- startAt = p.currentTime
- }
- }
- }
-
- currentItem = workItem
- player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
- if player.errorMessage == nil {
- player.play()
- startSyncTimer()
- }
- }
-
- /// Convenience for podcast episodes.
- func play(podcast: LibraryItem, episode: PodcastEpisode) async {
- var synthetic = LibraryItem(
- id: podcast.id,
- title: episode.title,
- author: podcast.title,
- durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
- audioFiles: [episode.audioFile]
- )
- synthetic.mediaType = "podcast"
- synthetic.episodeId = episode.id
- await play(item: synthetic)
- }
-
- func stopPlayback(reportFinal: Bool = true) {
- if reportFinal { reportProgress(force: true) }
- syncTimer?.invalidate()
- syncTimer = nil
- player.teardown()
- currentItem = nil
- lastReportedSecond = -10
- }
-
- func togglePlay() {
- guard currentItem != nil else { return }
- player.togglePlay()
- if !player.isPlaying { reportProgress(force: true) }
- }
-
- func skip(by seconds: Double) {
- guard currentItem != nil else { return }
- player.skip(by: seconds)
- reportProgress(force: true)
- }
-
- func seekAbsolute(_ target: Double) {
- guard currentItem != nil else { return }
- player.seekAbsolute(target)
- reportProgress(force: true)
- }
-
- func setRate(_ newRate: Float) {
- player.setRate(newRate)
- }
-
- private func startSyncTimer() {
- syncTimer?.invalidate()
- let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
- Task { @MainActor [weak self] in
- self?.reportProgress(force: false)
- }
- }
- RunLoop.main.add(timer, forMode: .common)
- syncTimer = timer
- }
-
- private func reportProgress(force: Bool) {
- guard let item = currentItem else { return }
- let t = player.absoluteCurrentTime
- let d = player.totalDuration
- guard d > 0 else { return }
- if !force && abs(t - lastReportedSecond) < 3 { return }
- lastReportedSecond = t
- let finished = (d - t) < 30
-
- cacheProgress(
- itemId: item.id,
- episodeId: item.episodeId,
- currentTime: t,
- duration: d,
- isFinished: finished
- )
-
- Task {
- await sync.report(
- itemId: item.id,
- episodeId: item.episodeId,
- currentTime: t,
- duration: d,
- isFinished: finished,
- isOnline: network.isOnline
- )
- }
- }
-}
-
-extension LibraryItem {
- /// Matches PlaybackProgress.syncKey for cache lookups.
- var syncKey: String {
- if let episodeId { return "\(id)|\(episodeId)" }
- return id
- }
-
- /// The DownloadManager keys downloads by this composite identifier,
- /// allowing the same podcast item to host multiple per-episode downloads.
- var downloadKey: String { syncKey }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/AuthStore.swift b/ABS Client iOS/ABS Client iOS/Services/AuthStore.swift
deleted file mode 100644
index ccb7a9c..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/AuthStore.swift
+++ /dev/null
@@ -1,94 +0,0 @@
-import Foundation
-import Observation
-
-enum AuthError: LocalizedError {
- case invalidURL
- case badResponse(Int)
- case noToken
- case unknown(String)
-
- var errorDescription: String? {
- switch self {
- case .invalidURL: return "Ungültige Server-URL."
- case .badResponse(let code): return "Server antwortete mit Status \(code)."
- case .noToken: return "Login fehlgeschlagen: kein Token erhalten."
- case .unknown(let msg): return msg
- }
- }
-}
-
-@Observable
-@MainActor
-final class AuthStore {
- var isLoggedIn: Bool = false
- var serverURL: String = ""
- var username: String = ""
- var token: String = ""
- var errorMessage: String?
-
- func restoreSession() {
- guard let creds = KeychainStore.load() else { return }
- self.serverURL = creds.serverURL
- self.username = creds.username
- self.token = creds.token
- self.isLoggedIn = true
- }
-
- func login(serverURL rawURL: String, username: String, password: String, remember: Bool) async {
- errorMessage = nil
- let normalized = Self.normalizeURL(rawURL)
- guard let url = URL(string: normalized + "/login") else {
- errorMessage = AuthError.invalidURL.errorDescription
- return
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let body = ["username": username, "password": password]
- request.httpBody = try? JSONEncoder().encode(body)
-
- do {
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- errorMessage = "Keine HTTP-Antwort vom Server."
- return
- }
- guard (200..<300).contains(http.statusCode) else {
- errorMessage = AuthError.badResponse(http.statusCode).errorDescription
- return
- }
- let decoded = try JSONDecoder().decode(LoginResponseDTO.self, from: data)
- self.serverURL = normalized
- self.username = decoded.user.username ?? username
- self.token = decoded.user.token
- self.isLoggedIn = true
-
- if remember {
- try? KeychainStore.save(StoredCredentials(
- serverURL: normalized,
- username: self.username,
- token: self.token
- ))
- } else {
- KeychainStore.delete()
- }
- } catch {
- errorMessage = "Login fehlgeschlagen: \(error.localizedDescription)"
- }
- }
-
- func logout() {
- KeychainStore.delete()
- token = ""
- isLoggedIn = false
- }
-
- static func normalizeURL(_ raw: String) -> String {
- var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- while s.hasSuffix("/") { s.removeLast() }
- if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") {
- s = "https://" + s
- }
- return s
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/DownloadManager.swift b/ABS Client iOS/ABS Client iOS/Services/DownloadManager.swift
deleted file mode 100644
index a7eea39..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/DownloadManager.swift
+++ /dev/null
@@ -1,259 +0,0 @@
-import Foundation
-import Observation
-
-struct DownloadedTrack: Codable, Hashable {
- let ino: String
- let filename: String
- let localPath: String // relative to AppPaths.downloadsDirectory
- let durationSeconds: Double
-
- enum CodingKeys: String, CodingKey {
- case ino, filename, localPath, durationSeconds
- }
-
- init(ino: String, filename: String, localPath: String, durationSeconds: Double) {
- self.ino = ino
- self.filename = filename
- self.localPath = localPath
- self.durationSeconds = durationSeconds
- }
-
- init(from decoder: Decoder) throws {
- let c = try decoder.container(keyedBy: CodingKeys.self)
- ino = try c.decode(String.self, forKey: .ino)
- filename = try c.decode(String.self, forKey: .filename)
- localPath = try c.decode(String.self, forKey: .localPath)
- durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0
- }
-}
-
-struct DownloadedItem: Codable, Hashable {
- let itemId: String
- var episodeId: String?
- let title: String
- let author: String
- let durationSeconds: Double
- let tracks: [DownloadedTrack]
-
- enum CodingKeys: String, CodingKey {
- case itemId, episodeId, title, author, durationSeconds, tracks
- }
-
- init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) {
- self.itemId = itemId
- self.episodeId = episodeId
- self.title = title
- self.author = author
- self.durationSeconds = durationSeconds
- self.tracks = tracks
- }
-
- init(from decoder: Decoder) throws {
- let c = try decoder.container(keyedBy: CodingKeys.self)
- itemId = try c.decode(String.self, forKey: .itemId)
- episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId)
- title = try c.decode(String.self, forKey: .title)
- author = try c.decode(String.self, forKey: .author)
- durationSeconds = try c.decode(Double.self, forKey: .durationSeconds)
- tracks = try c.decode([DownloadedTrack].self, forKey: .tracks)
- }
-
- var downloadKey: String {
- if let episodeId { return "\(itemId)|\(episodeId)" }
- return itemId
- }
-}
-
-@Observable
-@MainActor
-final class DownloadManager {
- private let client: ABSClient
- /// Keyed by downloadKey (itemId or "itemId|episodeId").
- private(set) var states: [String: DownloadState] = [:]
- private(set) var downloadedItems: [String: DownloadedItem] = [:]
-
- private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
- private var activeTasks: [String: Task] = [:]
-
- init(client: ABSClient) {
- self.client = client
- try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
- loadIndex()
- }
-
- func state(for downloadKey: String) -> DownloadState {
- states[downloadKey] ?? .notDownloaded
- }
-
- func isDownloaded(downloadKey: String) -> Bool {
- if case .downloaded = state(for: downloadKey) { return true }
- return false
- }
-
- func localTrackURLs(for downloadKey: String) -> [URL]? {
- guard let item = downloadedItems[downloadKey] else { return nil }
- return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
- }
-
- /// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
- /// For a podcast episode pass the synthetic LibraryItem that the AppState builds
- /// (item.id == podcastItemId, item.episodeId == episodeId, audioFiles == [episode.audioFile]).
- func startDownload(item: LibraryItem) {
- let key = item.downloadKey
- guard activeTasks[key] == nil else { return }
- states[key] = .downloading(progress: 0)
-
- let task = Task { @MainActor [weak self] in
- guard let self else { return }
- var workItem = item
-
- // Books may arrive with empty audioFiles (list endpoint omits them).
- // Episodes always arrive populated, since AppState builds the synthetic item.
- if !workItem.isPodcast && workItem.audioFiles.isEmpty {
- do {
- workItem = try await self.client.fetchItemDetail(itemId: item.id)
- } catch {
- self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
- self.activeTasks[key] = nil
- return
- }
- }
- if workItem.audioFiles.isEmpty {
- self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
- self.activeTasks[key] = nil
- return
- }
- await self.performDownload(workItem: workItem, downloadKey: key)
- self.activeTasks[key] = nil
- }
- activeTasks[key] = task
- }
-
- func cancel(downloadKey: String) {
- activeTasks[downloadKey]?.cancel()
- activeTasks[downloadKey] = nil
- states[downloadKey] = .notDownloaded
- }
-
- func delete(downloadKey: String) {
- cancel(downloadKey: downloadKey)
- if let item = downloadedItems[downloadKey] {
- let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
- try? FileManager.default.removeItem(at: dir)
- // If this was an episode and the podcast's parent directory is now empty, clean it up too.
- if item.episodeId != nil {
- let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
- if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
- try? FileManager.default.removeItem(at: parent)
- }
- }
- }
- downloadedItems.removeValue(forKey: downloadKey)
- states[downloadKey] = .notDownloaded
- persistIndex()
- }
-
- private func directoryURL(itemId: String, episodeId: String?) -> URL {
- var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
- if let episodeId {
- dir = dir.appendingPathComponent(episodeId, isDirectory: true)
- }
- return dir
- }
-
- private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String {
- if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" }
- return "\(itemId)/\(fileName)"
- }
-
- private func performDownload(workItem: LibraryItem, downloadKey: String) async {
- let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
- do {
- try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
- } catch {
- states[downloadKey] = .failed(message: error.localizedDescription)
- return
- }
-
- var tracks: [DownloadedTrack] = []
- let total = max(workItem.audioFiles.count, 1)
-
- for (idx, file) in workItem.audioFiles.enumerated() {
- if Task.isCancelled {
- states[downloadKey] = .notDownloaded
- return
- }
- guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
- var request = URLRequest(url: url)
- for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
-
- do {
- let (tempURL, response) = try await URLSession.shared.download(for: request)
- if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
- states[downloadKey] = .failed(message: "HTTP \(http.statusCode) bei Datei \(file.filename)")
- try? FileManager.default.removeItem(at: tempURL)
- return
- }
- let ext = file.ext.isEmpty ? "mp3" : file.ext
- let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
- let dest = itemDir.appendingPathComponent(destName)
- try? FileManager.default.removeItem(at: dest)
- try FileManager.default.moveItem(at: tempURL, to: dest)
- tracks.append(DownloadedTrack(
- ino: file.ino,
- filename: file.filename,
- localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
- durationSeconds: file.durationSeconds
- ))
- states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
- } catch {
- states[downloadKey] = .failed(message: error.localizedDescription)
- return
- }
- }
-
- let downloaded = DownloadedItem(
- itemId: workItem.id,
- episodeId: workItem.episodeId,
- title: workItem.title,
- author: workItem.author,
- durationSeconds: workItem.durationSeconds,
- tracks: tracks
- )
- downloadedItems[downloadKey] = downloaded
- states[downloadKey] = .downloaded
- persistIndex()
- }
-
- private func loadIndex() {
- guard let data = try? Data(contentsOf: indexFile),
- let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
- // Re-key by current downloadKey (handles both legacy itemId-only keys and new composite keys).
- var rekeyed: [String: DownloadedItem] = [:]
- for (_, item) in decoded {
- if item.tracks.isEmpty { continue }
- rekeyed[item.downloadKey] = item
- }
- downloadedItems = rekeyed
- for k in rekeyed.keys {
- states[k] = .downloaded
- }
- // Clean up phantom folders.
- for (oldKey, item) in decoded where item.tracks.isEmpty {
- let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey)
- try? FileManager.default.removeItem(at: dir)
- }
- if rekeyed.count != decoded.count {
- persistIndex()
- }
- }
-
- private func persistIndex() {
- do {
- let data = try JSONEncoder().encode(downloadedItems)
- try data.write(to: indexFile, options: .atomic)
- } catch {
- // non-fatal
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/KeychainStore.swift b/ABS Client iOS/ABS Client iOS/Services/KeychainStore.swift
deleted file mode 100644
index bb31e18..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/KeychainStore.swift
+++ /dev/null
@@ -1,59 +0,0 @@
-import Foundation
-import Security
-
-struct StoredCredentials: Codable {
- let serverURL: String
- let username: String
- let token: String
-}
-
-enum KeychainError: Error {
- case osStatus(OSStatus)
- case encodingFailed
-}
-
-enum KeychainStore {
- private static let service = "com.local.Audiobookshelf-swift.auth"
- private static let account = "primary"
-
- static func save(_ creds: StoredCredentials) throws {
- let data = try JSONEncoder().encode(creds)
-
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- ]
- SecItemDelete(query as CFDictionary)
-
- var attributes = query
- attributes[kSecValueData as String] = data
- attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
-
- let status = SecItemAdd(attributes as CFDictionary, nil)
- guard status == errSecSuccess else { throw KeychainError.osStatus(status) }
- }
-
- static func load() -> StoredCredentials? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne,
- ]
- var item: CFTypeRef?
- let status = SecItemCopyMatching(query as CFDictionary, &item)
- guard status == errSecSuccess, let data = item as? Data else { return nil }
- return try? JSONDecoder().decode(StoredCredentials.self, from: data)
- }
-
- static func delete() {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- ]
- SecItemDelete(query as CFDictionary)
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/NetworkMonitor.swift b/ABS Client iOS/ABS Client iOS/Services/NetworkMonitor.swift
deleted file mode 100644
index 26ab95e..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/NetworkMonitor.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import Foundation
-import Network
-import Observation
-
-@Observable
-@MainActor
-final class NetworkMonitor {
- var isOnline: Bool = true
-
- private let monitor = NWPathMonitor()
- private let queue = DispatchQueue(label: "NetworkMonitor")
-
- func start(onChange: @escaping @MainActor (Bool) -> Void) {
- monitor.pathUpdateHandler = { [weak self] path in
- let online = path.status == .satisfied
- Task { @MainActor [weak self] in
- guard let self else { return }
- let previous = self.isOnline
- self.isOnline = online
- if previous != online {
- onChange(online)
- }
- }
- }
- monitor.start(queue: queue)
- }
-
- func stop() {
- monitor.cancel()
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/PlayerEngine.swift b/ABS Client iOS/ABS Client iOS/Services/PlayerEngine.swift
deleted file mode 100644
index 3042237..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/PlayerEngine.swift
+++ /dev/null
@@ -1,336 +0,0 @@
-import Foundation
-import AVFoundation
-import MediaPlayer
-import Observation
-
-#if canImport(UIKit)
-import UIKit
-private typealias PlayerArtworkImage = UIImage
-#else
-import AppKit
-private typealias PlayerArtworkImage = NSImage
-#endif
-
-@Observable
-@MainActor
-final class PlayerEngine {
- var isPlaying: Bool = false
- var absoluteCurrentTime: Double = 0
- var totalDuration: Double = 0
- var rate: Float = 1.0
- var isReady: Bool = false
- var errorMessage: String?
-
- private var player: AVQueuePlayer?
- private var trackDurations: [Double] = []
- private var trackPlayerItems: [AVPlayerItem] = []
- private var currentTrackIndex: Int = 0
- private var timeObserver: Any?
- private var endObservers: [NSObjectProtocol] = []
- private var isSeeking: Bool = false
-
- var itemId: String?
-
- // Now-playing metadata that travels with the current item.
- private var currentTitle: String = ""
- private var currentAuthor: String = ""
- private var currentCoverURL: URL?
- private var remoteCommandsConfigured: Bool = false
-
- nonisolated init() {}
-
- func load(item: LibraryItem, client: ABSClient, downloads: DownloadManager, startAt absoluteTime: Double) {
- teardown()
- self.itemId = item.id
- self.errorMessage = nil
-
- let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey)
- let urls: [URL]
-
- if useLocal, let localURLs = downloads.localTrackURLs(for: item.downloadKey), !localURLs.isEmpty {
- urls = localURLs
- trackDurations = (0.. 0 ? totalDuration : absolute
- absoluteCurrentTime = max(0, min(absolute, cap))
- let wasPlaying = isPlaying
- isPlaying = player.timeControlStatus == .playing
- if wasPlaying != isPlaying { updateNowPlayingInfo() }
- }
-
- func teardown() {
- if let token = timeObserver { player?.removeTimeObserver(token) }
- timeObserver = nil
- for obs in endObservers { NotificationCenter.default.removeObserver(obs) }
- endObservers.removeAll()
- player?.pause()
- player?.removeAllItems()
- player = nil
- trackPlayerItems.removeAll()
- trackDurations.removeAll()
- isPlaying = false
- isReady = false
- absoluteCurrentTime = 0
- totalDuration = 0
- currentTrackIndex = 0
- itemId = nil
- errorMessage = nil
- isSeeking = false
- currentTitle = ""
- currentAuthor = ""
- currentCoverURL = nil
- clearNowPlayingInfo()
- }
-
- // MARK: - Now-playing / remote commands
-
- private func configureRemoteCommandsIfNeeded() {
- guard !remoteCommandsConfigured else { return }
- remoteCommandsConfigured = true
-
- let center = MPRemoteCommandCenter.shared()
-
- center.playCommand.addTarget { [weak self] _ in
- Task { @MainActor in self?.play() }
- return .success
- }
- center.pauseCommand.addTarget { [weak self] _ in
- Task { @MainActor in self?.pause() }
- return .success
- }
- center.togglePlayPauseCommand.addTarget { [weak self] _ in
- Task { @MainActor in self?.togglePlay() }
- return .success
- }
- applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
- center.skipForwardCommand.addTarget { [weak self] _ in
- let s = Double(Self.currentSkipSeconds())
- Task { @MainActor in self?.skip(by: s) }
- return .success
- }
- center.skipBackwardCommand.addTarget { [weak self] _ in
- let s = Double(Self.currentSkipSeconds())
- Task { @MainActor in self?.skip(by: -s) }
- return .success
- }
- // Keep the lock-screen icon in sync with the user's preference.
- NotificationCenter.default.addObserver(
- forName: UserDefaults.didChangeNotification, object: nil, queue: .main
- ) { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
- }
- }
- center.changePlaybackPositionCommand.addTarget { [weak self] event in
- guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else {
- return .commandFailed
- }
- let target = posEvent.positionTime
- Task { @MainActor in self?.seekAbsolute(target) }
- return .success
- }
- center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
- center.changePlaybackRateCommand.addTarget { [weak self] event in
- guard let rateEvent = event as? MPChangePlaybackRateCommandEvent else { return .commandFailed }
- Task { @MainActor in self?.setRate(rateEvent.playbackRate) }
- return .success
- }
- }
-
- private func updateNowPlayingInfo() {
- guard itemId != nil else {
- clearNowPlayingInfo()
- return
- }
- var info: [String: Any] = [
- MPMediaItemPropertyTitle: currentTitle,
- MPMediaItemPropertyArtist: currentAuthor,
- MPMediaItemPropertyPlaybackDuration: totalDuration,
- MPNowPlayingInfoPropertyElapsedPlaybackTime: absoluteCurrentTime,
- MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? Double(rate) : 0.0,
- MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
- MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
- ]
- // Preserve artwork across updates so we don't blank the lock-screen image.
- if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo,
- let art = existing[MPMediaItemPropertyArtwork] {
- info[MPMediaItemPropertyArtwork] = art
- }
- MPNowPlayingInfoCenter.default().nowPlayingInfo = info
- }
-
- private func fetchAndAttachArtwork() {
- guard let url = currentCoverURL else { return }
- Task.detached {
- do {
- let (data, _) = try await URLSession.shared.data(from: url)
- guard let img = PlayerArtworkImage(data: data) else { return }
- let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
- await MainActor.run { [weak self] in
- // Drop the result if the user has since switched items.
- guard let self, self.currentCoverURL == url else { return }
- var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
- info[MPMediaItemPropertyArtwork] = artwork
- MPNowPlayingInfoCenter.default().nowPlayingInfo = info
- }
- } catch { }
- }
- }
-
- private func clearNowPlayingInfo() {
- MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
- }
-
- private func applyRemoteSkipInterval(seconds: Int) {
- let center = MPRemoteCommandCenter.shared()
- center.skipForwardCommand.preferredIntervals = [NSNumber(value: seconds)]
- center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seconds)]
- }
-
- /// Reads the user-configured skip duration; defaults to 30s when unset.
- static func currentSkipSeconds() -> Int {
- let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds")
- return raw > 0 ? raw : 30
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Services/ProgressSyncManager.swift b/ABS Client iOS/ABS Client iOS/Services/ProgressSyncManager.swift
deleted file mode 100644
index 2c7cd0e..0000000
--- a/ABS Client iOS/ABS Client iOS/Services/ProgressSyncManager.swift
+++ /dev/null
@@ -1,94 +0,0 @@
-import Foundation
-import Observation
-
-@Observable
-@MainActor
-final class ProgressSyncManager {
- private let client: ABSClient
- private(set) var queuedCount: Int = 0
- private(set) var lastSyncError: String?
-
- /// Latest progress per itemId, persisted to disk.
- private var queue: [String: PlaybackProgress] = [:]
-
- private let queueFile: URL
-
- init(client: ABSClient) {
- self.client = client
- let dir = AppPaths.supportDirectory
- try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- self.queueFile = dir.appendingPathComponent("progress-queue.json")
- loadQueue()
- }
-
- func report(itemId: String, episodeId: String? = nil, currentTime: Double, duration: Double, isFinished: Bool, isOnline: Bool) async {
- let progress = PlaybackProgress(
- itemId: itemId,
- episodeId: episodeId,
- currentTime: currentTime,
- duration: duration,
- isFinished: isFinished,
- updatedAt: Date()
- )
- let key = progress.syncKey
-
- if isOnline {
- do {
- try await client.saveProgress(progress)
- queue.removeValue(forKey: key)
- persist()
- lastSyncError = nil
- return
- } catch {
- lastSyncError = error.localizedDescription
- }
- }
-
- queue[key] = progress
- persist()
- }
-
- func drain() async {
- guard !queue.isEmpty else { return }
- let snapshot = queue
- for (id, progress) in snapshot {
- do {
- try await client.saveProgress(progress)
- queue.removeValue(forKey: id)
- } catch {
- lastSyncError = error.localizedDescription
- break
- }
- }
- persist()
- }
-
- private func loadQueue() {
- guard let data = try? Data(contentsOf: queueFile),
- let decoded = try? JSONDecoder().decode([String: PlaybackProgress].self, from: data) else { return }
- queue = decoded
- queuedCount = decoded.count
- }
-
- private func persist() {
- queuedCount = queue.count
- do {
- let data = try JSONEncoder().encode(queue)
- try data.write(to: queueFile, options: .atomic)
- } catch {
- lastSyncError = "Queue konnte nicht gespeichert werden: \(error.localizedDescription)"
- }
- }
-}
-
-enum AppPaths {
- static var supportDirectory: URL {
- let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
- ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
- return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true)
- }
-
- static var downloadsDirectory: URL {
- supportDirectory.appendingPathComponent("downloads", isDirectory: true)
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/ContentView.swift b/ABS Client iOS/ABS Client iOS/Views/ContentView.swift
deleted file mode 100644
index e0f3fc5..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/ContentView.swift
+++ /dev/null
@@ -1,13 +0,0 @@
-import SwiftUI
-
-struct ContentView: View {
- @Environment(AppState.self) private var app
-
- var body: some View {
- if app.auth.isLoggedIn {
- MainView()
- } else {
- LoginView()
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/LibraryGridView.swift b/ABS Client iOS/ABS Client iOS/Views/LibraryGridView.swift
deleted file mode 100644
index baf97d1..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/LibraryGridView.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-import SwiftUI
-
-struct LibraryGridView: View {
- let items: [LibraryItem]
- var onRefresh: (() async -> Void)? = nil
- let onSelect: (LibraryItem) -> Void
-
- private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]
-
- var body: some View {
- ScrollView {
- LazyVGrid(columns: columns, spacing: 18) {
- ForEach(items) { item in
- LibraryItemCell(item: item)
- .contentShape(Rectangle())
- .onTapGesture { onSelect(item) }
- }
- }
- .padding(16)
- }
- .refreshable {
- await onRefresh?()
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift b/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift
deleted file mode 100644
index fadcf0a..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift
+++ /dev/null
@@ -1,136 +0,0 @@
-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 {
- 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?()
- }
- }
-}
-
-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)
- }
- }
- Spacer(minLength: 8)
- downloadStatus
- }
- .contextMenu { downloadMenuItems }
- }
-
- private var cover: some View {
- Group {
- if let url = app.client.coverURL(itemId: item.id) {
- AsyncImage(url: url) { phase in
- switch phase {
- case .empty:
- Rectangle().fill(.quaternary)
- case .success(let img):
- img.resizable().aspectRatio(contentMode: .fill)
- case .failure:
- Rectangle().fill(.quaternary)
- .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
- @unknown default:
- Rectangle().fill(.quaternary)
- }
- }
- } else {
- Rectangle().fill(.quaternary)
- }
- }
- .frame(width: 52, height: 52)
- .clipShape(RoundedRectangle(cornerRadius: 6))
- }
-
- @ViewBuilder
- private var downloadStatus: some View {
- let state = app.downloads.state(for: item.downloadKey)
- switch state {
- case .downloaded:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.white, .green)
- .font(.title3)
- case .downloading(let p):
- DownloadProgressRing(progress: p)
- .frame(width: 22, height: 22)
- 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")
- }
- }
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/LoginView.swift b/ABS Client iOS/ABS Client iOS/Views/LoginView.swift
deleted file mode 100644
index 8211439..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/LoginView.swift
+++ /dev/null
@@ -1,99 +0,0 @@
-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 {
- ScrollView {
- VStack(spacing: 18) {
- Spacer(minLength: 32)
- Image(systemName: "books.vertical.fill")
- .font(.system(size: 56))
- .foregroundStyle(.tint)
- Text("ABS Client")
- .font(.largeTitle).bold()
- Text("Verbinde dich mit deinem Audiobookshelf-Server")
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
-
- VStack(alignment: .leading, spacing: 14) {
- field(label: "Server-URL", placeholder: "https://abs.example.com") {
- TextField("https://abs.example.com", text: $serverURL)
- .textContentType(.URL)
- .keyboardType(.URL)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled(true)
- }
- field(label: "Benutzername", placeholder: "user") {
- TextField("user", text: $username)
- .textContentType(.username)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled(true)
- }
- field(label: "Passwort", placeholder: "••••••") {
- SecureField("••••••", text: $password)
- .textContentType(.password)
- }
- Toggle("Anmeldung merken", isOn: $remember)
- }
- .padding()
- .background(Color(.secondarySystemBackground))
- .clipShape(RoundedRectangle(cornerRadius: 12))
-
- if let err = app.auth.errorMessage {
- Text(err)
- .foregroundStyle(.red)
- .font(.callout)
- .multilineTextAlignment(.center)
- }
-
- Button(action: doLogin) {
- if isLoading {
- ProgressView()
- .frame(maxWidth: .infinity)
- } else {
- Text("Einloggen")
- .frame(maxWidth: .infinity)
- .font(.headline)
- }
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(isLoading || serverURL.isEmpty || username.isEmpty || password.isEmpty)
-
- Spacer(minLength: 32)
- }
- .padding(20)
- .frame(maxWidth: 480)
- .frame(maxWidth: .infinity)
- }
- }
-
- @ViewBuilder
- private func field(label: String, placeholder: String, @ViewBuilder content: () -> C) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(label).font(.subheadline).foregroundStyle(.secondary)
- content()
- .textFieldStyle(.roundedBorder)
- }
- }
-
- private func doLogin() {
- isLoading = true
- Task {
- await app.auth.login(
- serverURL: serverURL,
- username: username,
- password: password,
- remember: remember
- )
- isLoading = false
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/MainView.swift b/ABS Client iOS/ABS Client iOS/Views/MainView.swift
deleted file mode 100644
index e66cf08..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/MainView.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-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
- @State private var showSettings: Bool = false
-
- private var layout: LibraryLayout {
- LibraryLayout(rawValue: layoutRaw) ?? .grid
- }
-
- var body: some View {
- NavigationStack(path: $navPath) {
- detail
- .navigationDestination(for: LibraryItem.self) { podcast in
- PodcastDetailView(podcast: podcast)
- }
- }
- .task {
- await vm.loadLibraries(client: app.client)
- await vm.loadItems(client: app.client, downloads: app.downloads)
- await app.refreshProgressCache()
- }
- .onChange(of: vm.selection) { _, _ in
- navPath.removeAll()
- Task {
- await vm.loadItems(client: app.client, downloads: app.downloads)
- await app.refreshProgressCache()
- }
- }
- .safeAreaInset(edge: .bottom, spacing: 0) {
- PlayerBar()
- .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
- .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
- }
- .sheet(isPresented: $showSettings) {
- SettingsView()
- .environment(app)
- }
- }
-
- private func reloadAll() 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) }
- }
- }
-
- @ViewBuilder
- private var detail: some View {
- Group {
- 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 {
- switch layout {
- case .grid:
- LibraryGridView(items: vm.items, onRefresh: reloadAll) { handleSelect($0) }
- case .list:
- LibraryListView(items: vm.items, onRefresh: reloadAll) { handleSelect($0) }
- }
- }
- }
- .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()
- statusFooter
- } label: {
- Image(systemName: "ellipsis.circle")
- }
- }
- }
- }
-
- 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 statusFooter: 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"
- }
- }
-
- 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"
- }
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/PlayerBar.swift b/ABS Client iOS/ABS Client iOS/Views/PlayerBar.swift
deleted file mode 100644
index b6db43b..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/PlayerBar.swift
+++ /dev/null
@@ -1,207 +0,0 @@
-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
- @State private var expanded: Bool = false
-
- var body: some View {
- if let item = app.currentItem {
- VStack(spacing: 0) {
- Divider()
- content(item: item)
- .padding(.horizontal, 14)
- .padding(.top, 8)
- .padding(.bottom, 10)
- .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)
- }
- }
- }
-
- @ViewBuilder
- private func content(item: LibraryItem) -> some View {
- VStack(spacing: 8) {
- // Header row: cover, title, play/pause, expand
- 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)
- Button {
- withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
- } label: {
- Image(systemName: expanded ? "chevron.down" : "chevron.up")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(6)
- }
- .buttonStyle(.plain)
- }
- .contentShape(Rectangle())
- .onTapGesture {
- withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
- }
-
- if expanded {
- 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)
- }
- }
- }
-
- private func cover(item: LibraryItem) -> some View {
- Group {
- if let url = app.client.coverURL(itemId: item.id) {
- AsyncImage(url: url) { phase in
- if let img = phase.image {
- img.resizable().aspectRatio(contentMode: .fill)
- } else {
- Color.gray.opacity(0.3)
- }
- }
- } else {
- Color.gray.opacity(0.3)
- }
- }
- .frame(width: 44, height: 44)
- .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)))
- }
- }
-
- /// Pick the closest SF Symbol variant for the configured skip interval.
- /// Falls back to plain arrows when no exact match exists.
- 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)
- }
-}
diff --git a/ABS Client iOS/ABS Client iOS/Views/PodcastDetailView.swift b/ABS Client iOS/ABS Client iOS/Views/PodcastDetailView.swift
deleted file mode 100644
index aad138d..0000000
--- a/ABS Client iOS/ABS Client iOS/Views/PodcastDetailView.swift
+++ /dev/null
@@ -1,217 +0,0 @@
-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)
- .navigationBarTitleDisplayMode(.inline)
- .task { await load() }
- }
-
- private var header: some View {
- HStack(spacing: 14) {
- if let url = app.client.coverURL(itemId: podcast.id) {
- AsyncImage(url: url) { phase in
- if let img = phase.image { img.resizable().aspectRatio(contentMode: .fill) }
- else { Color.gray.opacity(0.3) }
- }
- .frame(width: 72, height: 72)
- .clipShape(RoundedRectangle(cornerRadius: 6))
- }
- VStack(alignment: .leading, spacing: 4) {
- Text(podcast.title).font(.headline).lineLimit(2)
- Text(podcast.author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
- if !episodes.isEmpty {
- Text("\(episodes.count) Folge\(episodes.count == 1 ? "" : "n")")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- Spacer()
- }
- .padding(16)
- }
-
- @ViewBuilder
- private var content: some View {
- if isLoading {
- ProgressView("Lade Folgen …")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else if let err = errorMessage {
- ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
- } else if episodes.isEmpty {
- ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
- } else {
- 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)
- }
- }
-
- private func load() async {
- isLoading = true
- do {
- let (detail, eps) = try await app.client.fetchEpisodes(podcastItemId: podcast.id)
- podcastDetail = detail
- episodes = eps
- errorMessage = nil
- } catch {
- errorMessage = error.localizedDescription
- }
- isLoading = false
- }
-}
-
-private struct EpisodeRow: View {
- @Environment(AppState.self) private var app
- let podcast: LibraryItem
- let episode: PodcastEpisode
-
- private var syntheticItem: LibraryItem {
- var item = LibraryItem(
- id: podcast.id,
- title: episode.title,
- author: podcast.title,
- durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
- audioFiles: [episode.audioFile]
- )
- item.mediaType = "podcast"
- item.episodeId = episode.id
- return item
- }
-
- var body: some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: "play.circle.fill")
- .font(.title)
- .foregroundStyle(.tint)
- .frame(width: 28)
- .padding(.top, 2)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(episode.title)
- .font(.subheadline).bold()
- .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)
- }
- }
- 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)
- }
- }
- Spacer(minLength: 0)
- downloadButton
- .padding(.top, 4)
- }
- .padding(.vertical, 4)
- .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)
- 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:
- Button {
- app.downloads.startDownload(item: syntheticItem)
- } label: {
- Image(systemName: "exclamationmark.arrow.circlepath")
- .font(.title3)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
- }
-
- @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"
- }
-}
diff --git a/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj
similarity index 87%
rename from ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj
rename to ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj
index 5302d98..8c41f64 100644
--- a/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj
+++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj
@@ -177,12 +177,15 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
- SDKROOT = macosx;
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -234,11 +237,14 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
- SDKROOT = macosx;
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -260,20 +266,37 @@
INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ "GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
+ "GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
+ "INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
+ "INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=iphoneos*]" = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=iphonesimulator*]" = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES;
+ SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
@@ -294,20 +317,37 @@
INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ "GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
+ "GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
+ "INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
+ "INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
+ "LD_RUNPATH_SEARCH_PATHS[sdk=iphoneos*]" = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=iphonesimulator*]" = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES;
+ SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
diff --git a/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ABS Client/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata
rename to ABS Client/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata
diff --git a/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
rename to ABS Client/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
diff --git a/ABS Client/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json b/ABS Client/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..85feeea
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.376",
+ "green" : "0.682",
+ "red" : "0.153"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.443",
+ "green" : "0.800",
+ "red" : "0.180"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json
similarity index 90%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json
index 64dc11e..7646f91 100644
--- a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,5 +1,11 @@
{
"images" : [
+ {
+ "filename" : "icon_512x512@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/Contents.json b/ABS Client/Audiobookshelf swift/Assets.xcassets/Contents.json
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Assets.xcassets/Contents.json
rename to ABS Client/Audiobookshelf swift/Assets.xcassets/Contents.json
diff --git a/ABS Client/Audiobookshelf swift/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ABS Client/Audiobookshelf swift/Assets.xcassets/LaunchBackground.colorset/Contents.json
new file mode 100644
index 0000000..0425637
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.000",
+ "green" : "0.000",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift
new file mode 100644
index 0000000..1981542
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+#if os(iOS)
+import AVFAudio
+#endif
+
+@main
+struct Audiobookshelf_swiftApp: App {
+ @State private var appState = AppState()
+
+ init() {
+ #if os(iOS)
+ configureAudioSession()
+ #endif
+ }
+
+ var body: some Scene {
+ #if os(macOS)
+ WindowGroup {
+ ContentView()
+ .environment(appState)
+ }
+ .windowResizability(.contentSize)
+
+ Settings {
+ SettingsView()
+ .environment(appState)
+ }
+ #else
+ WindowGroup {
+ ContentView()
+ .environment(appState)
+ }
+ #endif
+ }
+
+ #if os(iOS)
+ private func configureAudioSession() {
+ do {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.playback, mode: .default, options: [])
+ try session.setActive(true)
+ } catch {
+ // non-fatal
+ }
+ }
+ #endif
+}
diff --git a/ABS Client Mac/Audiobookshelf swift/Models/APIResponses.swift b/ABS Client/Audiobookshelf swift/Models/APIResponses.swift
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Models/APIResponses.swift
rename to ABS Client/Audiobookshelf swift/Models/APIResponses.swift
diff --git a/ABS Client Mac/Audiobookshelf swift/Models/Models.swift b/ABS Client/Audiobookshelf swift/Models/Models.swift
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Models/Models.swift
rename to ABS Client/Audiobookshelf swift/Models/Models.swift
diff --git a/ABS Client iOS/ABS Client iOS/Services/ABSClient.swift b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift
similarity index 88%
rename from ABS Client iOS/ABS Client iOS/Services/ABSClient.swift
rename to ABS Client/Audiobookshelf swift/Services/ABSClient.swift
index 8bb4c25..bdbebb3 100644
--- a/ABS Client iOS/ABS Client iOS/Services/ABSClient.swift
+++ b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift
@@ -16,17 +16,36 @@ enum ABSClientError: LocalizedError {
}
}
+// Accepts any server certificate so that private servers with self-signed or
+// custom-CA certificates work without needing the CA installed on-device.
+// Acceptable because the user explicitly configures the server URL themselves.
+private final class AnyServerTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
+ func urlSession(
+ _ session: URLSession,
+ didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
+ ) {
+ if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
+ let trust = challenge.protectionSpace.serverTrust {
+ completionHandler(.useCredential, URLCredential(trust: trust))
+ } else {
+ completionHandler(.performDefaultHandling, nil)
+ }
+ }
+}
+
@MainActor
final class ABSClient {
private let auth: AuthStore
- private let session: URLSession
+ private let sessionDelegate = AnyServerTrustDelegate()
+ private(set) var session: URLSession = URLSession.shared
init(auth: AuthStore) {
self.auth = auth
let config = URLSessionConfiguration.default
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.waitsForConnectivity = false
- self.session = URLSession(configuration: config)
+ self.session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
}
private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {
diff --git a/ABS Client Mac/Audiobookshelf swift/Services/AppState.swift b/ABS Client/Audiobookshelf swift/Services/AppState.swift
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Services/AppState.swift
rename to ABS Client/Audiobookshelf swift/Services/AppState.swift
diff --git a/ABS Client Mac/Audiobookshelf swift/Services/AuthStore.swift b/ABS Client/Audiobookshelf swift/Services/AuthStore.swift
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Services/AuthStore.swift
rename to ABS Client/Audiobookshelf swift/Services/AuthStore.swift
diff --git a/ABS Client Mac/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
similarity index 77%
rename from ABS Client Mac/Audiobookshelf swift/Services/DownloadManager.swift
rename to ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
index a7eea39..de67f9c 100644
--- a/ABS Client Mac/Audiobookshelf swift/Services/DownloadManager.swift
+++ b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
@@ -96,8 +96,6 @@ final class DownloadManager {
}
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
- /// For a podcast episode pass the synthetic LibraryItem that the AppState builds
- /// (item.id == podcastItemId, item.episodeId == episodeId, audioFiles == [episode.audioFile]).
func startDownload(item: LibraryItem) {
let key = item.downloadKey
guard activeTasks[key] == nil else { return }
@@ -107,8 +105,6 @@ final class DownloadManager {
guard let self else { return }
var workItem = item
- // Books may arrive with empty audioFiles (list endpoint omits them).
- // Episodes always arrive populated, since AppState builds the synthetic item.
if !workItem.isPodcast && workItem.audioFiles.isEmpty {
do {
workItem = try await self.client.fetchItemDetail(itemId: item.id)
@@ -140,7 +136,6 @@ final class DownloadManager {
if let item = downloadedItems[downloadKey] {
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
try? FileManager.default.removeItem(at: dir)
- // If this was an episode and the podcast's parent directory is now empty, clean it up too.
if item.episodeId != nil {
let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
@@ -187,29 +182,34 @@ final class DownloadManager {
var request = URLRequest(url: url)
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
+ let tempURL: URL
do {
- let (tempURL, response) = try await URLSession.shared.download(for: request)
- if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
- states[downloadKey] = .failed(message: "HTTP \(http.statusCode) bei Datei \(file.filename)")
- try? FileManager.default.removeItem(at: tempURL)
- return
- }
- let ext = file.ext.isEmpty ? "mp3" : file.ext
- let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
- let dest = itemDir.appendingPathComponent(destName)
- try? FileManager.default.removeItem(at: dest)
- try FileManager.default.moveItem(at: tempURL, to: dest)
- tracks.append(DownloadedTrack(
- ino: file.ino,
- filename: file.filename,
- localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
- durationSeconds: file.durationSeconds
- ))
- states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
+ tempURL = try await downloadWithRetry(request: request, filename: file.filename)
+ } catch is CancellationError {
+ states[downloadKey] = .notDownloaded
+ return
} catch {
states[downloadKey] = .failed(message: error.localizedDescription)
return
}
+
+ let ext = file.ext.isEmpty ? "mp3" : file.ext
+ let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
+ let dest = itemDir.appendingPathComponent(destName)
+ do {
+ try? FileManager.default.removeItem(at: dest)
+ try FileManager.default.moveItem(at: tempURL, to: dest)
+ } catch {
+ states[downloadKey] = .failed(message: error.localizedDescription)
+ return
+ }
+ tracks.append(DownloadedTrack(
+ ino: file.ino,
+ filename: file.filename,
+ localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
+ durationSeconds: file.durationSeconds
+ ))
+ states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
}
let downloaded = DownloadedItem(
@@ -225,10 +225,46 @@ final class DownloadManager {
persistIndex()
}
+ /// Downloads with up to `maxAttempts` retries and resume-data support so a brief
+ /// network dropout picks up where it left off. Uses the client session so that
+ /// self-signed server certificates are accepted.
+ private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL {
+ let session = client.session
+ var resumeData: Data? = nil
+ var lastError: Error = URLError(.unknown)
+
+ for attempt in 0.. 0 {
let count = max(1, trackDurations.count)
let perTrack = item.durationSeconds / Double(count)
@@ -105,6 +110,9 @@ final class PlayerEngine {
currentAuthor = item.author
currentCoverURL = client.coverURL(itemId: item.id)
+ #if os(iOS)
+ configureAudioSessionObserversIfNeeded()
+ #endif
configureRemoteCommandsIfNeeded()
updateNowPlayingInfo()
fetchAndAttachArtwork()
@@ -113,6 +121,9 @@ final class PlayerEngine {
}
func play() {
+ #if os(iOS)
+ try? AVAudioSession.sharedInstance().setActive(true)
+ #endif
player?.play()
player?.rate = rate
isPlaying = true
@@ -153,16 +164,13 @@ final class PlayerEngine {
switchToTrack(index: trackIndex)
absoluteCurrentTime = clamped
guard let currentItem = player?.currentItem else {
- // No item to seek (e.g. empty queue after failed insert) — don't
- // leave isSeeking stuck, which would freeze refreshAbsoluteTime.
+ // No item to seek: don't leave isSeeking stuck, which would freeze the scrubber.
return
}
isSeeking = true
let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600)
- // Use a small tolerance so seeking succeeds on formats where exact
- // keyframe alignment isn't guaranteed (e.g. VBR MP3 without a Xing
- // header). Zero-tolerance seeks fail silently on such files, causing
- // the slider to snap back because the player position never moved.
+ // Small tolerance so seeking succeeds on VBR MP3s without a Xing header.
+ // Zero-tolerance seeks fail silently on such files, snapping the slider back.
let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600)
currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in
Task { @MainActor [weak self] in
@@ -208,8 +216,6 @@ final class PlayerEngine {
let trackTime = current.currentTime().seconds
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
- // AVPlayer can report an item duration slightly longer than the metadata we have.
- // Clamp the visible time so the scrubber/labels never exceed totalDuration.
let cap = totalDuration > 0 ? totalDuration : absolute
absoluteCurrentTime = max(0, min(absolute, cap))
let wasPlaying = isPlaying
@@ -238,6 +244,7 @@ final class PlayerEngine {
currentTitle = ""
currentAuthor = ""
currentCoverURL = nil
+ artworkSession = nil
clearNowPlayingInfo()
}
@@ -272,7 +279,6 @@ final class PlayerEngine {
Task { @MainActor in self?.skip(by: -s) }
return .success
}
- // Keep the lock-screen icon in sync with the user's preference.
NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification, object: nil, queue: .main
) { [weak self] _ in
@@ -310,7 +316,6 @@ final class PlayerEngine {
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
]
- // Preserve artwork across updates so we don't blank the lock-screen image.
if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo,
let art = existing[MPMediaItemPropertyArtwork] {
info[MPMediaItemPropertyArtwork] = art
@@ -320,13 +325,13 @@ final class PlayerEngine {
private func fetchAndAttachArtwork() {
guard let url = currentCoverURL else { return }
+ let session = artworkSession ?? URLSession.shared
Task.detached {
do {
- let (data, _) = try await URLSession.shared.data(from: url)
+ let (data, _) = try await session.data(from: url)
guard let img = PlayerArtworkImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
await MainActor.run { [weak self] in
- // Drop the result if the user has since switched items.
guard let self, self.currentCoverURL == url else { return }
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPMediaItemPropertyArtwork] = artwork
@@ -346,9 +351,74 @@ final class PlayerEngine {
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seconds)]
}
- /// Reads the user-configured skip duration; defaults to 30s when unset.
static func currentSkipSeconds() -> Int {
let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds")
return raw > 0 ? raw : 30
}
+
+ // MARK: - iOS audio session observers
+
+ #if os(iOS)
+ private func configureAudioSessionObserversIfNeeded() {
+ guard !audioSessionObserversConfigured else { return }
+ audioSessionObserversConfigured = true
+
+ let center = NotificationCenter.default
+
+ center.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main
+ ) { [weak self] notification in
+ Task { @MainActor [weak self] in
+ self?.handleAudioInterruption(notification: notification)
+ }
+ }
+
+ center.addObserver(
+ forName: AVAudioSession.routeChangeNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main
+ ) { [weak self] notification in
+ Task { @MainActor [weak self] in
+ self?.handleRouteChange(notification: notification)
+ }
+ }
+ }
+
+ private func handleAudioInterruption(notification: Notification) {
+ guard let info = notification.userInfo,
+ let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
+ let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
+
+ switch type {
+ case .began:
+ wasPlayingBeforeInterruption = isPlaying
+ if isPlaying {
+ player?.pause()
+ isPlaying = false
+ updateNowPlayingInfo()
+ }
+ case .ended:
+ let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw)
+ try? AVAudioSession.sharedInstance().setActive(true)
+ if wasPlayingBeforeInterruption && options.contains(.shouldResume) {
+ play()
+ }
+ @unknown default:
+ break
+ }
+ }
+
+ private func handleRouteChange(notification: Notification) {
+ guard let info = notification.userInfo,
+ let reasonRaw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
+ // Pause when headphones are unplugged (Apple's recommended behavior).
+ if reason == .oldDeviceUnavailable {
+ pause()
+ }
+ }
+ #endif
}
diff --git a/ABS Client Mac/Audiobookshelf swift/Services/ProgressSyncManager.swift b/ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift
similarity index 100%
rename from ABS Client Mac/Audiobookshelf swift/Services/ProgressSyncManager.swift
rename to ABS Client/Audiobookshelf swift/Services/ProgressSyncManager.swift
diff --git a/ABS Client/Audiobookshelf swift/Views/ContentView.swift b/ABS Client/Audiobookshelf swift/Views/ContentView.swift
new file mode 100644
index 0000000..c25fb46
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Views/ContentView.swift
@@ -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
+ }
+}
diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift
new file mode 100644
index 0000000..9b714a0
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift
@@ -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
+ }
+}
diff --git a/ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
similarity index 68%
rename from ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift
rename to ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
index f53e3de..f9881dd 100644
--- a/ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift
+++ b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
@@ -5,32 +5,68 @@ struct LibraryItemCell: View {
let item: LibraryItem
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
+ VStack(alignment: .leading, spacing: 2) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
cover
- downloadBadge
- .padding(6)
+ downloadBadge.padding(4)
}
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
- .padding(.horizontal, 6)
- .padding(.bottom, 6)
+ .padding(.horizontal, 3)
+ .padding(.bottom, 3)
}
Text(item.title)
- .font(.subheadline).bold()
- .lineLimit(2, reservesSpace: true)
+ #if os(iOS)
+ .font(.system(size: 11, weight: .bold))
+ #else
+ .font(.headline)
+ #endif
+ .lineLimit(2)
.multilineTextAlignment(.leading)
Text(item.author)
- .font(.caption)
+ .font(.system(size: 9))
.foregroundStyle(.secondary)
- .lineLimit(1, reservesSpace: true)
- }
- .contextMenu {
- downloadMenuItems
+ .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
@@ -51,9 +87,12 @@ struct LibraryItemCell: View {
Rectangle().fill(.quaternary)
}
}
- .aspectRatio(1, contentMode: .fit)
+ .frame(width: 180, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+ #endif
+
+ // MARK: - Download badge
@ViewBuilder
private var downloadBadge: some View {
@@ -66,6 +105,11 @@ struct LibraryItemCell: View {
.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)
@@ -76,6 +120,8 @@ struct LibraryItemCell: View {
}
}
+ // MARK: - Context menu
+
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.downloadKey
@@ -107,7 +153,8 @@ struct LibraryItemCell: View {
}
}
-/// Green progress bar drawn at the bottom of a cover. Hidden when no progress.
+// MARK: - Shared components
+
struct CoverProgressBar: View {
let fraction: Double
@@ -119,7 +166,7 @@ struct CoverProgressBar: View {
.fill(Color.black.opacity(0.55))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2, style: .continuous)
- .fill(Color.green)
+ .fill(Color.accentColor)
.frame(width: max(2, geo.size.width * fraction), height: 4)
}
}
@@ -141,15 +188,14 @@ struct DownloadProgressRing: View {
.padding(4)
Circle()
.trim(from: 0, to: max(0.03, min(progress, 1)))
- .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
+ .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: 12, weight: .bold))
+ .font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
}
- .frame(width: 28, height: 28)
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
}
}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
similarity index 84%
rename from ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift
rename to ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
index 7bedc30..5f83d4f 100644
--- a/ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift
+++ b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
@@ -11,9 +11,24 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
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
@@ -27,6 +42,7 @@ struct LibraryListView: View {
}
.padding(.vertical, 4)
}
+ #endif
}
}
@@ -56,20 +72,28 @@ struct LibraryListRow: View {
}
.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 }
}
@@ -93,8 +117,13 @@ struct LibraryListRow: View {
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
@@ -107,7 +136,11 @@ struct LibraryListRow: View {
.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)
@@ -143,6 +176,7 @@ struct LibraryListRow: View {
}
}
+ #if os(macOS)
private func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "" }
let total = Int(seconds)
@@ -151,4 +185,5 @@ struct LibraryListRow: View {
if h > 0 { return "\(h) h \(m) min" }
return "\(m) min"
}
+ #endif
}
diff --git a/ABS Client/Audiobookshelf swift/Views/LoginView.swift b/ABS Client/Audiobookshelf swift/Views/LoginView.swift
new file mode 100644
index 0000000..eedbe68
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Views/LoginView.swift
@@ -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(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
+ }
+ }
+}
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/MainView.swift b/ABS Client/Audiobookshelf swift/Views/MainView.swift
similarity index 60%
rename from ABS Client Mac/Audiobookshelf swift/Views/MainView.swift
rename to ABS Client/Audiobookshelf swift/Views/MainView.swift
index 64fef1f..c64201b 100644
--- a/ABS Client Mac/Audiobookshelf swift/Views/MainView.swift
+++ b/ABS Client/Audiobookshelf swift/Views/MainView.swift
@@ -73,12 +73,70 @@ struct MainView: View {
@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: {
@@ -89,23 +147,13 @@ struct MainView: View {
}
}
}
- .task {
- await vm.loadLibraries(client: app.client)
- await vm.loadItems(client: app.client, downloads: app.downloads)
- await app.refreshProgressCache()
- }
- .onChange(of: vm.selection) { _, _ in
- navPath.removeAll()
- Task {
- await vm.loadItems(client: app.client, downloads: app.downloads)
- await app.refreshProgressCache()
- }
- }
- .safeAreaInset(edge: .bottom, spacing: 0) {
- PlayerBar()
- .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
- .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
- }
+ #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) {
@@ -116,6 +164,9 @@ struct MainView: View {
}
}
+ // MARK: - macOS sidebar
+
+ #if os(macOS)
private var sidebar: some View {
List(selection: $vm.selection) {
Section("Bibliotheken") {
@@ -166,6 +217,9 @@ struct MainView: View {
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
+ #endif
+
+ // MARK: - Detail content (shared)
@ViewBuilder
private var detail: some View {
@@ -180,24 +234,17 @@ struct MainView: View {
Group {
switch layout {
case .grid:
- LibraryGridView(items: vm.items) { item in
- handleSelect(item)
- }
+ LibraryGridView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
case .list:
- LibraryListView(items: vm.items) { item in
- handleSelect(item)
- }
+ LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
}
}
+ #if os(macOS)
.navigationTitle(currentTitle)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
- Task {
- await vm.loadLibraries(client: app.client)
- await vm.loadItems(client: app.client, downloads: app.downloads)
- await app.refreshProgressCache()
- }
+ Task { await loadAll() }
} label: {
if vm.isLoading {
ProgressView().controlSize(.small)
@@ -220,9 +267,65 @@ struct MainView: View {
.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):
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
similarity index 72%
rename from ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift
rename to ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
index 448cdfd..f0c71d9 100644
--- a/ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift
+++ b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
@@ -12,7 +12,12 @@ struct PlayerBar: View {
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))
@@ -20,7 +25,7 @@ struct PlayerBar: View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
- ProgressView().controlSize(.small)
+ ProgressView()
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
@@ -31,6 +36,62 @@ struct PlayerBar: View {
}
}
+ // 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) {
@@ -67,24 +128,6 @@ struct PlayerBar: View {
}
}
- private func cover(item: LibraryItem) -> some View {
- Group {
- if let url = app.client.coverURL(itemId: item.id) {
- AsyncImage(url: url) { phase in
- if let img = phase.image {
- img.resizable().aspectRatio(contentMode: .fill)
- } else {
- Color.gray.opacity(0.3)
- }
- }
- } else {
- Color.gray.opacity(0.3)
- }
- }
- .frame(width: 48, height: 48)
- .clipShape(RoundedRectangle(cornerRadius: 6))
- }
-
private var transportControls: some View {
HStack(spacing: 14) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
@@ -109,26 +152,45 @@ struct PlayerBar: View {
}
}
- 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 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
- 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"
+ // 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 {
@@ -176,26 +238,36 @@ struct PlayerBar: View {
} label: {
Text(String(format: "%.2g×", Double(app.player.rate)))
.font(.caption.monospacedDigit())
- .padding(.horizontal, 8).padding(.vertical, 4)
+ .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 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)
- }
+ 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"
}
- .help(app.network.isOnline ? "Online – Fortschritt wird synchronisiert" : "Offline – \(app.sync.queuedCount) Eintrag/Einträge wartend")
}
private func formatTime(_ seconds: Double) -> String {
diff --git a/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift b/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
similarity index 86%
rename from ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift
rename to ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
index 4e97f61..31351dd 100644
--- a/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift
+++ b/ABS Client/Audiobookshelf swift/Views/PodcastDetailView.swift
@@ -16,6 +16,9 @@ struct PodcastDetailView: View {
content
}
.navigationTitle(podcastDetail?.title ?? podcast.title)
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
.task { await load() }
}
@@ -30,7 +33,11 @@ struct PodcastDetailView: View {
.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")")
@@ -53,6 +60,18 @@ struct PodcastDetailView: View {
} 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
@@ -67,6 +86,7 @@ struct PodcastDetailView: View {
}
}
}
+ #endif
}
}
@@ -107,12 +127,18 @@ private struct EpisodeRow: View {
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 {
@@ -127,12 +153,14 @@ private struct EpisodeRow: View {
.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 {
@@ -145,16 +173,24 @@ private struct EpisodeRow: View {
}
.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 }
}
@@ -172,17 +208,17 @@ private struct EpisodeRow: View {
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
+ #if os(macOS)
.help("Episode für Offline herunterladen")
+ #endif
case .downloading(let p):
DownloadProgressRing(progress: p)
- .frame(width: 24, height: 24)
+ .frame(width: 22, height: 22)
.onTapGesture { app.downloads.cancel(downloadKey: key) }
- .help("\(Int(p * 100)) % – zum Abbrechen klicken")
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
- .help("Heruntergeladen")
case .failed(let msg):
Button {
app.downloads.startDownload(item: syntheticItem)
@@ -192,7 +228,7 @@ private struct EpisodeRow: View {
.foregroundStyle(.red)
}
.buttonStyle(.plain)
- .help("Fehlgeschlagen: \(msg) – zum Wiederholen klicken")
+ .help("Fehlgeschlagen: \(msg)")
}
}
diff --git a/ABS Client iOS/ABS Client iOS/Views/SettingsView.swift b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift
similarity index 56%
rename from ABS Client iOS/ABS Client iOS/Views/SettingsView.swift
rename to ABS Client/Audiobookshelf swift/Views/SettingsView.swift
index a56db3c..ca83838 100644
--- a/ABS Client iOS/ABS Client iOS/Views/SettingsView.swift
+++ b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift
@@ -2,7 +2,9 @@ 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
@@ -13,6 +15,7 @@ struct SettingsView: View {
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
var body: some View {
+ #if os(iOS)
NavigationStack {
Form {
connectionSection
@@ -40,11 +43,44 @@ struct SettingsView: View {
}
Button("Abbrechen", role: .cancel) { }
} message: {
- Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Hörbücher bleiben erhalten.")
+ 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") {
@@ -62,7 +98,6 @@ struct SettingsView: View {
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 8, height: 8)
Text(app.network.isOnline ? "Online" : "Offline")
- Spacer()
if app.sync.queuedCount > 0 {
Text("\(app.sync.queuedCount) wartend")
.foregroundStyle(.secondary)
@@ -117,7 +152,7 @@ struct SettingsView: View {
} header: {
Text("Downloads")
} footer: {
- Text("Heruntergeladene Hörbücher und Folgen können einzeln in der Bibliothek über das Kontextmenü gelöscht werden.")
+ Text("Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden.")
}
}
@@ -130,6 +165,82 @@ struct SettingsView: View {
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 ?? "?"
diff --git a/ABS Client/Audiobookshelf swift/Views/SplashView.swift b/ABS Client/Audiobookshelf swift/Views/SplashView.swift
new file mode 100644
index 0000000..680aee5
--- /dev/null
+++ b/ABS Client/Audiobookshelf swift/Views/SplashView.swift
@@ -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
+ }
+ }
+ }
+}
diff --git a/ABS Client/Info-iOS.plist b/ABS Client/Info-iOS.plist
new file mode 100644
index 0000000..0461174
--- /dev/null
+++ b/ABS Client/Info-iOS.plist
@@ -0,0 +1,50 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ ABS Client
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ABS Client
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSApplicationCategoryType
+ public.app-category.books
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UIBackgroundModes
+
+ audio
+
+ UILaunchScreen
+
+ UIColorName
+ LaunchBackground
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+