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 // 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 } 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)) 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 { func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
struct Response: Decodable { let url: String } struct Response: Decodable { let url: String }
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") 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 { /// Returned by POST /api/audio/{slug}/{n}.
// The Go API is synchronous: it blocks until generation completes and /// - 202 Accepted: job enqueued poll via pollAudioStatus()
// returns {"url": "...", "filename": "..."} on success. /// - 200 OK: audio already cached url is ready to play
let url: String struct AudioTriggerResponse: Decodable {
let filename: String 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 { struct ProgressEntry: Decodable, Identifiable {

View File

@@ -165,14 +165,28 @@ final class AudioPlayerService: ObservableObject {
return return
} }
// Slow path: trigger TTS generation. Go API blocks until done. // Slow path: trigger TTS generation (async returns 202 immediately).
status = .generating status = .generating
generationProgress = 50 generationProgress = 10
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
audioURL = response.url
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 status = .ready
generationProgress = 100 generationProgress = 100
await playURL(response.url) await playURL(playableURL)
await prefetchNext() await prefetchNext()
} catch is CancellationError { } catch is CancellationError {
// Cancelled no-op // Cancelled no-op
@@ -197,9 +211,16 @@ final class AudioPlayerService: ObservableObject {
nextPrefetchStatus = .prefetched nextPrefetchStatus = .prefetched
return return
} }
// Slow path: trigger generation so it's ready for next time. // Slow path: trigger generation; poll until done (background won't block playback).
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
nextAudioURL = response.url 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 nextPrefetchStatus = .prefetched
} catch { } catch {
nextPrefetchStatus = .failed nextPrefetchStatus = .failed

View File

@@ -398,23 +398,23 @@
{@const chapterUrl = data.inLib {@const chapterUrl = data.inLib
? `/books/${data.book.slug}/chapters/${chapter.number}` ? `/books/${data.book.slug}/chapters/${chapter.number}`
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`} : `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
<div class="flex items-center gap-2 px-3 py-2 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}"> <div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0"> <a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
<!-- Chapter number --> <!-- Chapter number -->
<span class="text-xs font-mono w-9 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}"> <span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
{chapter.number} {chapter.number}
</span> </span>
<!-- Title --> <!-- Title -->
<span class="text-sm {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors"> <span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
{chapter.title || `Chapter ${chapter.number}`} {chapter.title || `Chapter ${chapter.number}`}
</span> </span>
<!-- Date label — desktop only --> <!-- Date label — desktop only -->
{#if (chapter as { date_label?: string }).date_label} {#if (chapter as { date_label?: string }).date_label}
<span class="text-xs text-zinc-600 flex-shrink-0 max-sm:hidden">&middot; {(chapter as { date_label?: string }).date_label}</span> <span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">&middot; {(chapter as { date_label?: string }).date_label}</span>
{/if} {/if}
<!-- "reading" badge --> <!-- "reading" badge -->
{#if isCurrent} {#if isCurrent}
<span class="text-xs text-amber-500 flex-shrink-0 font-medium">reading</span> <span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
{/if} {/if}
</a> </a>
<!-- Admin: scrape from this chapter up (hover-only) --> <!-- Admin: scrape from this chapter up (hover-only) -->