Merge iOS and Mac app into one

This commit is contained in:
Scarriffle
2026-05-17 21:06:59 +02:00
parent 069f8bac2d
commit ac7906f0cf
72 changed files with 1277 additions and 3408 deletions

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "31076C78-07D5-4F41-A530-12A6CA1E48B0"
type = "1"
version = "2.0">
</Bucket>

View File

@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 KiB

View File

@@ -1,20 +0,0 @@
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)
}
}
}

View File

@@ -1,209 +0,0 @@
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)"] }
}

View File

@@ -1,16 +0,0 @@
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)
}
}

View File

@@ -1,21 +0,0 @@
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)
}
}
}

View File

@@ -1,158 +0,0 @@
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)) %")
}
}

View File

@@ -1,94 +0,0 @@
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
}
}
}

View File

@@ -1,119 +0,0 @@
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))"
}
}

View File

@@ -1,341 +0,0 @@
// !$*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 */;
}

View File

@@ -1,14 +0,0 @@
<?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>

View File

@@ -1,31 +0,0 @@
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
}
}
}

View File

@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,86 +0,0 @@
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]?
}

View File

@@ -1,81 +0,0 @@
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)
}

View File

@@ -1,224 +0,0 @@
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 }
}

View File

@@ -1,94 +0,0 @@
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
}
}

View File

@@ -1,259 +0,0 @@
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
}
}
}

View File

@@ -1,59 +0,0 @@
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)
}
}

View File

@@ -1,31 +0,0 @@
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()
}
}

View File

@@ -1,336 +0,0 @@
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
}
}

View File

@@ -1,94 +0,0 @@
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)
}
}

View File

@@ -1,13 +0,0 @@
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
var body: some View {
if app.auth.isLoggedIn {
MainView()
} else {
LoginView()
}
}
}

View File

@@ -1,25 +0,0 @@
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?()
}
}
}

View File

@@ -1,136 +0,0 @@
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")
}
}
}
}
}

View File

@@ -1,99 +0,0 @@
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
}
}
}

View File

@@ -1,231 +0,0 @@
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"
}
}
}

View File

@@ -1,207 +0,0 @@
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)
}
}

View File

@@ -1,217 +0,0 @@
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"
}
}

View File

