diff --git a/Audiobookshelf swift/ABSService.swift b/Audiobookshelf swift/ABSService.swift new file mode 100644 index 0000000..99b0cc5 --- /dev/null +++ b/Audiobookshelf swift/ABSService.swift @@ -0,0 +1,98 @@ +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/AuthManager.swift b/Audiobookshelf swift/AuthManager.swift new file mode 100644 index 0000000..e69de29 diff --git a/Audiobookshelf swift/LibraryView.swift b/Audiobookshelf swift/LibraryView.swift new file mode 100644 index 0000000..0b6b9be --- /dev/null +++ b/Audiobookshelf swift/LibraryView.swift @@ -0,0 +1,72 @@ +// +// 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 new file mode 100644 index 0000000..fa37989 --- /dev/null +++ b/Audiobookshelf swift/LoginResponse.swift @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..2ce3b49 --- /dev/null +++ b/Audiobookshelf swift/PlayerView.swift @@ -0,0 +1,215 @@ +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/LibraryView.swift b/LibraryView.swift new file mode 100644 index 0000000..4df9d87 --- /dev/null +++ b/LibraryView.swift @@ -0,0 +1,64 @@ +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