Skip to content

Fixed long delay before streamed LCP audiobooks start playing - #880

Draft
mickael-menu wants to merge 8 commits into
developfrom
fix-issue-579
Draft

Fixed long delay before streamed LCP audiobooks start playing#880
mickael-menu wants to merge 8 commits into
developfrom
fix-issue-579

Conversation

@mickael-menu

Copy link
Copy Markdown
Member

Fixes #579

Streaming an LCP-protected audiobook with large, single-chapter tracks would not start playing until the whole track had been downloaded and decrypted. During that time the navigator reported .playing, so apps had no way to show a loading indicator, and any failure was silently swallowed.

Decrypt AES-CBC resources in chunks

LCPDecryptor.CBCLCPResource.stream() read and deciphered the full requested range before handing anything to the caller. Since AVPlayer typically asks for the entire resource, this meant downloading and decrypting the complete track
upfront.

It now decrypts and consumes the range in 256 KB chunks, so the player receives the beginning of the track while the rest is still being fetched. The plaintext size is resolved once for the whole stream, the requested range is clamped to it, and cancellation is checked between chunks. The per-chunk block math is unchanged, extracted into a private decrypt(range:) helper.

Fix out-of-range reads breaking the read-ahead buffer

BufferingResource extended every read to lowerBound + maxSize without checking the resource length. Resources backed by an HTTP server reject such requests with a 416 Range Not Satisfiable error, and ZIPFoundation throws
rangeOutOfBounds. Two fixes:

  • BufferingResource clamps its read-ahead to the estimated length when known.
  • ZIPFoundationResource.stream() clamps out-of-range indexes instead of failing, as required by the Streamable contract. Added tests covering the same behavior for the Minizip container.

Report an honest playback state and surface loading errors

  • AudioNavigator.state returns .loading when the player is stalled on an empty buffer. Because automaticallyWaitsToMinimizeStalling is disabled, AVPlayer reports .playing in that situation. The navigator now observes isPlaybackLikelyToKeepUp so the state is republished when buffering resumes.
  • Resource loading failures are forwarded to NavigatorDelegate.navigator(_:didFailToLoadResourceAt:withError:), both
    from PublicationMediaLoader and from a failed AVPlayerItem, instead of being logged and dropped.
  • Cancelled reads are not reported as errors, as the player routinely abandons requests when seeking. Added ReadError.isCancellation, and ReadError.wrap() now passes through errors that are already ReadErrors instead of burying them in a .decoding case.

Keep media resources cached across loading requests

PublicationMediaLoader dropped a resource as soon as its last loading request finished. As the player routinely cancels a request only to immediately issue a new one for the same entry, this threw away the buffered data and forced re-downloading the beginning of the track. Resources are now only evicted when a different entry is requested.

mickael-menu and others added 8 commits July 10, 2026 11:52
The Streamable contract requires out-of-range indexes to be clamped,
but ZIPFoundationContainer let ReadiumZIPFoundation throw a
rangeOutOfBounds error instead. BufferingResource also extended every
request with a read-ahead that could exceed the resource length,
over-requesting from resources that reject out-of-range ranges (e.g.
HTTP servers replying with 416).

Part of #579.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CBCLCPResource used to materialize the entire requested range in
memory (or the whole resource for open-ended requests) before a single
consume call. Since AVPlayer requests audio tracks with open-ended
ranges, a streamed LCP audiobook was fully downloaded and decrypted
before playback could start.

The range is now decrypted and delivered in 256 KB plaintext chunks,
checking for task cancellation between chunks so AVPlayer can actually
stop an abandoned request.

Part of #579.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ator

With automaticallyWaitsToMinimizeStalling disabled, AVPlayer reports
.playing even when stalled on an empty buffer, so AudioNavigator
claimed the audiobook was playing while nothing was audible. The state
now reports .loading in that situation, with a KVO observer on
isPlaybackLikelyToKeepUp for snappier transitions.

Failures of the AVPlayerItem and of the media loader used to be
silently swallowed; they are now forwarded to
NavigatorDelegate.navigator(_:didFailToLoadResourceAt:withError:).

Part of #579.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The end-to-end verification on a streamed LCP audiobook confirmed the
fixes, so the temporary instrumentation is no longer needed. Also
restores the LCPL fulfillment in the TestApp, which was disabled to
force streaming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PublicationMediaLoader evicted the cached resource as soon as its last
loading request finished. But AVPlayer routinely abandons a data
request to reissue a new one for the same entry, and rebuilding the
resource threw away the buffered data and the cached plaintext size,
re-downloading the beginning of the track. The resources of other
entries are still evicted, e.g. when switching tracks.

Part of #579.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the player abandoned a data request while streaming, the
cancelled HTTP read surfaced through the ZIP layer as
.decoding(ReadError.access(.http(.cancelled))) and was forwarded to
NavigatorDelegate.didFailToLoadResourceAt, even though nothing failed.

ReadError.wrap() now passes through errors that are already
ReadErrors instead of re-wrapping them in .decoding, and
PublicationMediaLoader filters cancellations with the new
ReadError.isCancellation helper, which also recognizes cancelled HTTP
requests and CancellationErrors nested in .decoding.

Part of #579.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
let itemError = item.error
log(.error, "Failed to load the player item: \(String(describing: itemError))")

let href = publication.readingOrder[resourceIndex].url().relativeURL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getOrNil(resourceIndex)? might be better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Audiobook Streaming Delay (audioBook.lcpl) – Chunks Fully Buffered Before Playback Starts

2 participants