Restructure project folders
@@ -7,7 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* 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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
39614D0C2FB4D44500DBEF5E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39614D0B2FB4D44500DBEF5E /* Audiobookshelf swift.app */,
|
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "Audiobookshelf swift";
|
productName = "Audiobookshelf swift";
|
||||||
productReference = 39614D0B2FB4D44500DBEF5E /* Audiobookshelf swift.app */;
|
productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@@ -248,21 +248,25 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.Audiobookshelf-swift";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -278,21 +282,25 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.Audiobookshelf-swift";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = 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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16x16.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16x16@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32x32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32x32@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128x128.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128x128@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256x256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256x256@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512x512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512x512@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"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
|
|
||||||