diff --git a/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj similarity index 90% rename from Audiobookshelf swift.xcodeproj/project.pbxproj rename to ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj index 2a1de0e..5302d98 100644 --- a/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - 39614D0B2FB4D44500DBEF5E /* Audiobookshelf swift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Audiobookshelf swift.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -40,7 +40,7 @@ 39614D0C2FB4D44500DBEF5E /* Products */ = { isa = PBXGroup; children = ( - 39614D0B2FB4D44500DBEF5E /* Audiobookshelf swift.app */, + 39614D0B2FB4D44500DBEF5E /* ABS Client.app */, ); name = Products; sourceTree = ""; @@ -67,7 +67,7 @@ packageProductDependencies = ( ); productName = "Audiobookshelf swift"; - productReference = 39614D0B2FB4D44500DBEF5E /* Audiobookshelf swift.app */; + productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -248,21 +248,25 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "ABS Client"; + INFOPLIST_KEY_CFBundleName = "ABS Client"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.local.Audiobookshelf-swift"; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; + PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -278,21 +282,25 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "ABS Client"; + INFOPLIST_KEY_CFBundleName = "ABS Client"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.local.Audiobookshelf-swift"; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; + PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ABS Client Mac/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to ABS Client Mac/Audiobookshelf swift.xcodeproj/project.xcworkspace/contents.xcworkspacedata 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 new file mode 100644 index 0000000..38c8462 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 100% rename from Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist rename to ABS Client Mac/Audiobookshelf swift.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json rename to ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AccentColor.colorset/Contents.json 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 new file mode 100644 index 0000000..4f335a0 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Bild.png differ diff --git a/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 69% rename from Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json rename to ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..64dc11e 100644 --- a/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,51 +1,61 @@ { "images" : [ { + "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..57320e1 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..8a2048e Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..acc0c71 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..28cdf34 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..8a2048e Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..e3e4362 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..28cdf34 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..f834489 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..e3e4362 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..4f335a0 Binary files /dev/null and b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Audiobookshelf swift/Assets.xcassets/Contents.json b/ABS Client Mac/Audiobookshelf swift/Assets.xcassets/Contents.json similarity index 100% rename from Audiobookshelf swift/Assets.xcassets/Contents.json rename to ABS Client Mac/Audiobookshelf swift/Assets.xcassets/Contents.json diff --git a/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift new file mode 100644 index 0000000..470b266 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Audiobookshelf_swiftApp.swift @@ -0,0 +1,20 @@ +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/Models/APIResponses.swift b/ABS Client Mac/Audiobookshelf swift/Models/APIResponses.swift new file mode 100644 index 0000000..43c4d08 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Models/APIResponses.swift @@ -0,0 +1,86 @@ +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 Mac/Audiobookshelf swift/Models/Models.swift b/ABS Client Mac/Audiobookshelf swift/Models/Models.swift new file mode 100644 index 0000000..236ec7a --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Models/Models.swift @@ -0,0 +1,81 @@ +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 Mac/Audiobookshelf swift/Services/ABSClient.swift b/ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift new file mode 100644 index 0000000..8bb4c25 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift @@ -0,0 +1,209 @@ +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/Services/AppState.swift b/ABS Client Mac/Audiobookshelf swift/Services/AppState.swift new file mode 100644 index 0000000..cb0b748 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/AppState.swift @@ -0,0 +1,224 @@ +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 Mac/Audiobookshelf swift/Services/AuthStore.swift b/ABS Client Mac/Audiobookshelf swift/Services/AuthStore.swift new file mode 100644 index 0000000..ccb7a9c --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/AuthStore.swift @@ -0,0 +1,94 @@ +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 Mac/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client Mac/Audiobookshelf swift/Services/DownloadManager.swift new file mode 100644 index 0000000..a7eea39 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/DownloadManager.swift @@ -0,0 +1,259 @@ +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 Mac/Audiobookshelf swift/Services/KeychainStore.swift b/ABS Client Mac/Audiobookshelf swift/Services/KeychainStore.swift new file mode 100644 index 0000000..bb31e18 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/KeychainStore.swift @@ -0,0 +1,59 @@ +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 Mac/Audiobookshelf swift/Services/NetworkMonitor.swift b/ABS Client Mac/Audiobookshelf swift/Services/NetworkMonitor.swift new file mode 100644 index 0000000..26ab95e --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/NetworkMonitor.swift @@ -0,0 +1,31 @@ +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 Mac/Audiobookshelf swift/Services/PlayerEngine.swift b/ABS Client Mac/Audiobookshelf swift/Services/PlayerEngine.swift new file mode 100644 index 0000000..163d60c --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/PlayerEngine.swift @@ -0,0 +1,354 @@ +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 { + let count = max(1, trackDurations.count) + let perTrack = item.durationSeconds / Double(count) + trackDurations = Array(repeating: perTrack, count: count) + } + + totalDuration = trackDurations.reduce(0, +) + + trackPlayerItems = urls.map { AVPlayerItem(url: $0) } + + let queue = AVQueuePlayer(items: trackPlayerItems) + queue.rate = rate + self.player = queue + + let center = NotificationCenter.default + for (idx, playerItem) in trackPlayerItems.enumerated() { + let token = center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleTrackEnd(finishedIndex: idx) + } + } + endObservers.append(token) + } + + seekAbsolute(absoluteTime) + + timeObserver = queue.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshAbsoluteTime() + } + } + + currentTitle = item.title + currentAuthor = item.author + currentCoverURL = client.coverURL(itemId: item.id) + + configureRemoteCommandsIfNeeded() + updateNowPlayingInfo() + fetchAndAttachArtwork() + + isReady = true + } + + func play() { + player?.play() + player?.rate = rate + isPlaying = true + updateNowPlayingInfo() + } + + func pause() { + player?.pause() + isPlaying = false + updateNowPlayingInfo() + } + + func togglePlay() { + isPlaying ? pause() : play() + } + + func setRate(_ newRate: Float) { + rate = newRate + if isPlaying { player?.rate = newRate } + updateNowPlayingInfo() + } + + func skip(by seconds: Double) { + seekAbsolute(absoluteCurrentTime + seconds) + } + + func seekAbsolute(_ target: Double) { + let clamped = max(0, min(target, max(totalDuration - 0.5, 0))) + var remaining = clamped + var trackIndex = 0 + for (idx, dur) in trackDurations.enumerated() { + if remaining <= dur || idx == trackDurations.count - 1 { + trackIndex = idx + break + } + remaining -= dur + } + 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. + 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. + let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600) + currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in + Task { @MainActor [weak self] in + self?.isSeeking = false + self?.refreshAbsoluteTime() + self?.updateNowPlayingInfo() + } + } + } + + private func switchToTrack(index: Int) { + guard index < trackPlayerItems.count, let player else { return } + if index == currentTrackIndex, player.currentItem === trackPlayerItems[index] { return } + + let wasPlaying = isPlaying + player.removeAllItems() + for i in index.. 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 Mac/Audiobookshelf swift/Services/ProgressSyncManager.swift b/ABS Client Mac/Audiobookshelf swift/Services/ProgressSyncManager.swift new file mode 100644 index 0000000..2c7cd0e --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Services/ProgressSyncManager.swift @@ -0,0 +1,94 @@ +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 Mac/Audiobookshelf swift/Views/ContentView.swift b/ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift new file mode 100644 index 0000000..d48720b --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct ContentView: View { + @Environment(AppState.self) private var app + + var body: some View { + Group { + if app.auth.isLoggedIn { + MainView() + } else { + LoginView() + } + } + .frame(minWidth: 900, minHeight: 600) + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift b/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift new file mode 100644 index 0000000..6dc6a3a --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/LibraryGridView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct LibraryGridView: View { + let items: [LibraryItem] + let onSelect: (LibraryItem) -> Void + + private let columns = [GridItem(.adaptive(minimum: 180), spacing: 20)] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(items) { item in + LibraryItemCell(item: item) + .contentShape(Rectangle()) + .onTapGesture { onSelect(item) } + } + } + .padding(20) + } + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift b/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift new file mode 100644 index 0000000..ee97a8c --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct LibraryItemCell: View { + @Environment(AppState.self) private var app + let item: LibraryItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottom) { + ZStack(alignment: .topTrailing) { + cover + downloadBadge + .padding(8) + } + CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId)) + .padding(.horizontal, 6) + .padding(.bottom, 6) + } + Text(item.title) + .font(.headline) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.leading) + Text(item.author) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1, reservesSpace: true) + } + .contextMenu { + downloadMenuItems + } + } + + private var cover: some View { + Group { + if let url = app.client.coverURL(itemId: item.id) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle().fill(.quaternary) + .overlay(ProgressView().controlSize(.small)) + case .success(let img): + img.resizable().aspectRatio(contentMode: .fill) + case .failure: + Rectangle().fill(.quaternary) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + @unknown default: + Rectangle().fill(.quaternary) + } + } + } else { + Rectangle().fill(.quaternary) + } + } + .frame(width: 180, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + @ViewBuilder + private var downloadBadge: some View { + let state = app.downloads.state(for: item.syncKey) + switch state { + case .downloaded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + .shadow(radius: 2) + case .downloading(let p): + DownloadProgressRing(progress: p) + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.white, .red) + .font(.title3) + .shadow(radius: 2) + case .notDownloaded: + EmptyView() + } + } + + @ViewBuilder + private var downloadMenuItems: some View { + let key = item.syncKey + let state = app.downloads.state(for: key) + if item.isPodcastContainer { + // Whole-podcast downloads aren't supported; instructions only. + Text("Episoden zum Download in der Podcast-Ansicht auswählen") + } else { + switch state { + case .notDownloaded, .failed: + Button { + app.downloads.startDownload(item: item) + } label: { + Label("Für Offline herunterladen", systemImage: "arrow.down.circle") + } + case .downloading: + Button { + app.downloads.cancel(downloadKey: key) + } label: { + Label("Download abbrechen", systemImage: "xmark.circle") + } + case .downloaded: + Button(role: .destructive) { + app.downloads.delete(downloadKey: key) + } label: { + Label("Heruntergeladene Dateien löschen", systemImage: "trash") + } + } + } + } +} + +/// Green progress bar drawn at the bottom of a cover (grid view). +/// Hidden completely when there's no known progress. +struct CoverProgressBar: View { + let fraction: Double + + var body: some View { + if fraction > 0 { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.black.opacity(0.55)) + .frame(height: 4) + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.green) + .frame(width: max(2, geo.size.width * fraction), height: 4) + } + } + .frame(height: 4) + .shadow(color: .black.opacity(0.35), radius: 1, y: 1) + } + } +} + +struct DownloadProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .fill(Color.black.opacity(0.75)) + Circle() + .stroke(Color.white.opacity(0.25), lineWidth: 3) + .padding(4) + Circle() + .trim(from: 0, to: max(0.03, min(progress, 1))) + .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(4) + .animation(.easeInOut(duration: 0.25), value: progress) + Image(systemName: "arrow.down") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: 32, height: 32) + .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1) + .help("Wird heruntergeladen … \(Int(progress * 100)) %") + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift b/ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift new file mode 100644 index 0000000..7bedc30 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +enum LibraryLayout: String, CaseIterable, Identifiable { + case grid + case list + + var id: String { rawValue } + var label: String { self == .grid ? "Kachelansicht" : "Listenansicht" } + var systemImage: String { self == .grid ? "square.grid.2x2" : "list.bullet" } +} + +struct LibraryListView: View { + let items: [LibraryItem] + let onSelect: (LibraryItem) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in + LibraryListRow(item: item) + .contentShape(Rectangle()) + .onTapGesture { onSelect(item) } + if idx < items.count - 1 { + Divider().padding(.leading, 76) + } + } + } + .padding(.vertical, 4) + } + } +} + +struct LibraryListRow: View { + @Environment(AppState.self) private var app + let item: LibraryItem + + var body: some View { + HStack(spacing: 12) { + cover + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.headline) + .lineLimit(1) + Text(item.author) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId) + if fraction > 0 { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3)) + RoundedRectangle(cornerRadius: 1.5).fill(Color.green) + .frame(width: max(2, geo.size.width * fraction)) + } + } + .frame(height: 3) + .padding(.top, 2) + .padding(.trailing, 40) + } + } + Spacer(minLength: 8) + if item.durationSeconds > 0 { + Text(formatDuration(item.durationSeconds)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + downloadStatus + .frame(width: 28) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .contextMenu { downloadMenuItems } + } + + private var cover: some View { + Group { + if let url = app.client.coverURL(itemId: item.id) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + Rectangle().fill(.quaternary) + case .success(let img): + img.resizable().aspectRatio(contentMode: .fill) + case .failure: + Rectangle().fill(.quaternary) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + @unknown default: + Rectangle().fill(.quaternary) + } + } + } else { + Rectangle().fill(.quaternary) + } + } + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + @ViewBuilder + private var downloadStatus: some View { + let state = app.downloads.state(for: item.downloadKey) + switch state { + case .downloaded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + case .downloading(let p): + DownloadProgressRing(progress: p) + .frame(width: 24, height: 24) + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.white, .red) + .font(.title3) + case .notDownloaded: + EmptyView() + } + } + + @ViewBuilder + private var downloadMenuItems: some View { + let key = item.downloadKey + let state = app.downloads.state(for: key) + if item.isPodcastContainer { + Text("Episoden zum Download in der Podcast-Ansicht auswählen") + } else { + switch state { + case .notDownloaded, .failed: + Button { app.downloads.startDownload(item: item) } label: { + Label("Für Offline herunterladen", systemImage: "arrow.down.circle") + } + case .downloading: + Button { app.downloads.cancel(downloadKey: key) } label: { + Label("Download abbrechen", systemImage: "xmark.circle") + } + case .downloaded: + Button(role: .destructive) { + app.downloads.delete(downloadKey: key) + } label: { + Label("Heruntergeladene Dateien löschen", systemImage: "trash") + } + } + } + } + + private func formatDuration(_ seconds: Double) -> String { + guard seconds.isFinite, seconds > 0 else { return "" } + let total = Int(seconds) + let h = total / 3600 + let m = (total % 3600) / 60 + if h > 0 { return "\(h) h \(m) min" } + return "\(m) min" + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift b/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift new file mode 100644 index 0000000..8a3d91a --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AppState.self) private var app + + @State private var serverURL: String = "" + @State private var username: String = "" + @State private var password: String = "" + @State private var remember: Bool = true + @State private var isLoading: Bool = false + + var body: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "books.vertical.fill") + .font(.system(size: 48)) + .foregroundStyle(.tint) + Text("ABS Client") + .font(.largeTitle).bold() + Text("Verbinde dich mit deinem Audiobookshelf-Server") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 12) { + LabeledField(label: "Server-URL", placeholder: "https://abs.example.com") { + TextField("", text: $serverURL) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + } + LabeledField(label: "Benutzername", placeholder: "user") { + TextField("", text: $username) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + } + LabeledField(label: "Passwort", placeholder: "••••••") { + SecureField("", text: $password) + .textFieldStyle(.roundedBorder) + } + Toggle("Anmeldung merken", isOn: $remember) + .toggleStyle(.checkbox) + } + .frame(maxWidth: 380) + + if let err = app.auth.errorMessage { + Text(err) + .foregroundStyle(.red) + .font(.callout) + .multilineTextAlignment(.center) + .frame(maxWidth: 380) + } + + Button(action: doLogin) { + if isLoading { + ProgressView().controlSize(.small) + } else { + Text("Einloggen") + .frame(maxWidth: 200) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isLoading || serverURL.isEmpty || username.isEmpty || password.isEmpty) + .keyboardShortcut(.defaultAction) + + Spacer() + } + .padding(32) + } + + private func doLogin() { + isLoading = true + Task { + await app.auth.login( + serverURL: serverURL, + username: username, + password: password, + remember: remember + ) + isLoading = false + } + } +} + +private struct LabeledField: 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/MainView.swift b/ABS Client Mac/Audiobookshelf swift/Views/MainView.swift new file mode 100644 index 0000000..64fef1f --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/MainView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +enum LibraryFilter: Hashable { + case library(String) + case downloaded +} + +@Observable +@MainActor +final class LibraryViewModel { + var libraries: [Library] = [] + var items: [LibraryItem] = [] + var isLoading: Bool = false + var errorMessage: String? + var selection: LibraryFilter? + + func loadLibraries(client: ABSClient) async { + isLoading = true + defer { isLoading = false } + do { + libraries = try await client.fetchLibraries() + if selection == nil, let first = libraries.first { + selection = .library(first.id) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func loadItems(client: ABSClient, downloads: DownloadManager) async { + guard let selection else { return } + isLoading = true + defer { isLoading = false } + switch selection { + case .library(let id): + do { + items = try await client.fetchItems(libraryId: id) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + case .downloaded: + items = downloads.downloadedItems.values.map { di in + let files: [AudioFile] = di.tracks.enumerated().map { idx, t in + AudioFile( + ino: t.ino, + filename: t.filename, + ext: "", + durationSeconds: t.durationSeconds, + index: idx + ) + } + var li = LibraryItem( + id: di.itemId, + title: di.title, + author: di.author, + durationSeconds: di.durationSeconds, + audioFiles: files + ) + if let episodeId = di.episodeId { + li.mediaType = "podcast" + li.episodeId = episodeId + } + return li + }.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + errorMessage = nil + } + } +} + +struct MainView: View { + @Environment(AppState.self) private var app + @State private var vm = LibraryViewModel() + @State private var navPath: [LibraryItem] = [] + @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue + + private var layout: LibraryLayout { + LibraryLayout(rawValue: layoutRaw) ?? .grid + } + + var body: some View { + NavigationSplitView { + sidebar + } detail: { + NavigationStack(path: $navPath) { + detail + .navigationDestination(for: LibraryItem.self) { podcast in + PodcastDetailView(podcast: podcast) + } + } + } + .task { + await vm.loadLibraries(client: app.client) + await vm.loadItems(client: app.client, downloads: app.downloads) + await app.refreshProgressCache() + } + .onChange(of: vm.selection) { _, _ in + navPath.removeAll() + Task { + await vm.loadItems(client: app.client, downloads: app.downloads) + await app.refreshProgressCache() + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + PlayerBar() + .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id) + .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback) + } + } + + private func handleSelect(_ item: LibraryItem) { + if item.isPodcastContainer { + navPath.append(item) + } else { + Task { await app.play(item: item) } + } + } + + private var sidebar: some View { + List(selection: $vm.selection) { + Section("Bibliotheken") { + ForEach(vm.libraries) { lib in + Label(lib.name, systemImage: "books.vertical") + .tag(LibraryFilter.library(lib.id)) + } + } + Section("Offline") { + Label("Heruntergeladen", systemImage: "arrow.down.circle.fill") + .tag(LibraryFilter.downloaded) + } + } + .listStyle(.sidebar) + .navigationTitle("ABS Client") + .safeAreaInset(edge: .bottom) { + sidebarFooter + } + } + + private var sidebarFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Divider() + HStack(spacing: 8) { + Circle() + .fill(app.network.isOnline ? .green : .orange) + .frame(width: 8, height: 8) + Text(app.network.isOnline ? "Online" : "Offline") + .font(.caption) + if app.sync.queuedCount > 0 { + Text("(\(app.sync.queuedCount) wartend)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + HStack { + Text(app.auth.username).font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Abmelden") { + app.stopPlayback() + app.auth.logout() + } + .buttonStyle(.borderless) + .font(.caption) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var detail: some View { + if vm.isLoading && vm.items.isEmpty { + ProgressView("Lade Bibliothek …") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let err = vm.errorMessage, vm.items.isEmpty { + ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) + } else if vm.items.isEmpty { + ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher.")) + } else { + Group { + switch layout { + case .grid: + LibraryGridView(items: vm.items) { item in + handleSelect(item) + } + case .list: + LibraryListView(items: vm.items) { item in + handleSelect(item) + } + } + } + .navigationTitle(currentTitle) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await vm.loadLibraries(client: app.client) + await vm.loadItems(client: app.client, downloads: app.downloads) + await app.refreshProgressCache() + } + } label: { + if vm.isLoading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .help("Bibliothek, Cover und Hörfortschritte neu laden") + .disabled(vm.isLoading) + } + ToolbarItem(placement: .primaryAction) { + Picker("Ansicht", selection: $layoutRaw) { + ForEach(LibraryLayout.allCases) { l in + Image(systemName: l.systemImage) + .help(l.label) + .tag(l.rawValue) + } + } + .pickerStyle(.segmented) + .help("Zwischen Kachel- und Listenansicht wechseln") + } + } + } + } + + private var currentTitle: String { + switch vm.selection { + case .library(let id): + return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek" + case .downloaded: + return "Heruntergeladen" + case .none: + return "Bibliothek" + } + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift b/ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift new file mode 100644 index 0000000..448cdfd --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift @@ -0,0 +1,212 @@ +import SwiftUI + +struct PlayerBar: View { + @Environment(AppState.self) private var app + @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30 + @State private var scrubbing: Bool = false + @State private var scrubValue: Double = 0 + + var body: some View { + if let item = app.currentItem { + VStack(spacing: 0) { + Divider() + content(item: item) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else if app.isPreparingPlayback { + VStack(spacing: 0) { + Divider() + HStack(spacing: 12) { + ProgressView().controlSize(.small) + Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(.bar) + } + } + } + + @ViewBuilder + private func content(item: LibraryItem) -> some View { + HStack(spacing: 14) { + cover(item: item) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.subheadline).bold().lineLimit(1) + Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1) + if let err = app.player.errorMessage { + Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1) + } + } + .frame(minWidth: 160, idealWidth: 200, maxWidth: 240, alignment: .leading) + + transportControls + + scrubber + .frame(minWidth: 200) + + rateMenu + + Spacer(minLength: 0) + + statusIndicator + + Button { + app.stopPlayback() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Wiedergabe beenden") + } + } + + private func cover(item: LibraryItem) -> some View { + Group { + if let url = app.client.coverURL(itemId: item.id) { + AsyncImage(url: url) { phase in + if let img = phase.image { + img.resizable().aspectRatio(contentMode: .fill) + } else { + Color.gray.opacity(0.3) + } + } + } else { + Color.gray.opacity(0.3) + } + } + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private var transportControls: some View { + HStack(spacing: 14) { + Button { app.skip(by: -Double(skipSeconds)) } label: { + Image(systemName: skipBackImage).font(.system(size: 18)) + } + .buttonStyle(.plain) + .disabled(!app.player.isReady) + + Button { app.togglePlay() } label: { + Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 34)) + } + .buttonStyle(.plain) + .disabled(!app.player.isReady) + .keyboardShortcut(.space, modifiers: []) + + Button { app.skip(by: Double(skipSeconds)) } label: { + Image(systemName: skipForwardImage).font(.system(size: 18)) + } + .buttonStyle(.plain) + .disabled(!app.player.isReady) + } + } + + private var skipForwardImage: String { + switch skipSeconds { + case ...10: return "goforward.10" + case 11...15: return "goforward.15" + case 16...30: return "goforward.30" + case 31...45: return "goforward.45" + case 46...60: return "goforward.60" + default: return "goforward.90" + } + } + + private var skipBackImage: String { + switch skipSeconds { + case ...10: return "gobackward.10" + case 11...15: return "gobackward.15" + case 16...30: return "gobackward.30" + case 31...45: return "gobackward.45" + case 46...60: return "gobackward.60" + default: return "gobackward.90" + } + } + + private var scrubber: some View { + VStack(spacing: 2) { + Slider( + value: Binding( + get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime }, + set: { scrubValue = $0; scrubbing = true } + ), + in: 0...max(app.player.totalDuration, 1), + onEditingChanged: { editing in + if !editing { + app.seekAbsolute(scrubValue) + scrubbing = false + } + } + ) + .disabled(!app.player.isReady) + HStack { + Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer() + Text(formatTime(app.player.totalDuration)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private var rateMenu: some View { + Menu { + ForEach([0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { r in + Button { + app.setRate(Float(r)) + } label: { + HStack { + Text(String(format: "%.2g×", r)) + if abs(Double(app.player.rate) - r) < 0.01 { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Text(String(format: "%.2g×", Double(app.player.rate))) + .font(.caption.monospacedDigit()) + .padding(.horizontal, 8).padding(.vertical, 4) + .overlay(Capsule().stroke(Color.secondary.opacity(0.4))) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Geschwindigkeit") + } + + private var statusIndicator: some View { + HStack(spacing: 4) { + Circle() + .fill(app.network.isOnline ? .green : .orange) + .frame(width: 6, height: 6) + if app.sync.queuedCount > 0 { + Text("\(app.sync.queuedCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .help(app.network.isOnline ? "Online – Fortschritt wird synchronisiert" : "Offline – \(app.sync.queuedCount) Eintrag/Einträge wartend") + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let total = Int(seconds) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%d:%02d", m, s) + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift b/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift new file mode 100644 index 0000000..4e97f61 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/PodcastDetailView.swift @@ -0,0 +1,233 @@ +import SwiftUI + +struct PodcastDetailView: View { + @Environment(AppState.self) private var app + let podcast: LibraryItem + + @State private var episodes: [PodcastEpisode] = [] + @State private var podcastDetail: LibraryItem? + @State private var isLoading: Bool = true + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + } + .navigationTitle(podcastDetail?.title ?? podcast.title) + .task { await load() } + } + + private var header: some View { + HStack(spacing: 14) { + if let url = app.client.coverURL(itemId: podcast.id) { + AsyncImage(url: url) { phase in + if let img = phase.image { img.resizable().aspectRatio(contentMode: .fill) } + else { Color.gray.opacity(0.3) } + } + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + VStack(alignment: .leading, spacing: 4) { + Text(podcast.title).font(.title3).bold().lineLimit(2) + Text(podcast.author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) + if !episodes.isEmpty { + Text("\(episodes.count) Folge\(episodes.count == 1 ? "" : "n")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .padding(16) + } + + @ViewBuilder + private var content: some View { + if isLoading { + ProgressView("Lade Folgen …") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let err = errorMessage { + ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) + } else if episodes.isEmpty { + ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen.")) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in + EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep) + .contentShape(Rectangle()) + .onTapGesture { + Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) } + } + if idx < episodes.count - 1 { + Divider().padding(.leading, 16) + } + } + } + } + } + } + + private func load() async { + isLoading = true + do { + let (detail, eps) = try await app.client.fetchEpisodes(podcastItemId: podcast.id) + podcastDetail = detail + episodes = eps + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } +} + +private struct EpisodeRow: View { + @Environment(AppState.self) private var app + let podcast: LibraryItem + let episode: PodcastEpisode + + private var syntheticItem: LibraryItem { + var item = LibraryItem( + id: podcast.id, + title: episode.title, + author: podcast.title, + durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds, + audioFiles: [episode.audioFile] + ) + item.mediaType = "podcast" + item.episodeId = episode.id + return item + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "play.circle.fill") + .font(.title) + .foregroundStyle(.tint) + .frame(width: 28) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text(episode.title) + .font(.headline) + .lineLimit(2) + HStack(spacing: 10) { + if let date = episode.formattedDate { + Label(date, systemImage: "calendar") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(.secondary) + } + if episode.durationSeconds > 0 { + Label(formatDuration(episode.durationSeconds), systemImage: "clock") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(.secondary) + } + if let season = episode.season, !season.isEmpty { + Text("S\(season)").font(.caption).foregroundStyle(.secondary) + } + if let ep = episode.episode, !ep.isEmpty { + Text("F\(ep)").font(.caption).foregroundStyle(.secondary) + } + } + let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id) + if frac > 0 { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3)) + RoundedRectangle(cornerRadius: 1.5).fill(Color.green) + .frame(width: max(2, geo.size.width * frac)) + } + } + .frame(height: 3) + .padding(.top, 2) + .padding(.trailing, 40) + } + } + Spacer(minLength: 0) + downloadButton + .frame(width: 32) + .padding(.top, 4) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contextMenu { contextMenuItems } + } + + @ViewBuilder + private var downloadButton: some View { + let key = syntheticItem.downloadKey + let state = app.downloads.state(for: key) + switch state { + case .notDownloaded: + Button { + app.downloads.startDownload(item: syntheticItem) + } label: { + Image(systemName: "arrow.down.circle") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Episode für Offline herunterladen") + case .downloading(let p): + DownloadProgressRing(progress: p) + .frame(width: 24, height: 24) + .onTapGesture { app.downloads.cancel(downloadKey: key) } + .help("\(Int(p * 100)) % – zum Abbrechen klicken") + case .downloaded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + .help("Heruntergeladen") + case .failed(let msg): + Button { + app.downloads.startDownload(item: syntheticItem) + } label: { + Image(systemName: "exclamationmark.arrow.circlepath") + .font(.title3) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help("Fehlgeschlagen: \(msg) – zum Wiederholen klicken") + } + } + + @ViewBuilder + private var contextMenuItems: some View { + let key = syntheticItem.downloadKey + let state = app.downloads.state(for: key) + switch state { + case .notDownloaded, .failed: + Button { + app.downloads.startDownload(item: syntheticItem) + } label: { + Label("Folge herunterladen", systemImage: "arrow.down.circle") + } + case .downloading: + Button { + app.downloads.cancel(downloadKey: key) + } label: { + Label("Download abbrechen", systemImage: "xmark.circle") + } + case .downloaded: + Button(role: .destructive) { + app.downloads.delete(downloadKey: key) + } label: { + Label("Heruntergeladene Folge löschen", systemImage: "trash") + } + } + } + + private func formatDuration(_ seconds: Double) -> String { + guard seconds.isFinite, seconds > 0 else { return "" } + let total = Int(seconds) + let h = total / 3600 + let m = (total % 3600) / 60 + if h > 0 { return "\(h) h \(m) min" } + return "\(m) min" + } +} diff --git a/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift b/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift new file mode 100644 index 0000000..6a461c6 --- /dev/null +++ b/ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var app + + @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30 + @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue + @AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true + + @State private var showLogoutConfirm: Bool = false + + private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90] + + var body: some View { + TabView { + connectionPane + .tabItem { Label("Verbindung", systemImage: "server.rack") } + + playbackPane + .tabItem { Label("Wiedergabe", systemImage: "play.circle") } + + appearancePane + .tabItem { Label("Darstellung", systemImage: "square.grid.2x2") } + + aboutPane + .tabItem { Label("Über", systemImage: "info.circle") } + } + .padding(20) + .frame(width: 480, height: 320) + .confirmationDialog( + "Mit Server abmelden?", + isPresented: $showLogoutConfirm, + titleVisibility: .visible + ) { + Button("Abmelden", role: .destructive) { + app.stopPlayback() + app.auth.logout() + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.") + } + } + + private var connectionPane: some View { + Form { + LabeledContent("Server") { + Text(app.auth.serverURL.isEmpty ? "—" : app.auth.serverURL) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + LabeledContent("Benutzer") { + Text(app.auth.username.isEmpty ? "—" : app.auth.username) + .foregroundStyle(.secondary) + } + LabeledContent("Status") { + HStack(spacing: 6) { + Circle() + .fill(app.network.isOnline ? .green : .orange) + .frame(width: 8, height: 8) + Text(app.network.isOnline ? "Online" : "Offline") + if app.sync.queuedCount > 0 { + Text("(\(app.sync.queuedCount) wartend)") + .foregroundStyle(.secondary) + } + } + } + HStack { + Spacer() + Button(role: .destructive) { + showLogoutConfirm = true + } label: { + Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + .formStyle(.grouped) + } + + private var playbackPane: some View { + Form { + Picker("Sprung-Dauer", selection: $skipSeconds) { + ForEach(Self.skipOptions, id: \.self) { sec in + Text("\(sec) s").tag(sec) + } + } + Text("Gilt für die Skip-Knöpfe in der Player-Leiste und Medientasten.") + .font(.caption) + .foregroundStyle(.secondary) + } + .formStyle(.grouped) + } + + private var appearancePane: some View { + Form { + Picker("Bibliotheks-Ansicht", selection: $layoutRaw) { + ForEach(LibraryLayout.allCases) { l in + Label(l.label, systemImage: l.systemImage).tag(l.rawValue) + } + } + Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch) + } + .formStyle(.grouped) + } + + private var aboutPane: some View { + Form { + LabeledContent("Version", value: appVersion) + LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge") + } + .formStyle(.grouped) + } + + private var appVersion: String { + let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" + let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" + return "\(v) (\(b))" + } +} diff --git a/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj b/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8695dd9 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj @@ -0,0 +1,341 @@ +// !$*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 new file mode 100644 index 0000000..e4db99a --- /dev/null +++ b/ABS Client iOS/ABS Client iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + 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 new file mode 100644 index 0000000..388b9e2 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/ABS_Client_iOSApp.swift @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 0000000..f27760d --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "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 new file mode 100644 index 0000000..f760ef8 Binary files /dev/null and b/ABS Client iOS/ABS Client iOS/Assets.xcassets/AppIcon.appiconset/icon_1024.png 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 new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "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 new file mode 100644 index 0000000..43c4d08 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Models/APIResponses.swift @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..236ec7a --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Models/Models.swift @@ -0,0 +1,81 @@ +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/ABSClient.swift b/ABS Client iOS/ABS Client iOS/Services/ABSClient.swift new file mode 100644 index 0000000..8bb4c25 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/ABSClient.swift @@ -0,0 +1,209 @@ +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 iOS/ABS Client iOS/Services/AppState.swift b/ABS Client iOS/ABS Client iOS/Services/AppState.swift new file mode 100644 index 0000000..cb0b748 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/AppState.swift @@ -0,0 +1,224 @@ +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 new file mode 100644 index 0000000..ccb7a9c --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/AuthStore.swift @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..a7eea39 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/DownloadManager.swift @@ -0,0 +1,259 @@ +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 new file mode 100644 index 0000000..bb31e18 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/KeychainStore.swift @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..26ab95e --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/NetworkMonitor.swift @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..3042237 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/PlayerEngine.swift @@ -0,0 +1,336 @@ +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 new file mode 100644 index 0000000..2c7cd0e --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Services/ProgressSyncManager.swift @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..e0f3fc5 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/ContentView.swift @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..baf97d1 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/LibraryGridView.swift @@ -0,0 +1,25 @@ +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/LibraryItemCell.swift b/ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift new file mode 100644 index 0000000..f53e3de --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift @@ -0,0 +1,155 @@ +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(6) + } + CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId)) + .padding(.horizontal, 6) + .padding(.bottom, 6) + } + Text(item.title) + .font(.subheadline).bold() + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.leading) + Text(item.author) + .font(.caption) + .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) + } + } + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + @ViewBuilder + private var downloadBadge: some View { + let state = app.downloads.state(for: item.downloadKey) + switch state { + case .downloaded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white, .green) + .font(.title3) + .shadow(radius: 2) + case .downloading(let p): + DownloadProgressRing(progress: p) + 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.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") + } + } + } + } +} + +/// Green progress bar drawn at the bottom of a cover. Hidden when no 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: 28, height: 28) + .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1) + } +} diff --git a/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift b/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift new file mode 100644 index 0000000..fadcf0a --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..8211439 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/LoginView.swift @@ -0,0 +1,99 @@ +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 new file mode 100644 index 0000000..e66cf08 --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/MainView.swift @@ -0,0 +1,231 @@ +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 new file mode 100644 index 0000000..b6db43b --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/PlayerBar.swift @@ -0,0 +1,207 @@ +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 new file mode 100644 index 0000000..aad138d --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/PodcastDetailView.swift @@ -0,0 +1,217 @@ +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 iOS/ABS Client iOS/Views/SettingsView.swift b/ABS Client iOS/ABS Client iOS/Views/SettingsView.swift new file mode 100644 index 0000000..a56db3c --- /dev/null +++ b/ABS Client iOS/ABS Client iOS/Views/SettingsView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var app + @Environment(\.dismiss) private var dismiss + + @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 { + NavigationStack { + Form { + connectionSection + playbackSection + appearanceSection + downloadsSection + aboutSection + } + .navigationTitle("Einstellungen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Fertig") { dismiss() } + } + } + .confirmationDialog( + "Mit Server abmelden?", + isPresented: $showLogoutConfirm, + titleVisibility: .visible + ) { + Button("Abmelden", role: .destructive) { + app.stopPlayback() + app.auth.logout() + dismiss() + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Hörbücher bleiben erhalten.") + } + } + } + + private var connectionSection: some View { + Section { + LabeledContent("Server") { + Text(app.auth.serverURL.isEmpty ? "—" : app.auth.serverURL) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + LabeledContent("Benutzer") { + Text(app.auth.username.isEmpty ? "—" : app.auth.username) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + Circle() + .fill(app.network.isOnline ? .green : .orange) + .frame(width: 8, height: 8) + Text(app.network.isOnline ? "Online" : "Offline") + Spacer() + if app.sync.queuedCount > 0 { + Text("\(app.sync.queuedCount) wartend") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + Button(role: .destructive) { + showLogoutConfirm = true + } label: { + Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right") + } + } header: { + Text("Verbindung") + } footer: { + Text("Abmelden setzt die gespeicherten Anmeldedaten zurück. Heruntergeladene Inhalte bleiben.") + } + } + + private var playbackSection: some View { + Section { + Picker("Sprung-Dauer", selection: $skipSeconds) { + ForEach(Self.skipOptions, id: \.self) { sec in + Text("\(sec) s").tag(sec) + } + } + } header: { + Text("Wiedergabe") + } footer: { + Text("Gilt für die Skip-Knöpfe in der Player-Leiste und auf dem Sperrbildschirm.") + } + } + + private var appearanceSection: some View { + Section { + Picker("Bibliotheks-Ansicht", selection: $layoutRaw) { + ForEach(LibraryLayout.allCases) { l in + Label(l.label, systemImage: l.systemImage).tag(l.rawValue) + } + } + Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch) + } header: { + Text("Darstellung") + } + } + + private var downloadsSection: some View { + Section { + LabeledContent("Heruntergeladen") { + Text("\(app.downloads.downloadedItems.count) Einträge") + .foregroundStyle(.secondary) + } + } header: { + Text("Downloads") + } footer: { + Text("Heruntergeladene Hörbücher und Folgen können einzeln in der Bibliothek über das Kontextmenü gelöscht werden.") + } + } + + private var aboutSection: some View { + Section { + LabeledContent("Version") { + Text(appVersion).foregroundStyle(.secondary) + } + } header: { + Text("Über") + } + } + + 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/Audiobookshelf swift/ABSService.swift b/Audiobookshelf swift/ABSService.swift deleted file mode 100644 index 99b0cc5..0000000 --- a/Audiobookshelf swift/ABSService.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -class ABSService: ObservableObject { - static let shared = ABSService() - - func fetchLibraries(serverURL: String, token: String) async throws -> [Library] { - let url = URL(string: "\(serverURL)/api/libraries")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(LibrariesResponse.self, from: data) - return response.libraries - } - - func fetchBooks(serverURL: String, token: String, libraryId: String) async throws -> [AudiobookItem] { - let url = URL(string: "\(serverURL)/api/libraries/\(libraryId)/items?limit=100")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(LibraryItemsResponse.self, from: data) - return response.results.compactMap { item in - AudiobookItem( - id: item.id, - title: item.media.metadata.title ?? "Unbekannt", - author: item.media.metadata.authorName ?? "Unbekannt", - coverURL: item.media.coverPath != nil ? "\(serverURL)/api/items/\(item.id)/cover" : nil, - duration: item.media.duration ?? 0, - mediaFiles: item.media.audioFiles?.map { - AudiobookItem.MediaFile(ino: $0.ino, name: $0.metadata.filename, path: $0.metadata.path) - } ?? [] - ) - } - } - - func fetchProgress(serverURL: String, token: String, itemId: String) async throws -> LibraryProgress? { - let url = URL(string: "\(serverURL)/api/me/progress/\(itemId)")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } - return try? JSONDecoder().decode(LibraryProgress.self, from: data) - } - - func updateProgress(serverURL: String, token: String, itemId: String, currentTime: Double, duration: Double) async { - guard let url = URL(string: "\(serverURL)/api/me/progress/\(itemId)") else { return } - var request = URLRequest(url: url) - request.httpMethod = "PATCH" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: Any] = [ - "currentTime": currentTime, - "duration": duration, - "isFinished": currentTime >= duration - 5 - ] - request.httpBody = try? JSONSerialization.data(withJSONObject: body) - try? await URLSession.shared.data(for: request) - } -} - -// MARK: - API Response Models -struct LibrariesResponse: Codable { - let libraries: [Library] -} - -struct LibraryItemsResponse: Codable { - let results: [RawLibraryItem] -} - -struct RawLibraryItem: Codable { - let id: String - let media: RawMedia -} - -struct RawMedia: Codable { - let metadata: RawMetadata - let coverPath: String? - let duration: Double? - let audioFiles: [RawAudioFile]? -} - -struct RawMetadata: Codable { - let title: String? - let authorName: String? -} - -struct RawAudioFile: Codable { - let ino: String - let metadata: RawFileMetadata -} - -struct RawFileMetadata: Codable { - let filename: String - let path: String -} diff --git a/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/Audiobookshelf swift/Audiobookshelf_swiftApp.swift deleted file mode 100644 index f85d16b..0000000 --- a/Audiobookshelf swift/Audiobookshelf_swiftApp.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Audiobookshelf_swiftApp.swift -// Audiobookshelf swift -// -// Created by Guido Schmit on 13.05.2026. -// - -import SwiftUI - -@main -struct Audiobookshelf_swiftApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/Audiobookshelf swift/AuthManager.swift b/Audiobookshelf swift/AuthManager.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Audiobookshelf swift/ContentView.swift b/Audiobookshelf swift/ContentView.swift deleted file mode 100644 index 8840b00..0000000 --- a/Audiobookshelf swift/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// Audiobookshelf swift -// -// Created by Guido Schmit on 13.05.2026. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/Audiobookshelf swift/LibraryView.swift b/Audiobookshelf swift/LibraryView.swift deleted file mode 100644 index 0b6b9be..0000000 --- a/Audiobookshelf swift/LibraryView.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// LibraryView.swift -// Audiobookshelf swift -// -// Created by Guido Schmit on 13.05.2026. -// - - -import SwiftUI - -struct LibraryView: View { - @ObservedObject var authManager: AuthManager - @State private var books: [AudiobookItem] = [] - @State private var isLoading = true - @State private var errorMessage = "" - - var body: some View { - NavigationView { - Group { - if isLoading { - ProgressView("Lädt Bibliothek...") - } else if !errorMessage.isEmpty { - Text(errorMessage).foregroundColor(.red) - } else { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) { - ForEach(books) { book in - BookCard(book: book, authManager: authManager) - } - } - .padding() - } - } - } - .navigationTitle("Meine Bibliothek") - .toolbar { - Button("Logout") { - authManager.logout() - } - } - } - .onAppear { loadBooks() } - } - - func loadBooks() { - guard let serverURL = authManager.serverURL, - let token = authManager.token else { return } - - // Erst Libraries laden, dann Bücher - let url = URL(string: "\(serverURL)/api/libraries")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - URLSession.shared.dataTask(with: request) { data, _, error in - guard let data = data else { return } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let libraries = json["libraries"] as? [[String: Any]], - let firstLib = libraries.first, - let libId = firstLib["id"] as? String { - loadBooksFromLibrary(libId: libId, serverURL: serverURL, token: token) - } - }.resume() - } - - func loadBooksFromLibrary(libId: String, serverURL: String, token: String) { - let url = URL(string: "\(serverURL)/api/libraries/\(libId)/items")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - URLSession.shared.dataTask(with: request) { data, _, _ in - guard let data = data else { return } - if let json = try? JSON diff --git a/Audiobookshelf swift/LoginResponse.swift b/Audiobookshelf swift/LoginResponse.swift deleted file mode 100644 index fa37989..0000000 --- a/Audiobookshelf swift/LoginResponse.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -// MARK: - Auth -struct LoginResponse: Codable { - let user: UserResponse -} - -struct UserResponse: Codable { - let token: String - let id: String -} - -// MARK: - Library -struct Library: Codable, Identifiable { - let id: String - let name: String -} - -// MARK: - AudiobookItem -struct AudiobookItem: Identifiable { - let id: String - let title: String - let author: String - let coverURL: String? - let duration: Double - let mediaFiles: [MediaFile] - - struct MediaFile { - let ino: String - let name: String - let path: String - } -} - -// MARK: - Progress -struct LibraryProgress: Codable { - let currentTime: Double - let duration: Double? - let isFinished: Bool? - - enum CodingKeys: String, CodingKey { - case currentTime - case duration - case isFinished - } -} diff --git a/Audiobookshelf swift/PlayerView.swift b/Audiobookshelf swift/PlayerView.swift deleted file mode 100644 index 2ce3b49..0000000 --- a/Audiobookshelf swift/PlayerView.swift +++ /dev/null @@ -1,215 +0,0 @@ -import SwiftUI -import AVFoundation - -struct PlayerView: View { - let book: AudiobookItem - @EnvironmentObject var authManager: AuthManager - @StateObject private var vm = PlayerViewModel() - - var body: some View { - VStack(spacing: 24) { - // Cover - if let coverURL = book.coverURL, let url = URL(string: coverURL) { - AsyncImage(url: url) { image in - image.resizable().aspectRatio(contentMode: .fit) - } placeholder: { - Rectangle().fill(Color.gray.opacity(0.3)) - .overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray)) - } - .frame(maxWidth: 280, maxHeight: 280) - .cornerRadius(12) - .shadow(radius: 8) - } else { - Rectangle().fill(Color.gray.opacity(0.3)) - .frame(width: 280, height: 280) - .cornerRadius(12) - .overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray)) - } - - // Titel & Autor - VStack(spacing: 4) { - Text(book.title) - .font(.title2).bold() - .multilineTextAlignment(.center) - Text(book.author) - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.horizontal) - - // Fortschrittsbalken - VStack(spacing: 4) { - Slider(value: $vm.currentTime, in: 0...max(vm.duration, 1)) { editing in - if !editing { - vm.seek(to: vm.currentTime) - } - } - .padding(.horizontal) - - HStack { - Text(formatTime(vm.currentTime)) - Spacer() - Text(formatTime(vm.duration)) - } - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - } - - // Steuerung - HStack(spacing: 40) { - Button(action: { vm.skip(-30) }) { - Image(systemName: "gobackward.30") - .font(.title) - } - - Button(action: { vm.togglePlayPause() }) { - Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 64)) - } - - Button(action: { vm.skip(30) }) { - Image(systemName: "goforward.30") - .font(.title) - } - } - .foregroundColor(.accentColor) - - // Wiedergabegeschwindigkeit - HStack { - Text("Geschwindigkeit:") - .font(.caption) - Picker("", selection: $vm.playbackRate) { - Text("0.75x").tag(Float(0.75)) - Text("1.0x").tag(Float(1.0)) - Text("1.25x").tag(Float(1.25)) - Text("1.5x").tag(Float(1.5)) - Text("2.0x").tag(Float(2.0)) - } - .pickerStyle(.segmented) - } - .padding(.horizontal) - - Spacer() - } - .padding(.top) - .navigationTitle("Player") - .navigationBarTitleDisplayMode(.inline) - .onAppear { - vm.setup(book: book, authManager: authManager) - } - .onDisappear { - vm.saveProgress(authManager: authManager) - } - } - - func formatTime(_ seconds: Double) -> String { - let s = Int(seconds) - return String(format: "%d:%02d:%02d", s/3600, (s%3600)/60, s%60) - } -} - -// MARK: - PlayerViewModel -class PlayerViewModel: ObservableObject { - @Published var isPlaying = false - @Published var currentTime: Double = 0 - @Published var duration: Double = 0 - @Published var playbackRate: Float = 1.0 { - didSet { player?.rate = isPlaying ? playbackRate : 0 } - } - - private var player: AVPlayer? - private var playerItem: AVPlayerItem? - private var timeObserver: Any? - private var book: AudiobookItem? - private var authManager: AuthManager? - - func setup(book: AudiobookItem, authManager: AuthManager) { - self.book = book - self.authManager = authManager - - guard let serverURL = authManager.serverURL, - let token = authManager.token, - let firstFile = book.mediaFiles.first else { return } - - let urlString = "\(serverURL)/api/items/\(book.id)/file/\(firstFile.ino)" - guard let url = URL(string: urlString) else { return } - - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": ["Authorization": "Bearer \(token)"]]) - playerItem = AVPlayerItem(asset: asset) - player = AVPlayer(playerItem: playerItem) - - // Duration - Task { - if let dur = try? await asset.load(.duration) { - await MainActor.run { - self.duration = dur.seconds - } - } - } - - // Fortschritt laden - Task { - if let progress = try? await ABSService.shared.fetchProgress( - serverURL: serverURL, token: token, itemId: book.id) { - await MainActor.run { - self.currentTime = progress.currentTime - self.player?.seek(to: CMTime(seconds: progress.currentTime, preferredTimescale: 1000)) - } - } - } - - // Time Observer - let interval = CMTime(seconds: 1, preferredTimescale: 1000) - timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in - self?.currentTime = time.seconds - } - } - - func togglePlayPause() { - guard let player else { return } - if isPlaying { - player.pause() - } else { - player.rate = playbackRate - } - isPlaying.toggle() - } - - func skip(_ seconds: Double) { - guard let player else { return } - let newTime = max(0, min(currentTime + seconds, duration)) - player.seek(to: CMTime(seconds: newTime, preferredTimescale: 1000)) - currentTime = newTime - } - - func seek(to time: Double) { - player?.seek(to: CMTime(seconds: time, preferredTimescale: 1000)) - } - - func saveProgress(authManager: AuthManager) { - guard let book, - let serverURL = authManager.serverURL, - let token = authManager.token else { return } - - Task { - await ABSService.shared.updateProgress( - serverURL: serverURL, - token: token, - itemId: book.id, - currentTime: currentTime, - duration: duration - ) - } - } - - deinit { - if let timeObserver { - player?.removeTimeObserver(timeObserver) - } - player?.pause() - } -} diff --git a/Exports/Mac/ABS-Client.dmg b/Exports/Mac/ABS-Client.dmg new file mode 100644 index 0000000..a5b77b8 Binary files /dev/null and b/Exports/Mac/ABS-Client.dmg differ diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/Info.plist b/Exports/Mac/ABS/ABS Client.app/Contents/Info.plist new file mode 100644 index 0000000..5ce97ac --- /dev/null +++ b/Exports/Mac/ABS/ABS Client.app/Contents/Info.plist @@ -0,0 +1,52 @@ + + + + + BuildMachineOSBuild + 25F71 + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ABS Client + CFBundleExecutable + ABS Client + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + com.local.Audiobookshelf-swift + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ABS Client + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 25F70 + DTPlatformName + macosx + DTPlatformVersion + 26.5 + DTSDKBuild + 25F70 + DTSDKName + macosx26.5 + DTXcode + 2650 + DTXcodeBuild + 17F42 + LSMinimumSystemVersion + 26.4 + + diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/MacOS/ABS Client b/Exports/Mac/ABS/ABS Client.app/Contents/MacOS/ABS Client new file mode 100755 index 0000000..27bb168 Binary files /dev/null and b/Exports/Mac/ABS/ABS Client.app/Contents/MacOS/ABS Client differ diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/PkgInfo b/Exports/Mac/ABS/ABS Client.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/Exports/Mac/ABS/ABS Client.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/Resources/AppIcon.icns b/Exports/Mac/ABS/ABS Client.app/Contents/Resources/AppIcon.icns new file mode 100644 index 0000000..3e9d861 Binary files /dev/null and b/Exports/Mac/ABS/ABS Client.app/Contents/Resources/AppIcon.icns differ diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/Resources/Assets.car b/Exports/Mac/ABS/ABS Client.app/Contents/Resources/Assets.car new file mode 100644 index 0000000..130683b Binary files /dev/null and b/Exports/Mac/ABS/ABS Client.app/Contents/Resources/Assets.car differ diff --git a/Exports/Mac/ABS/ABS Client.app/Contents/_CodeSignature/CodeResources b/Exports/Mac/ABS/ABS Client.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..b0e1f54 --- /dev/null +++ b/Exports/Mac/ABS/ABS Client.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,139 @@ + + + + + files + + Resources/AppIcon.icns + + PIslMkIS+dR91jAoGiXhG5ZQxNw= + + Resources/Assets.car + + znIIamhkfmvHB0hErsEqUixsQhI= + + + files2 + + Resources/AppIcon.icns + + hash2 + + UmgGZpb6aN5Xnz7XswrsNpMxnVcQINgSUpIhHWS1GBM= + + + Resources/Assets.car + + hash2 + + HHk6wONib9SlJckhU6DwwijL2pI9JaiC6553U0Novu8= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Exports/Mac/dmgmaker.sh b/Exports/Mac/dmgmaker.sh new file mode 100644 index 0000000..9ec663c --- /dev/null +++ b/Exports/Mac/dmgmaker.sh @@ -0,0 +1,9 @@ +create-dmg \ + --volname "ABS-Client" \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "ABS-Client.app" 150 200 \ + --app-drop-link 450 200 \ + ~/ABS-Client/Exports/Mac/ABS-Client.dmg \ + ABS Client.app +≈ diff --git a/LibraryView.swift b/LibraryView.swift deleted file mode 100644 index 4df9d87..0000000 --- a/LibraryView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import SwiftUI - -struct LibraryView: View { - @ObservedObject var authManager: AuthManager - @State private var books: [AudiobookItem] = [] - @State private var isLoading = true - @State private var errorMessage = "" - - var body: some View { - NavigationView { - Group { - if isLoading { - ProgressView("Lädt Bibliothek...") - } else if !errorMessage.isEmpty { - Text(errorMessage).foregroundColor(.red) - } else { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) { - ForEach(books) { book in - BookCard(book: book, authManager: authManager) - } - } - .padding() - } - } - } - .navigationTitle("Meine Bibliothek") - .toolbar { - Button("Logout") { - authManager.logout() - } - } - } - .onAppear { loadBooks() } - } - - func loadBooks() { - guard let serverURL = authManager.serverURL, - let token = authManager.token else { return } - - // Erst Libraries laden, dann Bücher - let url = URL(string: "\(serverURL)/api/libraries")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - URLSession.shared.dataTask(with: request) { data, _, error in - guard let data = data else { return } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let libraries = json["libraries"] as? [[String: Any]], - let firstLib = libraries.first, - let libId = firstLib["id"] as? String { - loadBooksFromLibrary(libId: libId, serverURL: serverURL, token: token) - } - }.resume() - } - - func loadBooksFromLibrary(libId: String, serverURL: String, token: String) { - let url = URL(string: "\(serverURL)/api/libraries/\(libId)/items")! - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - URLSession.shared.dataTask(with: request) { data, _, _ in - guard let data = data else { return } - if let json = try? JSON