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
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">· {(chapter as { date_label?: string }).date_label}</span>
|
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">· {(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) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user