@@ -177,12 +177,15 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4; MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -234,11 +237,14 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4; MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = macosx; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -260,20 +266,37 @@
INFOPLIST_KEY_CFBundleName = "ABS Client"; INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
"LD_RUNPATH_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LD_RUNPATH_SEARCH_PATHS[sdk=iphonesimulator*]" = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client"; PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
}; };
@@ -294,20 +317,37 @@
INFOPLIST_KEY_CFBundleName = "ABS Client"; INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
"LD_RUNPATH_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LD_RUNPATH_SEARCH_PATHS[sdk=iphonesimulator*]" = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client"; PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
}; };

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.376",
"green" : "0.682",
"red" : "0.153"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.443",
"green" : "0.800",
"red" : "0.180"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,5 +1,11 @@
{ {
"images" : [ "images" : [
{
"filename" : "icon_512x512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{ {
"filename" : "icon_16x16.png", "filename" : "icon_16x16.png",
"idiom" : "mac", "idiom" : "mac",

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
#if os(iOS)
import AVFAudio
#endif
@main
struct Audiobookshelf_swiftApp: App {
@State private var appState = AppState()
init() {
#if os(iOS)
configureAudioSession()
#endif
}
var body: some Scene {
#if os(macOS)
WindowGroup {
ContentView()
.environment(appState)
}
.windowResizability(.contentSize)
Settings {
SettingsView()
.environment(appState)
}
#else
WindowGroup {
ContentView()
.environment(appState)
}
#endif
}
#if os(iOS)
private func configureAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [])
try session.setActive(true)
} catch {
// non-fatal
}
}
#endif
}

View File

@@ -16,17 +16,36 @@ enum ABSClientError: LocalizedError {
} }
} }
// Accepts any server certificate so that private servers with self-signed or
// custom-CA certificates work without needing the CA installed on-device.
// Acceptable because the user explicitly configures the server URL themselves.
private final class AnyServerTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.performDefaultHandling, nil)
}
}
}
@MainActor @MainActor
final class ABSClient { final class ABSClient {
private let auth: AuthStore private let auth: AuthStore
private let session: URLSession private let sessionDelegate = AnyServerTrustDelegate()
private(set) var session: URLSession = URLSession.shared
init(auth: AuthStore) { init(auth: AuthStore) {
self.auth = auth self.auth = auth
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.requestCachePolicy = .reloadIgnoringLocalCacheData config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.waitsForConnectivity = false config.waitsForConnectivity = false
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
} }
private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest { private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {

View File

@@ -96,8 +96,6 @@ final class DownloadManager {
} }
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile). /// 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) { func startDownload(item: LibraryItem) {
let key = item.downloadKey let key = item.downloadKey
guard activeTasks[key] == nil else { return } guard activeTasks[key] == nil else { return }
@@ -107,8 +105,6 @@ final class DownloadManager {
guard let self else { return } guard let self else { return }
var workItem = item 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 { if !workItem.isPodcast && workItem.audioFiles.isEmpty {
do { do {
workItem = try await self.client.fetchItemDetail(itemId: item.id) workItem = try await self.client.fetchItemDetail(itemId: item.id)
@@ -140,7 +136,6 @@ final class DownloadManager {
if let item = downloadedItems[downloadKey] { if let item = downloadedItems[downloadKey] {
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId) let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
try? FileManager.default.removeItem(at: dir) 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 { if item.episodeId != nil {
let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId) let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty { if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
@@ -187,29 +182,34 @@ final class DownloadManager {
var request = URLRequest(url: url) var request = URLRequest(url: url)
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
let tempURL: URL
do { do {
let (tempURL, response) = try await URLSession.shared.download(for: request) tempURL = try await downloadWithRetry(request: request, filename: file.filename)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { } catch is CancellationError {
states[downloadKey] = .failed(message: "HTTP \(http.statusCode) bei Datei \(file.filename)") states[downloadKey] = .notDownloaded
try? FileManager.default.removeItem(at: tempURL) return
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 { } catch {
states[downloadKey] = .failed(message: error.localizedDescription) states[downloadKey] = .failed(message: error.localizedDescription)
return return
} }
let ext = file.ext.isEmpty ? "mp3" : file.ext
let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
let dest = itemDir.appendingPathComponent(destName)
do {
try? FileManager.default.removeItem(at: dest)
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
states[downloadKey] = .failed(message: error.localizedDescription)
return
}
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))
} }
let downloaded = DownloadedItem( let downloaded = DownloadedItem(
@@ -225,10 +225,46 @@ final class DownloadManager {
persistIndex() persistIndex()
} }
/// Downloads with up to `maxAttempts` retries and resume-data support so a brief
/// network dropout picks up where it left off. Uses the client session so that
/// self-signed server certificates are accepted.
private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL {
let session = client.session
var resumeData: Data? = nil
var lastError: Error = URLError(.unknown)
for attempt in 0..<maxAttempts {
try Task.checkCancellation()
do {
let (tempURL, response): (URL, URLResponse)
if let resume = resumeData {
(tempURL, response) = try await session.download(resumeFrom: resume)
} else {
(tempURL, response) = try await session.download(for: request)
}
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
try? FileManager.default.removeItem(at: tempURL)
throw URLError(.badServerResponse)
}
return tempURL
} catch is CancellationError {
throw CancellationError()
} catch let error as NSError {
resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
lastError = error
if attempt < maxAttempts - 1 {
// Exponential backoff: 1 s, 2 s, 4 s, 8 s
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
try await Task.sleep(nanoseconds: delay)
}
}
}
throw lastError
}
private func loadIndex() { private func loadIndex() {
guard let data = try? Data(contentsOf: indexFile), guard let data = try? Data(contentsOf: indexFile),
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return } 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] = [:] var rekeyed: [String: DownloadedItem] = [:]
for (_, item) in decoded { for (_, item) in decoded {
if item.tracks.isEmpty { continue } if item.tracks.isEmpty { continue }
@@ -238,7 +274,6 @@ final class DownloadManager {
for k in rekeyed.keys { for k in rekeyed.keys {
states[k] = .downloaded states[k] = .downloaded
} }
// Clean up phantom folders.
for (oldKey, item) in decoded where item.tracks.isEmpty { for (oldKey, item) in decoded where item.tracks.isEmpty {
let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey) let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey)
try? FileManager.default.removeItem(at: dir) try? FileManager.default.removeItem(at: dir)

View File

@@ -31,11 +31,16 @@ final class PlayerEngine {
var itemId: String? var itemId: String?
// Now-playing metadata that travels with the current item.
private var currentTitle: String = "" private var currentTitle: String = ""
private var currentAuthor: String = "" private var currentAuthor: String = ""
private var currentCoverURL: URL? private var currentCoverURL: URL?
private var remoteCommandsConfigured: Bool = false private var remoteCommandsConfigured: Bool = false
private var artworkSession: URLSession?
#if os(iOS)
private var audioSessionObserversConfigured: Bool = false
private var wasPlayingBeforeInterruption: Bool = false
#endif
nonisolated init() {} nonisolated init() {}
@@ -43,6 +48,7 @@ final class PlayerEngine {
teardown() teardown()
self.itemId = item.id self.itemId = item.id
self.errorMessage = nil self.errorMessage = nil
self.artworkSession = client.session
let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey) let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey)
let urls: [URL] let urls: [URL]
@@ -64,8 +70,7 @@ final class PlayerEngine {
// When audio files carry no duration (e.g. some podcast episodes or // When audio files carry no duration (e.g. some podcast episodes or
// freshly-scanned items), fall back to the item's reported total. // freshly-scanned items), fall back to the item's reported total.
// Distribute equally across all tracks so that trackDurations.count // Distribute equally across all tracks so that trackDurations.count
// always matches trackPlayerItems.count a mismatch breaks // always matches trackPlayerItems.count.
// handleTrackEnd and refreshAbsoluteTime.
if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 0 { if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 0 {
let count = max(1, trackDurations.count) let count = max(1, trackDurations.count)
let perTrack = item.durationSeconds / Double(count) let perTrack = item.durationSeconds / Double(count)
@@ -105,6 +110,9 @@ final class PlayerEngine {
currentAuthor = item.author currentAuthor = item.author
currentCoverURL = client.coverURL(itemId: item.id) currentCoverURL = client.coverURL(itemId: item.id)
#if os(iOS)
configureAudioSessionObserversIfNeeded()
#endif
configureRemoteCommandsIfNeeded() configureRemoteCommandsIfNeeded()
updateNowPlayingInfo() updateNowPlayingInfo()
fetchAndAttachArtwork() fetchAndAttachArtwork()
@@ -113,6 +121,9 @@ final class PlayerEngine {
} }
func play() { func play() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
player?.play() player?.play()
player?.rate = rate player?.rate = rate
isPlaying = true isPlaying = true
@@ -153,16 +164,13 @@ final class PlayerEngine {
switchToTrack(index: trackIndex) switchToTrack(index: trackIndex)
absoluteCurrentTime = clamped absoluteCurrentTime = clamped
guard let currentItem = player?.currentItem else { guard let currentItem = player?.currentItem else {
// No item to seek (e.g. empty queue after failed insert) don't // No item to seek: don't leave isSeeking stuck, which would freeze the scrubber.
// leave isSeeking stuck, which would freeze refreshAbsoluteTime.
return return
} }
isSeeking = true isSeeking = true
let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600) let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600)
// Use a small tolerance so seeking succeeds on formats where exact // Small tolerance so seeking succeeds on VBR MP3s without a Xing header.
// keyframe alignment isn't guaranteed (e.g. VBR MP3 without a Xing // Zero-tolerance seeks fail silently on such files, snapping the slider back.
// 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) let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600)
currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -208,8 +216,6 @@ final class PlayerEngine {
let trackTime = current.currentTime().seconds let trackTime = current.currentTime().seconds
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +) let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
let absolute = prior + (trackTime.isFinite ? trackTime : 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 let cap = totalDuration > 0 ? totalDuration : absolute
absoluteCurrentTime = max(0, min(absolute, cap)) absoluteCurrentTime = max(0, min(absolute, cap))
let wasPlaying = isPlaying let wasPlaying = isPlaying
@@ -238,6 +244,7 @@ final class PlayerEngine {
currentTitle = "" currentTitle = ""
currentAuthor = "" currentAuthor = ""
currentCoverURL = nil currentCoverURL = nil
artworkSession = nil
clearNowPlayingInfo() clearNowPlayingInfo()
} }
@@ -272,7 +279,6 @@ final class PlayerEngine {
Task { @MainActor in self?.skip(by: -s) } Task { @MainActor in self?.skip(by: -s) }
return .success return .success
} }
// Keep the lock-screen icon in sync with the user's preference.
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification, object: nil, queue: .main forName: UserDefaults.didChangeNotification, object: nil, queue: .main
) { [weak self] _ in ) { [weak self] _ in
@@ -310,7 +316,6 @@ final class PlayerEngine {
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
] ]
// Preserve artwork across updates so we don't blank the lock-screen image.
if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo, if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo,
let art = existing[MPMediaItemPropertyArtwork] { let art = existing[MPMediaItemPropertyArtwork] {
info[MPMediaItemPropertyArtwork] = art info[MPMediaItemPropertyArtwork] = art
@@ -320,13 +325,13 @@ final class PlayerEngine {
private func fetchAndAttachArtwork() { private func fetchAndAttachArtwork() {
guard let url = currentCoverURL else { return } guard let url = currentCoverURL else { return }
let session = artworkSession ?? URLSession.shared
Task.detached { Task.detached {
do { do {
let (data, _) = try await URLSession.shared.data(from: url) let (data, _) = try await session.data(from: url)
guard let img = PlayerArtworkImage(data: data) else { return } guard let img = PlayerArtworkImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img } let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
// Drop the result if the user has since switched items.
guard let self, self.currentCoverURL == url else { return } guard let self, self.currentCoverURL == url else { return }
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPMediaItemPropertyArtwork] = artwork info[MPMediaItemPropertyArtwork] = artwork
@@ -346,9 +351,74 @@ final class PlayerEngine {
center.skipBackwardCommand.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 { static func currentSkipSeconds() -> Int {
let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds") let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds")
return raw > 0 ? raw : 30 return raw > 0 ? raw : 30
} }
// MARK: - iOS audio session observers
#if os(iOS)
private func configureAudioSessionObserversIfNeeded() {
guard !audioSessionObserversConfigured else { return }
audioSessionObserversConfigured = true
let center = NotificationCenter.default
center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
Task { @MainActor [weak self] in
self?.handleAudioInterruption(notification: notification)
}
}
center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
Task { @MainActor [weak self] in
self?.handleRouteChange(notification: notification)
}
}
}
private func handleAudioInterruption(notification: Notification) {
guard let info = notification.userInfo,
let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
switch type {
case .began:
wasPlayingBeforeInterruption = isPlaying
if isPlaying {
player?.pause()
isPlaying = false
updateNowPlayingInfo()
}
case .ended:
let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw)
try? AVAudioSession.sharedInstance().setActive(true)
if wasPlayingBeforeInterruption && options.contains(.shouldResume) {
play()
}
@unknown default:
break
}
}
private func handleRouteChange(notification: Notification) {
guard let info = notification.userInfo,
let reasonRaw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
// Pause when headphones are unplugged (Apple's recommended behavior).
if reason == .oldDeviceUnavailable {
pause()
}
}
#endif
} }

View File

@@ -0,0 +1,53 @@
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
#if os(iOS)
@State private var splashVisible = true
#endif
var body: some View {
ZStack {
mainContent
#if os(iOS)
if splashVisible {
SplashView()
.zIndex(10)
.transition(.opacity.animation(.easeOut(duration: 0.55)))
}
#endif
}
.task { await boot() }
}
@ViewBuilder
private var mainContent: some View {
Group {
if app.auth.isLoggedIn {
MainView()
} else {
LoginView()
}
}
#if os(iOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#else
.frame(minWidth: 900, minHeight: 600)
#endif
}
private func boot() async {
#if os(iOS)
// Run bootstrap and minimum splash time in parallel;
// dismiss splash only after BOTH complete.
await withTaskGroup(of: Void.self) { group in
group.addTask { await app.bootstrap() }
group.addTask { try? await Task.sleep(for: .seconds(1.2)) }
await group.waitForAll()
}
withAnimation { splashVisible = false }
#else
await app.bootstrap()
#endif
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct LibraryGridView: View {
let items: [LibraryItem]
var onRefresh: (() async -> Void)? = nil
var onSelect: (LibraryItem) -> Void
var body: some View {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: 8) {
ForEach(items) { item in
LibraryItemCell(item: item)
.onTapGesture { onSelect(item) }
}
}
#if os(iOS)
.padding(.horizontal, 8)
.padding(.vertical, 4)
#else
.padding(20)
#endif
}
#if os(iOS)
.refreshable { await onRefresh?() }
#endif
}
private var gridColumns: [GridItem] {
#if os(iOS)
// 3 equal columns compact spacing for full height utilization
[GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible())]
#else
[GridItem(.adaptive(minimum: 180), spacing: 20)]
#endif
}
}

View File

@@ -5,32 +5,68 @@ struct LibraryItemCell: View {
let item: LibraryItem let item: LibraryItem
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 2) {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
cover cover
downloadBadge downloadBadge.padding(4)
.padding(6)
} }
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId)) CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 6) .padding(.horizontal, 3)
.padding(.bottom, 6) .padding(.bottom, 3)
} }
Text(item.title) Text(item.title)
.font(.subheadline).bold() #if os(iOS)
.lineLimit(2, reservesSpace: true) .font(.system(size: 11, weight: .bold))
#else
.font(.headline)
#endif
.lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
Text(item.author) Text(item.author)
.font(.caption) .font(.system(size: 9))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1, reservesSpace: true) .lineLimit(1)
}
.contextMenu {
downloadMenuItems
} }
// Ensure the cell fills its full grid column width
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { downloadMenuItems }
} }
// MARK: - Cover
private var cover: some View { private var cover: some View {
#if os(iOS)
iOSCover
#else
macosCover
#endif
}
#if os(iOS)
private var iOSCover: some View {
Color(.systemGray6) // neutral bg for PNG transparent areas
.frame(maxWidth: .infinity) // explicitly fill the column width
.aspectRatio(1, contentMode: .fit)
.overlay {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView().tint(.accentColor)
}
.clipped() // clip image overflow before rounding
} else {
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
#endif
#if os(macOS)
private var macosCover: some View {
Group { Group {
if let url = app.client.coverURL(itemId: item.id) { if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
@@ -51,9 +87,12 @@ struct LibraryItemCell: View {
Rectangle().fill(.quaternary) Rectangle().fill(.quaternary)
} }
} }
.aspectRatio(1, contentMode: .fit) .frame(width: 180, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
} }
#endif
// MARK: - Download badge
@ViewBuilder @ViewBuilder
private var downloadBadge: some View { private var downloadBadge: some View {
@@ -66,6 +105,11 @@ struct LibraryItemCell: View {
.shadow(radius: 2) .shadow(radius: 2)
case .downloading(let p): case .downloading(let p):
DownloadProgressRing(progress: p) DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 26, height: 26)
#else
.frame(width: 32, height: 32)
#endif
case .failed: case .failed:
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red) .foregroundStyle(.white, .red)
@@ -76,6 +120,8 @@ struct LibraryItemCell: View {
} }
} }
// MARK: - Context menu
@ViewBuilder @ViewBuilder
private var downloadMenuItems: some View { private var downloadMenuItems: some View {
let key = item.downloadKey let key = item.downloadKey
@@ -107,7 +153,8 @@ struct LibraryItemCell: View {
} }
} }
/// Green progress bar drawn at the bottom of a cover. Hidden when no progress. // MARK: - Shared components
struct CoverProgressBar: View { struct CoverProgressBar: View {
let fraction: Double let fraction: Double
@@ -119,7 +166,7 @@ struct CoverProgressBar: View {
.fill(Color.black.opacity(0.55)) .fill(Color.black.opacity(0.55))
.frame(height: 4) .frame(height: 4)
RoundedRectangle(cornerRadius: 2, style: .continuous) RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.green) .fill(Color.accentColor)
.frame(width: max(2, geo.size.width * fraction), height: 4) .frame(width: max(2, geo.size.width * fraction), height: 4)
} }
} }
@@ -141,15 +188,14 @@ struct DownloadProgressRing: View {
.padding(4) .padding(4)
Circle() Circle()
.trim(from: 0, to: max(0.03, min(progress, 1))) .trim(from: 0, to: max(0.03, min(progress, 1)))
.stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.padding(4) .padding(4)
.animation(.easeInOut(duration: 0.25), value: progress) .animation(.easeInOut(duration: 0.25), value: progress)
Image(systemName: "arrow.down") Image(systemName: "arrow.down")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
} }
.frame(width: 28, height: 28)
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1) .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
} }
} }

