Fixed long delay before streamed LCP audiobooks start playing - #880
Draft
mickael-menu wants to merge 8 commits into
Draft
Fixed long delay before streamed LCP audiobooks start playing#880mickael-menu wants to merge 8 commits into
mickael-menu wants to merge 8 commits into
Conversation
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>
stevenzeck
reviewed
Aug 14, 2026
| let itemError = item.error | ||
| log(.error, "Failed to load the player item: \(String(describing: itemError))") | ||
|
|
||
| let href = publication.readingOrder[resourceIndex].url().relativeURL |
Contributor
There was a problem hiding this comment.
getOrNil(resourceIndex)? might be better.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. SinceAVPlayertypically asks for the entire resource, this meant downloading and decrypting the complete trackupfront.
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
BufferingResourceextended every read tolowerBound + maxSizewithout checking the resource length. Resources backed by an HTTP server reject such requests with a416 Range Not Satisfiableerror, andZIPFoundationthrowsrangeOutOfBounds. Two fixes:BufferingResourceclamps its read-ahead to the estimated length when known.ZIPFoundationResource.stream()clamps out-of-range indexes instead of failing, as required by theStreamablecontract. Added tests covering the same behavior for the Minizip container.Report an honest playback state and surface loading errors
AudioNavigator.statereturns.loadingwhen the player is stalled on an empty buffer. BecauseautomaticallyWaitsToMinimizeStallingis disabled,AVPlayerreports.playingin that situation. The navigator now observesisPlaybackLikelyToKeepUpso the state is republished when buffering resumes.NavigatorDelegate.navigator(_:didFailToLoadResourceAt:withError:), bothfrom
PublicationMediaLoaderand from a failedAVPlayerItem, instead of being logged and dropped.ReadError.isCancellation, andReadError.wrap()now passes through errors that are alreadyReadErrors instead of burying them in a.decodingcase.Keep media resources cached across loading requests
PublicationMediaLoaderdropped 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.