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 + + +