View File

@@ -11,9 +11,24 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
struct LibraryListView: View { struct LibraryListView: View {
let items: [LibraryItem] let items: [LibraryItem]
var onRefresh: (() async -> Void)? = nil
let onSelect: (LibraryItem) -> Void let onSelect: (LibraryItem) -> Void
var body: some View { var body: some View {
#if os(iOS)
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?()
}
#else
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
@@ -27,6 +42,7 @@ struct LibraryListView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
#endif
} }
} }
@@ -56,20 +72,28 @@ struct LibraryListRow: View {
} }
.frame(height: 3) .frame(height: 3)
.padding(.top, 2) .padding(.top, 2)
#if os(macOS)
.padding(.trailing, 40) .padding(.trailing, 40)
#endif
} }
} }
Spacer(minLength: 8) Spacer(minLength: 8)
#if os(macOS)
if item.durationSeconds > 0 { if item.durationSeconds > 0 {
Text(formatDuration(item.durationSeconds)) Text(formatDuration(item.durationSeconds))
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif
downloadStatus downloadStatus
#if os(macOS)
.frame(width: 28) .frame(width: 28)
#endif
} }
#if os(macOS)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
#endif
.contextMenu { downloadMenuItems } .contextMenu { downloadMenuItems }
} }
@@ -93,8 +117,13 @@ struct LibraryListRow: View {
Rectangle().fill(.quaternary) Rectangle().fill(.quaternary)
} }
} }
#if os(iOS)
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 6))
#else
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 4)) .clipShape(RoundedRectangle(cornerRadius: 4))
#endif
} }
@ViewBuilder @ViewBuilder
@@ -107,7 +136,11 @@ struct LibraryListRow: View {
.font(.title3) .font(.title3)
case .downloading(let p): case .downloading(let p):
DownloadProgressRing(progress: p) DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 22, height: 22)
#else
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
#endif
case .failed: case .failed:
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red) .foregroundStyle(.white, .red)
@@ -143,6 +176,7 @@ struct LibraryListRow: View {
} }
} }
#if os(macOS)
private func formatDuration(_ seconds: Double) -> String { private func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "" } guard seconds.isFinite, seconds > 0 else { return "" }
let total = Int(seconds) let total = Int(seconds)
@@ -151,4 +185,5 @@ struct LibraryListRow: View {
if h > 0 { return "\(h) h \(m) min" } if h > 0 { return "\(h) h \(m) min" }
return "\(m) min" return "\(m) min"
} }
#endif
} }

