feature/backend-rewrite #2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ?? "") }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? "")
|
||||
|
||||
@@ -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 ?? "") }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? "") }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user