diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index a3ddcba..de73c85 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -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 } diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index 3b8d4c2..1518e68 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -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=" 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) ?? "" + 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 { diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index 3e619ca..3c18d04 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -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? private var prefetchTask: Task? + // 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 diff --git a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift index 37051c4..384742c 100644 --- a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift @@ -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 } diff --git a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift index 0a2864a..75d3717 100644 --- a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift @@ -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 } diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift index 56ff569..4b9112f 100644 --- a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift @@ -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() diff --git a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift index 55583c9..739ea25 100644 --- a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift @@ -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 } diff --git a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift index aed85b3..50dd87d 100644 --- a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift @@ -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 } diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index f346006..a2bbd7d 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -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 ?? "") } } diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift index dcb9f47..765701e 100644 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -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") diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index f111b9e..a95dd79 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -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)" + 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) diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index c924bce..9fa6770 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -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 ?? "") diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index b82f6da..1efd2e4 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -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 ?? "") } } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 0fbf8a8..1ab0e3e 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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) diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index c92e8f9..56efe3c 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -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 ?? "") } }