Some checks failed
CI / UI / Build (pull_request) Failing after 5s
CI / Scraper / Test (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Failing after 15s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- AudioPlayerService: replace loading/generating states with a single
.generating state; presign fast path before triggering TTS; fix URL
resolution for relative paths; cache cover artwork to avoid re-downloads;
fix duration KVO race (durationObserver); use toleranceBefore/After:zero
for accurate seeking; prefetch next chapter unconditionally (not just when
autoNext is on); handle auto-next internally on playback finish
- APIClient: fix URL construction (appendingPathComponent encodes slashes);
add verbose debug logging for all requests/responses/decoding errors;
fix sessions() to unwrap {sessions:[]} envelope; fix BrowseResponse
CodingKey hasNext → camelCase
- AudioGenerateResponse: update to synchronous {url, filename} shape
- Models: remove redundant AudioStatus enum; remove CodingKeys from
UserSettings (server now sends camelCase)
- Views: fix alert bindings (.constant → proper two-way Binding); add
error+retry UI to BrowseView; add pull-to-refresh to browse list;
fix ChapterReaderView to show error state and use dynamic WKWebView
height; fix HomeView HStack alignment; only treat audio as current
if the player isActive; suppress CancellationError from error UI
87 lines
3.2 KiB
Swift
87 lines
3.2 KiB
Swift
import SwiftUI
|
|
import Kingfisher
|
|
|
|
struct LibraryView: View {
|
|
@StateObject private var vm = LibraryViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if vm.isLoading && vm.items.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if vm.items.isEmpty {
|
|
EmptyStateView(
|
|
icon: "bookmark",
|
|
title: "No saved books",
|
|
message: "Books you save or start reading will appear here."
|
|
)
|
|
} else {
|
|
ScrollView {
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
|
|
spacing: 16
|
|
) {
|
|
ForEach(vm.items) { item in
|
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
|
LibraryCard(item: item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Library")
|
|
.navigationDestination(for: NavDestination.self) { dest in
|
|
switch dest {
|
|
case .book(let slug): BookDetailView(slug: slug)
|
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
}
|
|
}
|
|
.refreshable { await vm.load() }
|
|
.task { await vm.load() }
|
|
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
|
Button("OK") { vm.error = nil }
|
|
} message: { Text(vm.error ?? "") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LibraryCard: View {
|
|
let item: LibraryItem
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
KFImage(URL(string: item.book.cover))
|
|
.resizable()
|
|
.placeholder {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color(.systemGray5))
|
|
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
|
}
|
|
.scaledToFill()
|
|
.frame(height: 200)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
|
if let ch = item.lastChapter {
|
|
Text("Ch.\(ch)")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
.padding(6)
|
|
}
|
|
}
|
|
Text(item.book.title)
|
|
.font(.caption.bold())
|
|
.lineLimit(2)
|
|
Text(item.book.author)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|