Grundcode AudiobookshelfClient
This commit is contained in:
98
Audiobookshelf swift/ABSService.swift
Normal file
98
Audiobookshelf swift/ABSService.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
0
Audiobookshelf swift/AuthManager.swift
Normal file
0
Audiobookshelf swift/AuthManager.swift
Normal file
72
Audiobookshelf swift/LibraryView.swift
Normal file
72
Audiobookshelf swift/LibraryView.swift
Normal file
@@ -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
|
||||||
46
Audiobookshelf swift/LoginResponse.swift
Normal file
46
Audiobookshelf swift/LoginResponse.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
215
Audiobookshelf swift/PlayerView.swift
Normal file
215
Audiobookshelf swift/PlayerView.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
LibraryView.swift
Normal file
64
LibraryView.swift
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user