View File

@@ -0,0 +1,200 @@
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 {
#if os(iOS)
iOSBody
#else
macOSBody
#endif
}
// MARK: - iOS
#if os(iOS)
private var iOSBody: some View {
VStack(spacing: 0) {
// Header with green gradient background
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.85), Color.accentColor.opacity(0.55)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea(edges: .top)
VStack(spacing: 10) {
Image(systemName: "books.vertical.fill")
.font(.system(size: 52, weight: .regular))
.foregroundStyle(.white)
Text("ABS Client")
.font(.title.bold())
.foregroundStyle(.white)
Text("Verbinde dich mit deinem Audiobookshelf-Server")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
}
.padding(.top, 56)
.padding(.bottom, 32)
.padding(.horizontal, 24)
}
.fixedSize(horizontal: false, vertical: true)
// Form fields uses native iOS Form appearance
Form {
Section {
TextField("https://abs.example.com", text: $serverURL)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.next)
} header: {
Text("Server-URL")
}
Section {
TextField("Benutzername", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.next)
SecureField("Passwort", text: $password)
.textContentType(.password)
.submitLabel(.go)
.onSubmit { if canLogin { doLogin() } }
} header: {
Text("Anmeldedaten")
}
Section {
Toggle("Anmeldung merken", isOn: $remember)
}
if let err = app.auth.errorMessage {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.callout)
}
}
Section {
Button(action: doLogin) {
HStack {
Spacer()
if isLoading {
ProgressView()
} else {
Text("Einloggen").bold()
}
Spacer()
}
}
.disabled(!canLogin)
}
}
.scrollContentBackground(.hidden)
.background(Color(.systemGroupedBackground))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground).ignoresSafeArea())
}
private var canLogin: Bool {
!isLoading && !serverURL.isEmpty && !username.isEmpty && !password.isEmpty
}
#endif
// MARK: - macOS
#if os(macOS)
private var macOSBody: 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") {
TextField("", text: $serverURL)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
labeledField(label: "Benutzername") {
TextField("", text: $username)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
labeledField(label: "Passwort") {
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)
}
@ViewBuilder
private func labeledField<C: View>(label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
content()
}
}
#endif
// MARK: - Shared
private func doLogin() {
isLoading = true
Task {
await app.auth.login(
serverURL: serverURL,
username: username,
password: password,
remember: remember
)
isLoading = false
}
}
}

