Restructure project folders
@@ -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 = "<group>";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "31076C78-07D5-4F41-A530-12A6CA1E48B0"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
|
After Width: | Height: | Size: 895 KiB |
@@ -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"
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 895 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]?
|
||||
}
|
||||
81
ABS Client Mac/Audiobookshelf swift/Models/Models.swift
Normal file
@@ -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)
|
||||
}
|
||||
209
ABS Client Mac/Audiobookshelf swift/Services/ABSClient.swift
Normal file
@@ -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<T: Decodable>(_ 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)"] }
|
||||
}
|
||||
224
ABS Client Mac/Audiobookshelf swift/Services/AppState.swift
Normal file
@@ -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 }
|
||||
}
|
||||
94
ABS Client Mac/Audiobookshelf swift/Services/AuthStore.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>] = [:]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
354
ABS Client Mac/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
@@ -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..<localURLs.count).map { idx in
|
||||
idx < item.audioFiles.count ? item.audioFiles[idx].durationSeconds : 0
|
||||
}
|
||||
} else {
|
||||
guard !item.audioFiles.isEmpty else {
|
||||
errorMessage = "Dieses Hörbuch enthält keine abspielbaren Audiodateien."
|
||||
return
|
||||
}
|
||||
urls = item.audioFiles.compactMap { client.audioFileURL(itemId: item.id, ino: $0.ino) }
|
||||
trackDurations = item.audioFiles.map { $0.durationSeconds }
|
||||
}
|
||||
|
||||
// When audio files carry no duration (e.g. some podcast episodes or
|
||||
// freshly-scanned items), fall back to the item's reported total.
|
||||
// Distribute equally across all tracks so that trackDurations.count
|
||||
// always matches trackPlayerItems.count — a mismatch breaks
|
||||
// handleTrackEnd and refreshAbsoluteTime.
|
||||
if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 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..<trackPlayerItems.count {
|
||||
let it = trackPlayerItems[i]
|
||||
it.seek(to: .zero, completionHandler: nil)
|
||||
if player.canInsert(it, after: nil) {
|
||||
player.insert(it, after: nil)
|
||||
}
|
||||
}
|
||||
currentTrackIndex = index
|
||||
if wasPlaying { player.play(); player.rate = rate }
|
||||
}
|
||||
|
||||
private func handleTrackEnd(finishedIndex: Int) {
|
||||
if finishedIndex < trackDurations.count - 1 {
|
||||
currentTrackIndex = finishedIndex + 1
|
||||
} else {
|
||||
isPlaying = false
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAbsoluteTime() {
|
||||
guard let player, let current = player.currentItem else { return }
|
||||
if isSeeking { return }
|
||||
if let idx = trackPlayerItems.firstIndex(where: { $0 === current }) {
|
||||
currentTrackIndex = idx
|
||||
}
|
||||
let trackTime = current.currentTime().seconds
|
||||
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
|
||||
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
|
||||
// AVPlayer can report an item duration slightly longer than the metadata we have.
|
||||
// Clamp the visible time so the scrubber/labels never exceed totalDuration.
|
||||
let cap = totalDuration > 0 ? totalDuration : absolute
|
||||
absoluteCurrentTime = max(0, min(absolute, cap))
|
||||
let wasPlaying = isPlaying
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
16
ABS Client Mac/Audiobookshelf swift/Views/ContentView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ABS Client Mac/Audiobookshelf swift/Views/LibraryItemCell.swift
Normal file
@@ -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)) %")
|
||||
}
|
||||
}
|
||||
154
ABS Client Mac/Audiobookshelf swift/Views/LibraryListView.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
94
ABS Client Mac/Audiobookshelf swift/Views/LoginView.swift
Normal file
@@ -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<Content: View>: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
236
ABS Client Mac/Audiobookshelf swift/Views/MainView.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
212
ABS Client Mac/Audiobookshelf swift/Views/PlayerBar.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
119
ABS Client Mac/Audiobookshelf swift/Views/SettingsView.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
341
ABS Client iOS/ABS Client iOS.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
A0000C012FB4E10100AB5001 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A0000B012FB4E10100AB5001 /* ABS Client iOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>ABS Client iOS.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
31
ABS Client iOS/ABS Client iOS/ABS_Client_iOSApp.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 632 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
86
ABS Client iOS/ABS Client iOS/Models/APIResponses.swift
Normal file
@@ -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]?
|
||||
}
|
||||
81
ABS Client iOS/ABS Client iOS/Models/Models.swift
Normal file
@@ -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)
|
||||
}
|
||||
209
ABS Client iOS/ABS Client iOS/Services/ABSClient.swift
Normal file
@@ -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<T: Decodable>(_ 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)"] }
|
||||
}
|
||||
224
ABS Client iOS/ABS Client iOS/Services/AppState.swift
Normal file
@@ -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 }
|
||||
}
|
||||
94
ABS Client iOS/ABS Client iOS/Services/AuthStore.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
259
ABS Client iOS/ABS Client iOS/Services/DownloadManager.swift
Normal file
@@ -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<Void, Never>] = [:]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ABS Client iOS/ABS Client iOS/Services/KeychainStore.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
31
ABS Client iOS/ABS Client iOS/Services/NetworkMonitor.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
336
ABS Client iOS/ABS Client iOS/Services/PlayerEngine.swift
Normal file
@@ -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..<localURLs.count).map { idx in
|
||||
idx < item.audioFiles.count ? item.audioFiles[idx].durationSeconds : 0
|
||||
}
|
||||
if trackDurations.allSatisfy({ $0 == 0 }) {
|
||||
trackDurations = [item.durationSeconds]
|
||||
}
|
||||
} else {
|
||||
guard !item.audioFiles.isEmpty else {
|
||||
errorMessage = "Dieses Hörbuch enthält keine abspielbaren Audiodateien."
|
||||
return
|
||||
}
|
||||
urls = item.audioFiles.compactMap { client.audioFileURL(itemId: item.id, ino: $0.ino) }
|
||||
trackDurations = item.audioFiles.map { $0.durationSeconds }
|
||||
}
|
||||
|
||||
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
|
||||
isSeeking = true
|
||||
let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600)
|
||||
player?.currentItem?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero) { [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..<trackPlayerItems.count {
|
||||
let it = trackPlayerItems[i]
|
||||
it.seek(to: .zero, completionHandler: nil)
|
||||
if player.canInsert(it, after: nil) {
|
||||
player.insert(it, after: nil)
|
||||
}
|
||||
}
|
||||
currentTrackIndex = index
|
||||
if wasPlaying { player.play(); player.rate = rate }
|
||||
}
|
||||
|
||||
private func handleTrackEnd(finishedIndex: Int) {
|
||||
if finishedIndex < trackDurations.count - 1 {
|
||||
currentTrackIndex = finishedIndex + 1
|
||||
} else {
|
||||
isPlaying = false
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAbsoluteTime() {
|
||||
guard let player, let current = player.currentItem else { return }
|
||||
if isSeeking { return }
|
||||
if let idx = trackPlayerItems.firstIndex(where: { $0 === current }) {
|
||||
currentTrackIndex = idx
|
||||
}
|
||||
let trackTime = current.currentTime().seconds
|
||||
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
|
||||
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
|
||||
// AVPlayer can report an item duration slightly longer than the metadata we have.
|
||||
// Clamp the visible time so the scrubber/labels never exceed totalDuration.
|
||||
let cap = totalDuration > 0 ? totalDuration : absolute
|
||||
absoluteCurrentTime = max(0, min(absolute, cap))
|
||||
let wasPlaying = isPlaying
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
13
ABS Client iOS/ABS Client iOS/Views/ContentView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
25
ABS Client iOS/ABS Client iOS/Views/LibraryGridView.swift
Normal file
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
155
ABS Client iOS/ABS Client iOS/Views/LibraryItemCell.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
136
ABS Client iOS/ABS Client iOS/Views/LibraryListView.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
ABS Client iOS/ABS Client iOS/Views/LoginView.swift
Normal file
@@ -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<C: View>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
231
ABS Client iOS/ABS Client iOS/Views/MainView.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
207
ABS Client iOS/ABS Client iOS/Views/PlayerBar.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
217
ABS Client iOS/ABS Client iOS/Views/PodcastDetailView.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
139
ABS Client iOS/ABS Client iOS/Views/SettingsView.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
BIN
Exports/Mac/ABS-Client.dmg
Normal file
52
Exports/Mac/ABS/ABS Client.app/Contents/Info.plist
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>25F71</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ABS Client</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ABS Client</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.local.Audiobookshelf-swift</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>ABS Client</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>25F70</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>26.5</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>25F70</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.5</string>
|
||||
<key>DTXcode</key>
|
||||
<string>2650</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>17F42</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.4</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Exports/Mac/ABS/ABS Client.app/Contents/MacOS/ABS Client
Executable file
1
Exports/Mac/ABS/ABS Client.app/Contents/PkgInfo
Normal file
@@ -0,0 +1 @@
|
||||
APPL????
|
||||
BIN
Exports/Mac/ABS/ABS Client.app/Contents/Resources/AppIcon.icns
Normal file
BIN
Exports/Mac/ABS/ABS Client.app/Contents/Resources/Assets.car
Normal file
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<data>
|
||||
PIslMkIS+dR91jAoGiXhG5ZQxNw=
|
||||
</data>
|
||||
<key>Resources/Assets.car</key>
|
||||
<data>
|
||||
znIIamhkfmvHB0hErsEqUixsQhI=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/AppIcon.icns</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
UmgGZpb6aN5Xnz7XswrsNpMxnVcQINgSUpIhHWS1GBM=
|
||||
</data>
|
||||
</dict>
|
||||
<key>Resources/Assets.car</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
HHk6wONib9SlJckhU6DwwijL2pI9JaiC6553U0Novu8=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
9
Exports/Mac/dmgmaker.sh
Normal file
@@ -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
|
||||
≈
|
||||
@@ -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
|
||||