iOS: fix audio generation to handle async 202 job flow + increase chapter list font size
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 10s
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

- Replace AudioGenerateResponse with AudioTriggerResponse to handle both 200 (cached) and 202 (async job) responses
- Add pollAudioStatus() in APIClient that polls /api/audio/status every 2s until done or failed
- Update generateAudio() and prefetchNext() in AudioPlayerService to poll instead of expecting a synchronous URL response
- Bump chapter list font sizes: number xs→sm, title sm→base, date/badge xs→sm, row padding py-2→py-2.5
This commit is contained in:
Admin
2026-03-07 20:33:19 +05:00
parent c6536d5b9f
commit 884c82b2c3
3 changed files with 82 additions and 19 deletions

View File

@@ -241,11 +241,42 @@ actor APIClient {
// MARK: - Audio
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioGenerateResponse {
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse {
struct Body: Encodable { let voice: String; let speed: Double }
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
}
/// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed.
/// Returns the presigned/proxy URL on success, throws on failure or cancellation.
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
struct StatusResponse: Decodable {
let status: String
let url: String?
let error: String?
}
while true {
try Task.checkCancellation()
let r: StatusResponse = try await fetch(path)
switch r.status {
case "done":
guard let url = r.url, !url.isEmpty else {
throw URLError(.badServerResponse)
}
return url
case "failed":
throw NSError(
domain: "AudioGeneration",
code: 0,
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]
)
default:
// pending / generating / idle keep polling
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s
}
}
}
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
struct Response: Decodable { let url: String }
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
@@ -406,11 +437,22 @@ struct SearchResponse: Decodable {
}
}
struct AudioGenerateResponse: Decodable {
// The Go API is synchronous: it blocks until generation completes and
// returns {"url": "...", "filename": "..."} on success.
let url: String
let filename: String
/// Returned by POST /api/audio/{slug}/{n}.
/// - 202 Accepted: job enqueued poll via pollAudioStatus()
/// - 200 OK: audio already cached url is ready to play
struct AudioTriggerResponse: Decodable {
let jobId: String? // present on 202
let status: String? // present on 202: "pending" | "generating"
let url: String? // present on 200: proxy URL ready to play
let filename: String? // present on 200
enum CodingKeys: String, CodingKey {
case jobId = "job_id"
case status, url, filename
}
/// True when the server accepted the request and created an async job.
var isAsync: Bool { jobId != nil }
}
struct ProgressEntry: Decodable, Identifiable {

View File

@@ -165,14 +165,28 @@ final class AudioPlayerService: ObservableObject {
return
}
// Slow path: trigger TTS generation. Go API blocks until done.
// Slow path: trigger TTS generation (async returns 202 immediately).
status = .generating
generationProgress = 50
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
audioURL = response.url
generationProgress = 10
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
let playableURL: String
if trigger.isAsync {
// 202 Accepted: poll until done.
generationProgress = 30
playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice)
} else {
// 200: already cached URL returned inline.
guard let url = trigger.url, !url.isEmpty else {
throw URLError(.badServerResponse)
}
playableURL = url
}
audioURL = playableURL
status = .ready
generationProgress = 100
await playURL(response.url)
await playURL(playableURL)
await prefetchNext()
} catch is CancellationError {
// Cancelled no-op
@@ -197,9 +211,16 @@ final class AudioPlayerService: ObservableObject {
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
// Slow path: trigger generation; poll until done (background won't block playback).
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
let url: String
if trigger.isAsync {
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
} else {
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
url = u
}
nextAudioURL = url
nextPrefetchStatus = .prefetched
} catch {
nextPrefetchStatus = .failed