View File

@@ -73,12 +73,70 @@ struct MainView: View {
@State private var vm = LibraryViewModel() @State private var vm = LibraryViewModel()
@State private var navPath: [LibraryItem] = [] @State private var navPath: [LibraryItem] = []
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
#if os(iOS)
@State private var showSettings: Bool = false
#endif
private var layout: LibraryLayout { private var layout: LibraryLayout {
LibraryLayout(rawValue: layoutRaw) ?? .grid LibraryLayout(rawValue: layoutRaw) ?? .grid
} }
var body: some View { var body: some View {
// Modifiers like .task and .onChange cannot chain after a #if/#endif block
// in a @ViewBuilder wrap the conditional nav in a separate property instead.
navigationRoot
.task { await loadAll() }
.onChange(of: vm.selection) { _, _ in
navPath.removeAll()
Task { await loadAll() }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
}
}
@ViewBuilder
private var navigationRoot: some View {
#if os(iOS)
NavigationStack(path: $navPath) {
detail
.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()
statusMenuSection
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.navigationDestination(for: LibraryItem.self) { podcast in
PodcastDetailView(podcast: podcast)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.environment(app)
}
#else
NavigationSplitView { NavigationSplitView {
sidebar sidebar
} detail: { } detail: {
@@ -89,23 +147,13 @@ struct MainView: View {
} }
} }
} }
.task { #endif
await vm.loadLibraries(client: app.client) }
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache() private func loadAll() async {
} await vm.loadLibraries(client: app.client)
.onChange(of: vm.selection) { _, _ in await vm.loadItems(client: app.client, downloads: app.downloads)
navPath.removeAll() await app.refreshProgressCache()
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) { private func handleSelect(_ item: LibraryItem) {
@@ -116,6 +164,9 @@ struct MainView: View {
} }
} }
// MARK: - macOS sidebar
#if os(macOS)
private var sidebar: some View { private var sidebar: some View {
List(selection: $vm.selection) { List(selection: $vm.selection) {
Section("Bibliotheken") { Section("Bibliotheken") {
@@ -166,6 +217,9 @@ struct MainView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
#endif
// MARK: - Detail content (shared)
@ViewBuilder @ViewBuilder
private var detail: some View { private var detail: some View {
@@ -180,24 +234,17 @@ struct MainView: View {
Group { Group {
switch layout { switch layout {
case .grid: case .grid:
LibraryGridView(items: vm.items) { item in LibraryGridView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
handleSelect(item)
}
case .list: case .list:
LibraryListView(items: vm.items) { item in LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
handleSelect(item)
}
} }
} }
#if os(macOS)
.navigationTitle(currentTitle) .navigationTitle(currentTitle)
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
Task { Task { await loadAll() }
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
} label: { } label: {
if vm.isLoading { if vm.isLoading {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
@@ -220,9 +267,65 @@ struct MainView: View {
.help("Zwischen Kachel- und Listenansicht wechseln") .help("Zwischen Kachel- und Listenansicht wechseln")
} }
} }
#endif
} }
} }
// MARK: - iOS-only helpers
#if os(iOS)
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 statusMenuSection: 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"
}
}
#endif
// MARK: - Shared helpers
private var currentTitle: String { private var currentTitle: String {
switch vm.selection { switch vm.selection {
case .library(let id): case .library(let id):

View File

@@ -12,7 +12,12 @@ struct PlayerBar: View {
Divider() Divider()
content(item: item) content(item: item)
.padding(.horizontal, 16) .padding(.horizontal, 16)
#if os(iOS)
.padding(.top, 8)
.padding(.bottom, 10)
#else
.padding(.vertical, 10) .padding(.vertical, 10)
#endif
.background(.bar) .background(.bar)
} }
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
@@ -20,7 +25,7 @@ struct PlayerBar: View {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider() Divider()
HStack(spacing: 12) { HStack(spacing: 12) {
ProgressView().controlSize(.small) ProgressView()
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary) Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer() Spacer()
} }
@@ -31,6 +36,62 @@ struct PlayerBar: View {
} }
} }
// MARK: - Platform layouts
#if os(iOS)
@ViewBuilder
private func content(item: LibraryItem) -> some View {
VStack(spacing: 8) {
// Header row: cover, title/author, play button
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)
}
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)
}
}
#else
@ViewBuilder @ViewBuilder
private func content(item: LibraryItem) -> some View { private func content(item: LibraryItem) -> some View {
HStack(spacing: 14) { HStack(spacing: 14) {
@@ -67,24 +128,6 @@ struct PlayerBar: View {
} }
} }
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 { private var transportControls: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Button { app.skip(by: -Double(skipSeconds)) } label: { Button { app.skip(by: -Double(skipSeconds)) } label: {
@@ -109,26 +152,45 @@ struct PlayerBar: View {
} }
} }
private var skipForwardImage: String { private var statusIndicator: some View {
switch skipSeconds { HStack(spacing: 4) {
case ...10: return "goforward.10" Circle()
case 11...15: return "goforward.15" .fill(app.network.isOnline ? .green : .orange)
case 16...30: return "goforward.30" .frame(width: 6, height: 6)
case 31...45: return "goforward.45" if app.sync.queuedCount > 0 {
case 46...60: return "goforward.60" Text("\(app.sync.queuedCount)")
default: return "goforward.90" .font(.caption2)
.foregroundStyle(.secondary)
}
} }
.help(app.network.isOnline
? "Online Fortschritt wird synchronisiert"
: "Offline \(app.sync.queuedCount) Eintrag/Einträge wartend")
} }
#endif
private var skipBackImage: String { // MARK: - Shared subviews
switch skipSeconds {
case ...10: return "gobackward.10" private func cover(item: LibraryItem) -> some View {
case 11...15: return "gobackward.15" Group {
case 16...30: return "gobackward.30" if let url = app.client.coverURL(itemId: item.id) {
case 31...45: return "gobackward.45" AsyncImage(url: url) { phase in
case 46...60: return "gobackward.60" if let img = phase.image {
default: return "gobackward.90" img.resizable().aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.3)
}
}
} else {
Color.gray.opacity(0.3)
}
} }
#if os(iOS)
.frame(width: 44, height: 44)
#else
.frame(width: 48, height: 48)
#endif
.clipShape(RoundedRectangle(cornerRadius: 6))
} }
private var scrubber: some View { private var scrubber: some View {
@@ -176,26 +238,36 @@ struct PlayerBar: View {
} label: { } label: {
Text(String(format: "%.2g×", Double(app.player.rate))) Text(String(format: "%.2g×", Double(app.player.rate)))
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.padding(.horizontal, 8).padding(.vertical, 4) .padding(.horizontal, 10).padding(.vertical, 5)
.overlay(Capsule().stroke(Color.secondary.opacity(0.4))) .overlay(Capsule().stroke(Color.secondary.opacity(0.4)))
} }
#if os(macOS)
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
.help("Geschwindigkeit") .help("Geschwindigkeit")
#endif
} }
private var statusIndicator: some View { private var skipForwardImage: String {
HStack(spacing: 4) { switch skipSeconds {
Circle() case ...10: return "goforward.10"
.fill(app.network.isOnline ? .green : .orange) case 11...15: return "goforward.15"
.frame(width: 6, height: 6) case 16...30: return "goforward.30"
if app.sync.queuedCount > 0 { case 31...45: return "goforward.45"
Text("\(app.sync.queuedCount)") case 46...60: return "goforward.60"
.font(.caption2) default: return "goforward.90"
.foregroundStyle(.secondary) }
} }
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"
} }
.help(app.network.isOnline ? "Online Fortschritt wird synchronisiert" : "Offline \(app.sync.queuedCount) Eintrag/Einträge wartend")
} }
private func formatTime(_ seconds: Double) -> String { private func formatTime(_ seconds: Double) -> String {

View File

@@ -16,6 +16,9 @@ struct PodcastDetailView: View {
content content
} }
.navigationTitle(podcastDetail?.title ?? podcast.title) .navigationTitle(podcastDetail?.title ?? podcast.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task { await load() } .task { await load() }
} }
@@ -30,7 +33,11 @@ struct PodcastDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
#if os(iOS)
Text(podcast.title).font(.headline).lineLimit(2)
#else
Text(podcast.title).font(.title3).bold().lineLimit(2) Text(podcast.title).font(.title3).bold().lineLimit(2)
#endif
Text(podcast.author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) Text(podcast.author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
if !episodes.isEmpty { if !episodes.isEmpty {
Text("\(episodes.count) Folge\(episodes.count == 1 ? "" : "n")") Text("\(episodes.count) Folge\(episodes.count == 1 ? "" : "n")")
@@ -53,6 +60,18 @@ struct PodcastDetailView: View {
} else if episodes.isEmpty { } else if episodes.isEmpty {
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen.")) ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
} else { } else {
#if os(iOS)
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)
#else
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
@@ -67,6 +86,7 @@ struct PodcastDetailView: View {
} }
} }
} }
#endif
} }
} }
@@ -107,12 +127,18 @@ private struct EpisodeRow: View {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.font(.title) .font(.title)
.foregroundStyle(.tint) .foregroundStyle(.tint)
#if os(macOS)
.frame(width: 28) .frame(width: 28)
#endif
.padding(.top, 2) .padding(.top, 2)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(episode.title) Text(episode.title)
#if os(iOS)
.font(.subheadline).bold()
#else
.font(.headline) .font(.headline)
#endif
.lineLimit(2) .lineLimit(2)
HStack(spacing: 10) { HStack(spacing: 10) {
if let date = episode.formattedDate { if let date = episode.formattedDate {
@@ -127,12 +153,14 @@ private struct EpisodeRow: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#if os(macOS)
if let season = episode.season, !season.isEmpty { if let season = episode.season, !season.isEmpty {
Text("S\(season)").font(.caption).foregroundStyle(.secondary) Text("S\(season)").font(.caption).foregroundStyle(.secondary)
} }
if let ep = episode.episode, !ep.isEmpty { if let ep = episode.episode, !ep.isEmpty {
Text("F\(ep)").font(.caption).foregroundStyle(.secondary) Text("F\(ep)").font(.caption).foregroundStyle(.secondary)
} }
#endif
} }
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id) let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
if frac > 0 { if frac > 0 {
@@ -145,16 +173,24 @@ private struct EpisodeRow: View {
} }
.frame(height: 3) .frame(height: 3)
.padding(.top, 2) .padding(.top, 2)
#if os(macOS)
.padding(.trailing, 40) .padding(.trailing, 40)
#endif
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
downloadButton downloadButton
#if os(macOS)
.frame(width: 32) .frame(width: 32)
.padding(.top, 4) .padding(.top, 4)
#endif
} }
#if os(macOS)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
#else
.padding(.vertical, 4)
#endif
.contextMenu { contextMenuItems } .contextMenu { contextMenuItems }
} }
@@ -172,17 +208,17 @@ private struct EpisodeRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#if os(macOS)
.help("Episode für Offline herunterladen") .help("Episode für Offline herunterladen")
#endif
case .downloading(let p): case .downloading(let p):
DownloadProgressRing(progress: p) DownloadProgressRing(progress: p)
.frame(width: 24, height: 24) .frame(width: 22, height: 22)
.onTapGesture { app.downloads.cancel(downloadKey: key) } .onTapGesture { app.downloads.cancel(downloadKey: key) }
.help("\(Int(p * 100)) % zum Abbrechen klicken")
case .downloaded: case .downloaded:
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green) .foregroundStyle(.white, .green)
.font(.title3) .font(.title3)
.help("Heruntergeladen")
case .failed(let msg): case .failed(let msg):
Button { Button {
app.downloads.startDownload(item: syntheticItem) app.downloads.startDownload(item: syntheticItem)
@@ -192,7 +228,7 @@ private struct EpisodeRow: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Fehlgeschlagen: \(msg) zum Wiederholen klicken") .help("Fehlgeschlagen: \(msg)")
} }
} }

