Merge iOS and Mac app into one
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Bucket
|
|
||||||
uuid = "31076C78-07D5-4F41-A530-12A6CA1E48B0"
|
|
||||||
type = "1"
|
|
||||||
version = "2.0">
|
|
||||||
</Bucket>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 895 KiB |
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)"] }
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)) %")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 */;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "icon_1024.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 632 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]?
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 895 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
53
ABS Client/Audiobookshelf swift/Views/ContentView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
200
ABS Client/Audiobookshelf swift/Views/LoginView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
@@ -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 {
|
||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 ?? "?"
|
||||||
95
ABS Client/Audiobookshelf swift/Views/SplashView.swift
Normal 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
@@ -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>
|
||||||