iOS: fix audio polling, AVPlayer stability, and misc UX issues
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
This commit is contained in:
Admin
2026-03-07 20:13:40 +05:00
parent 89f0dfb113
commit 460e7553bf
15 changed files with 354 additions and 142 deletions

View File

@@ -101,11 +101,8 @@ struct UserSettings: Codable {
var voice: String
var speed: Double
enum CodingKeys: String, CodingKey {
case id
case autoNext = "auto_next"
case voice, speed
}
// Server sends/expects camelCase: { autoNext, voice, speed }
// (No CodingKeys needed Swift synthesises the same names by default)
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
}
@@ -243,10 +240,6 @@ struct BookBrief: Codable {
// MARK: - Audio
enum AudioStatus {
case idle, loading, generating, ready, error(String)
}
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}

View File

@@ -9,7 +9,7 @@ import Foundation
actor APIClient {
static let shared = APIClient()
private var baseURL: URL
var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
private var sessionId: String? // anon session id (UUID)
@@ -58,7 +58,16 @@ actor APIClient {
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
let url = baseURL.appendingPathComponent(path)
// Build URL by appending the path string directly to the base URL string.
// appendingPathComponent() percent-encodes slashes, which breaks multi-segment
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
print("[APIClient] ▶ \(method) \(urlString)")
guard let url = URL(string: urlString) else {
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
throw APIError.invalidResponse
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
@@ -66,6 +75,12 @@ actor APIClient {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
// Log cookies being sent
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))" })")
} else {
print("[APIClient] cookies: (none)")
}
return req
}
@@ -75,15 +90,40 @@ actor APIClient {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
print("[APIClient] ✗ \(path) — not an HTTP response")
throw APIError.invalidResponse
}
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
print("[APIClient] ◀ \(http.statusCode) \(path)\(data.count) bytes")
if data.count < 2000 {
print("[APIClient] body: \(rawBody)")
} else {
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
}
guard (200..<300).contains(http.statusCode) else {
let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
throw APIError.httpError(http.statusCode, message)
throw APIError.httpError(http.statusCode, rawBody)
}
do {
return try JSONDecoder.iso8601.decode(T.self, from: data)
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
print("[APIClient] ✓ decoded \(T.self) from \(path)")
return decoded
} catch {
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
print("[APIClient] decode error: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .typeMismatch(let type, let ctx):
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .valueNotFound(let type, let ctx):
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .keyNotFound(let key, let ctx):
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .dataCorrupted(let ctx):
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
@unknown default:
print("[APIClient] unknown decoding error: \(error)")
}
}
throw APIError.decodingError(error)
}
}
@@ -237,7 +277,9 @@ actor APIClient {
// MARK: - Sessions
func sessions() async throws -> [UserSession] {
try await fetch("/api/sessions")
struct Response: Decodable { let sessions: [UserSession] }
let r: Response = try await fetch("/api/sessions")
return r.sessions
}
func revokeSession(id: String) async throws {
@@ -316,7 +358,7 @@ struct BrowseResponse: Decodable {
enum CodingKeys: String, CodingKey {
case novels, page
case hasNext = "has_next"
case hasNext = "hasNext"
}
}
@@ -365,9 +407,10 @@ struct SearchResponse: Decodable {
}
struct AudioGenerateResponse: Decodable {
let status: String // "generating" | "ready" | "error"
let url: String?
let message: String?
// The Go API is synchronous: it blocks until generation completes and
// returns {"url": "...", "filename": "..."} on success.
let url: String
let filename: String
}
struct ProgressEntry: Decodable, Identifiable {

View File

@@ -6,12 +6,12 @@ import Combine
// MARK: - AudioPlayerService
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
// next chapter audio mirroring the web AudioStore.
// next chapter audio.
@MainActor
final class AudioPlayerService: ObservableObject {
// MARK: - Published state (mirrors web AudioStore fields)
// MARK: - Published state
@Published var slug: String = ""
@Published var chapter: Int = 0
@@ -36,7 +36,6 @@ final class AudioPlayerService: ObservableObject {
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@Published var nextProgress: Double = 0
@Published var nextPrefetchedChapter: Int? = nil
var isActive: Bool {
@@ -52,10 +51,16 @@ final class AudioPlayerService: ObservableObject {
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var statusObserver: AnyCancellable?
private var durationObserver: AnyCancellable?
private var finishObserver: AnyCancellable?
private var generationTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>?
// Cached cover image downloaded once per chapter load, reused on every
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
private var cachedCoverArtwork: MPMediaItemArtwork?
private var cachedCoverURL: String = ""
// MARK: - Init
init() {
@@ -69,7 +74,6 @@ final class AudioPlayerService: ObservableObject {
func load(slug: String, chapter: Int, chapterTitle: String,
bookTitle: String, coverURL: String, voice: String, speed: Double,
chapters: [ChapterIndexBrief], nextChapter: Int?) {
// Cancel any in-flight generation
generationTask?.cancel()
prefetchTask?.cancel()
stop()
@@ -87,9 +91,16 @@ final class AudioPlayerService: ObservableObject {
self.nextAudioURL = ""
self.nextPrefetchedChapter = nil
status = .loading
status = .generating
generationProgress = 0
// Invalidate cover cache if the book changed.
if coverURL != cachedCoverURL {
cachedCoverArtwork = nil
cachedCoverURL = coverURL
prefetchCoverArtwork(from: coverURL)
}
generationTask = Task { await generateAudio() }
}
@@ -112,9 +123,11 @@ final class AudioPlayerService: ObservableObject {
func seek(to seconds: Double) {
let time = CMTime(seconds: seconds, preferredTimescale: 600)
player?.seek(to: time)
currentTime = seconds
updateNowPlaying()
currentTime = seconds // optimistic UI update
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.updateNowPlaying() }
}
}
func skip(by seconds: Double) {
@@ -133,84 +146,61 @@ final class AudioPlayerService: ObservableObject {
isPlaying = false
currentTime = 0
duration = 0
audioURL = ""
status = .idle
}
// MARK: - Audio generation loop (mirrors web polling pattern)
// MARK: - Audio generation
private func generateAudio() async {
guard !slug.isEmpty, chapter > 0 else { return }
do {
var pollCount = 0
while !Task.isCancelled {
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
switch response.status {
case "ready":
if let url = response.url {
await MainActor.run {
self.audioURL = url
self.status = .ready
self.generationProgress = 100
}
await playURL(url)
await prefetchNext()
}
return
case "generating":
await MainActor.run {
self.status = .generating
// Simulate progress ramp (same trick as the web UI)
self.generationProgress = min(95, Double(pollCount) * 8)
}
pollCount += 1
try await Task.sleep(for: .seconds(2))
case "error":
await MainActor.run {
self.status = .error(response.message ?? "Unknown error")
}
return
default:
break
}
// Fast path: audio already in MinIO get a presigned URL and play immediately.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
audioURL = presignedURL
status = .ready
generationProgress = 100
await playURL(presignedURL)
await prefetchNext()
return
}
// Slow path: trigger TTS generation. Go API blocks until done.
status = .generating
generationProgress = 50
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
audioURL = response.url
status = .ready
generationProgress = 100
await playURL(response.url)
await prefetchNext()
} catch is CancellationError {
// Cancelled no-op
} catch {
await MainActor.run {
self.status = .error(error.localizedDescription)
self.errorMessage = error.localizedDescription
}
status = .error(error.localizedDescription)
errorMessage = error.localizedDescription
}
}
// MARK: - Prefetch next chapter
// Always prefetch regardless of autoNext faster playback when the user
// manually navigates forward. autoNext only controls whether we auto-navigate.
private func prefetchNext() async {
guard autoNext, let next = nextChapter, !Task.isCancelled else { return }
guard let next = nextChapter, !Task.isCancelled else { return }
nextPrefetchStatus = .prefetching
nextPrefetchedChapter = next
do {
var pollCount = 0
while !Task.isCancelled {
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
switch response.status {
case "ready":
if let url = response.url {
nextAudioURL = url
nextPrefetchStatus = .prefetched
}
return
case "generating":
nextProgress = min(95, Double(pollCount) * 8)
pollCount += 1
try await Task.sleep(for: .seconds(2))
case "error":
nextPrefetchStatus = .failed
return
default:
break
}
// Fast path: already in MinIO.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) {
nextAudioURL = presignedURL
nextPrefetchStatus = .prefetched
return
}
// Slow path: trigger generation so it's ready for next time.
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
nextAudioURL = response.url
nextPrefetchStatus = .prefetched
} catch {
nextPrefetchStatus = .failed
}
@@ -219,28 +209,63 @@ final class AudioPlayerService: ObservableObject {
// MARK: - AVPlayer management
private func playURL(_ urlString: String) async {
guard let url = URL(string: urlString) else { return }
// Resolve relative paths (e.g. "/api/audio/...") to absolute URLs.
let resolved: URL?
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
resolved = URL(string: urlString)
} else {
resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL
}
guard let url = resolved else { return }
teardownPlayer()
let item = AVPlayerItem(url: url)
playerItem = item
player = AVPlayer(playerItem: item)
player?.rate = Float(speed)
// Observe playback time
// KVO: update duration as soon as asset metadata is loaded.
durationObserver = item.publisher(for: \.duration)
.receive(on: RunLoop.main)
.sink { [weak self] dur in
guard let self else { return }
let secs = dur.seconds
if secs.isFinite && secs > 0 {
self.duration = secs
self.updateNowPlaying()
}
}
// KVO: set playback rate once the item is ready.
// Do NOT call player?.play() unconditionally let readyToPlay trigger it
// so we don't race between AVPlayer's internal buffering and our call.
statusObserver = item.publisher(for: \.status)
.receive(on: RunLoop.main)
.sink { [weak self] itemStatus in
guard let self else { return }
if itemStatus == .readyToPlay {
self.player?.rate = Float(self.speed)
self.isPlaying = true
self.updateNowPlaying()
} else if itemStatus == .failed {
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
}
}
// Periodic time observer for seek bar position.
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self else { return }
Task { @MainActor in
self.currentTime = time.seconds
if let dur = self.playerItem?.duration.seconds, dur.isFinite, dur > 0 {
self.duration = dur
let secs = time.seconds
if secs.isFinite && secs >= 0 {
self.currentTime = secs
}
}
}
// Observe when playback ends
// Observe when playback ends.
finishObserver = NotificationCenter.default
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
.sink { [weak self] _ in
@@ -249,14 +274,15 @@ final class AudioPlayerService: ObservableObject {
}
}
// Kick off buffering actual playback starts via statusObserver above.
player?.play()
isPlaying = true
updateNowPlaying()
}
private func teardownPlayer() {
if let observer = timeObserver { player?.removeTimeObserver(observer) }
timeObserver = nil
statusObserver = nil
durationObserver = nil
finishObserver = nil
player = nil
playerItem = nil
@@ -264,14 +290,74 @@ final class AudioPlayerService: ObservableObject {
private func handlePlaybackFinished() {
isPlaying = false
if autoNext, let next = nextChapter {
// The chapter view model listens for this signal to navigate
NotificationCenter.default.post(name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next])
guard let next = nextChapter else { return }
// Always notify the view that the chapter finished (it may update UI).
NotificationCenter.default.post(
name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next, "autoNext": autoNext]
)
// If autoNext is on, load the next chapter internally right away.
// We already have the metadata in `chapters`, so we can reconstruct
// everything without waiting for the view to navigate.
guard autoNext else { return }
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
// If we already prefetched a URL for the next chapter, skip straight to
// playback and kick off generation in the background for the one after.
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
let url = nextAudioURL
// Advance state before tearing down the current player.
chapter = next
chapterTitle = nextTitle
nextChapter = nextNextChapter
nextPrefetchStatus = .none
nextAudioURL = ""
nextPrefetchedChapter = nil
audioURL = url
status = .ready
generationProgress = 100
generationTask = Task {
await playURL(url)
await prefetchNext()
}
} else {
// No prefetch available do a full load.
load(
slug: slug,
chapter: next,
chapterTitle: nextTitle,
bookTitle: bookTitle,
coverURL: coverURL,
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter
)
}
}
// MARK: - Cover art prefetch
private func prefetchCoverArtwork(from urlString: String) {
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self, let data, let image = UIImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Task { @MainActor in
self.cachedCoverArtwork = artwork
self.updateNowPlaying()
}
}.resume()
}
// MARK: - Audio Session
private func configureAudioSession() {
@@ -283,7 +369,7 @@ final class AudioPlayerService: ObservableObject {
}
}
// MARK: - Lock Screen / Control Center (NowPlayingInfoCenter)
// MARK: - Lock Screen / Control Center
private func setupRemoteCommandCenter() {
let center = MPRemoteCommandCenter.shared()
@@ -325,19 +411,10 @@ final class AudioPlayerService: ObservableObject {
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
]
// Cover art (async download)
if !coverURL.isEmpty, let url = URL(string: coverURL) {
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data, let image = UIImage(data: data) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
var updated = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
updated[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = updated
}
}.resume()
// Use cached artwork downloaded once in prefetchCoverArtwork().
if let artwork = cachedCoverArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
@@ -346,14 +423,13 @@ final class AudioPlayerService: ObservableObject {
enum AudioPlayerStatus: Equatable {
case idle
case loading
case generating
case generating // covers both "loading" and "generating TTS" phases
case ready
case error(String)
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.generating, .generating), (.ready, .ready):
case (.idle, .idle), (.generating, .generating), (.ready, .ready):
return true
case (.error(let a), .error(let b)):
return a == b

View File

@@ -26,7 +26,9 @@ final class BookDetailViewModel: ObservableObject {
saved = detail.saved
lastChapter = detail.lastChapter
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}

View File

@@ -37,7 +37,9 @@ final class BrowseViewModel: ObservableObject {
novels = result.results
hasNext = false
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
@@ -48,12 +50,14 @@ final class BrowseViewModel: ObservableObject {
}
private func loadPage(_ page: Int) async {
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
isLoading = true
error = nil
do {
let result = try await APIClient.shared.browse(
page: page, genre: genre, sort: sort, status: status
)
print("[Browse] ✓ page \(page)\(result.novels.count) novels, hasNext=\(result.hasNext)")
if page == 1 {
novels = result.novels
} else {
@@ -62,7 +66,10 @@ final class BrowseViewModel: ObservableObject {
hasNext = result.hasNext
currentPage = page
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
print("[Browse] ✗ error on page \(page): \(error)")
self.error = error.localizedDescription
}
}
isLoading = false
}

View File

@@ -16,22 +16,32 @@ final class ChapterReaderViewModel: ObservableObject {
}
func load() async {
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
isLoading = true
error = nil
do {
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
// Record reading progress
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
print("[ChapterReader] ✗ error: \(error)")
self.error = error.localizedDescription
}
}
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
isLoading = false
}
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
guard let content else { return }
let isCurrent = audioPlayer.slug == slug && audioPlayer.chapter == chapter
// Only treat as "current" if the player is active (not idle/stopped).
// If the user stopped playback, isActive is false we must re-load.
let isCurrent = audioPlayer.isActive &&
audioPlayer.slug == slug &&
audioPlayer.chapter == chapter
if isCurrent {
audioPlayer.togglePlayPause()

View File

@@ -19,7 +19,9 @@ final class HomeViewModel: ObservableObject {
recentlyUpdated = data.recentlyUpdated
stats = data.stats
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}

View File

@@ -12,7 +12,9 @@ final class LibraryViewModel: ObservableObject {
do {
items = try await APIClient.shared.library()
} catch {
self.error = error.localizedDescription
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}

View File

@@ -30,7 +30,7 @@ struct BookDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}

View File

@@ -49,7 +49,23 @@ struct BrowseView: View {
if vm.isLoading && vm.novels.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.novels.isEmpty && !vm.isLoading {
EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.").frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 16) {
if let errMsg = vm.error {
Image(systemName: "wifi.slash")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(errMsg)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
Button("Retry") { Task { await vm.loadFirstPage() } }
.buttonStyle(.borderedProminent)
.tint(.amber)
} else {
EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
@@ -70,6 +86,7 @@ struct BrowseView: View {
}
.padding()
}
.refreshable { await vm.loadFirstPage() }
}
}
.navigationTitle("Discover")

View File

@@ -18,19 +18,46 @@ struct ChapterReaderView: View {
}
var body: some View {
let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")")
Group {
if vm.isLoading {
let _ = print("[ChapterReaderView] branch: LOADING")
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let content = vm.content {
let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'")
readerContent(content)
} else if let errMsg = vm.error {
let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'")
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.orange)
Text(errMsg)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
Button("Retry") { Task { await vm.load() } }
.buttonStyle(.borderedProminent)
.tint(.amber)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)")
Color.clear
}
}
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
.navigationBarTitleDisplayMode(.inline)
.toolbar { audioToolbarButton }
.task { await vm.load() }
.task {
print("[ChapterReaderView] .task fired — calling vm.load()")
await vm.load()
print("[ChapterReaderView] .task completed")
}
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
if let next = note.userInfo?["next"] as? Int {
guard let next = note.userInfo?["next"] as? Int else { return }
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
if shouldAutoNavigate {
vm.navigateTo = next
}
}
@@ -42,15 +69,15 @@ struct ChapterReaderView: View {
ChapterReaderView(slug: slug, chapterNumber: next)
}
}
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
// MARK: - Content
@State private var webHeight: CGFloat = 800
@ViewBuilder
private func readerContent(_ content: ChapterResponse) -> some View {
let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)")
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Header
@@ -68,7 +95,8 @@ struct ChapterReaderView: View {
Divider()
// Chapter body
HTMLContentView(html: content.html)
HTMLContentView(html: content.html, height: $webHeight)
.frame(height: webHeight)
.padding(.horizontal)
Divider()
@@ -119,30 +147,62 @@ struct ChapterReaderView: View {
struct HTMLContentView: UIViewRepresentable {
let html: String
@Binding var height: CGFloat
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let wv = WKWebView()
wv.scrollView.isScrollEnabled = false
wv.isOpaque = false
wv.backgroundColor = .clear
wv.scrollView.backgroundColor = .clear
wv.navigationDelegate = context.coordinator
return wv
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let isDark = UITraitCollection.current.userInterfaceStyle == .dark
let textColor = isDark ? "#e5e5e5" : "#1a1a1a"
let css = """
body {
font-family: -apple-system, Georgia, serif;
font-size: 17px;
line-height: 1.7;
color: \(UITraitCollection.current.userInterfaceStyle == .dark ? "#e5e5e5" : "#1a1a1a");
color: \(textColor);
background: transparent;
margin: 0; padding: 0;
word-break: break-word;
}
p { margin: 0 0 1em 0; }
"""
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView")
uiView.loadHTMLString(wrapped, baseURL: nil)
}
class Coordinator: NSObject, WKNavigationDelegate {
var parent: HTMLContentView
init(_ parent: HTMLContentView) { self.parent = parent }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("[HTMLContentView] didFinish — evaluating scrollHeight")
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
DispatchQueue.main.async {
print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))")
if let h = result as? CGFloat, h > 0 {
print("[HTMLContentView] height set to CGFloat \(h)")
self.parent.height = h
} else if let h = result as? Double, h > 0 {
print("[HTMLContentView] height set to Double \(h)")
self.parent.height = CGFloat(h)
} else {
print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)")
}
}
}
}
}
}
// MARK: - Reverse label style (icon on right)

View File

@@ -28,17 +28,17 @@ struct HomeView: View {
// Continue reading
if !vm.continueReading.isEmpty {
SectionHeader(title: "Continue Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 12) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
}
.padding(.horizontal)
}
.padding(.horizontal)
}
}
// Recently updated
@@ -83,7 +83,7 @@ struct HomeView: View {
}
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: {
Text(vm.error ?? "")

View File

@@ -42,7 +42,7 @@ struct LibraryView: View {
}
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}

View File

@@ -43,7 +43,7 @@ struct MiniPlayerView: View {
// Status spinner or playback controls
switch audioPlayer.status {
case .loading, .generating:
case .generating:
ProgressView()
.scaleEffect(0.8)
.frame(width: 36)

View File

@@ -68,7 +68,7 @@ struct ProfileView: View {
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.alert("Error", isPresented: .constant(vm.error != nil)) {
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}