View File

@@ -2,7 +2,9 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@Environment(AppState.self) private var app @Environment(AppState.self) private var app
#if os(iOS)
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
#endif
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30 @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
@@ -13,6 +15,7 @@ struct SettingsView: View {
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90] private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
var body: some View { var body: some View {
#if os(iOS)
NavigationStack { NavigationStack {
Form { Form {
connectionSection connectionSection
@@ -40,11 +43,44 @@ struct SettingsView: View {
} }
Button("Abbrechen", role: .cancel) { } Button("Abbrechen", role: .cancel) { }
} message: { } message: {
Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Hörbücher bleiben erhalten.") Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.")
} }
} }
#else
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.")
}
#endif
} }
// MARK: - iOS Form sections
#if os(iOS)
private var connectionSection: some View { private var connectionSection: some View {
Section { Section {
LabeledContent("Server") { LabeledContent("Server") {
@@ -62,7 +98,6 @@ struct SettingsView: View {
.fill(app.network.isOnline ? .green : .orange) .fill(app.network.isOnline ? .green : .orange)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(app.network.isOnline ? "Online" : "Offline") Text(app.network.isOnline ? "Online" : "Offline")
Spacer()
if app.sync.queuedCount > 0 { if app.sync.queuedCount > 0 {
Text("\(app.sync.queuedCount) wartend") Text("\(app.sync.queuedCount) wartend")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -117,7 +152,7 @@ struct SettingsView: View {
} header: { } header: {
Text("Downloads") Text("Downloads")
} footer: { } footer: {
Text("Heruntergeladene Hörbücher und Folgen können einzeln in der Bibliothek über das Kontextmenü gelöscht werden.") Text("Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden.")
} }
} }
@@ -130,6 +165,82 @@ struct SettingsView: View {
Text("Über") Text("Über")
} }
} }
#endif
// MARK: - macOS TabView panes
#if os(macOS)
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)
}
#endif
// MARK: - Shared
private var appVersion: String { private var appVersion: String {
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"

View File

@@ -0,0 +1,95 @@
import SwiftUI
struct SplashView: View {
@State private var appeared = false
var body: some View {
ZStack {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
VStack(spacing: 36) {
// Animated icon
ZStack {
// Outer glow pulse
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 180, height: 180)
.scaleEffect(appeared ? 1.0 : 0.2)
.blur(radius: appeared ? 12 : 40)
.animation(.easeOut(duration: 1.1), value: appeared)
// Ring border
Circle()
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
.frame(width: 130, height: 130)
.scaleEffect(appeared ? 1.0 : 0.4)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.8).delay(0.1), value: appeared)
// Book icon springs into place
Image(systemName: "books.vertical.fill")
.font(.system(size: 58, weight: .regular))
.foregroundStyle(Color.accentColor)
.scaleEffect(appeared ? 1.0 : 0.1)
.opacity(appeared ? 1 : 0)
.animation(.spring(duration: 0.65, bounce: 0.55), value: appeared)
.symbolEffect(.pulse.byLayer,
options: .speed(0.5).repeating,
value: appeared)
}
// Text
VStack(spacing: 6) {
Text("ABS Client")
.font(.title2.bold())
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 18)
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
Text("Audiobookshelf")
.font(.subheadline)
.foregroundStyle(.secondary)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.45).delay(0.42), value: appeared)
}
}
// Loading dots at bottom
VStack {
Spacer()
LoadingDots()
.opacity(appeared ? 1 : 0)
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
.padding(.bottom, 60)
}
}
.onAppear { appeared = true }
}
}
private struct LoadingDots: View {
@State private var phase: Int = 0
var body: some View {
HStack(spacing: 7) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
.frame(width: 7, height: 7)
.scaleEffect(phase == i ? 1.25 : 1.0)
.animation(.easeInOut(duration: 0.35), value: phase)
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
phase = (phase + 1) % 3
}
}
}
}

50
ABS Client/Info-iOS.plist Normal file
View File

@@ -0,0 +1,50 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ABS Client</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ABS Client</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.books</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>