Skip to content

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

Open
mickael-menu wants to merge 11 commits into
developfrom
fix-issue-579
Open

Fixed long delay before streamed LCP audiobooks start playing#880
mickael-menu wants to merge 11 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.

Comment thread Sources/Navigator/Audiobook/AudioNavigator.swift Outdated
mickael-menu and others added 9 commits September 11, 2026 14:46
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new tests fail Swift 6 compilation, and seeking while stalled can unintentionally stop playback.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Improves progressive playback for streamed, LCP-protected audiobooks.

Changes:

  • Decrypts AES-CBC resources incrementally.
  • Clamps ZIP and buffered reads to resource bounds.
  • Reports buffering states and loading failures while retaining cached media resources.
File summaries
File Description
CHANGELOG.md Documents streaming and playback fixes.
Sources/LCP/Content Protection/LCPDecryptor.swift Adds chunked CBC decryption.
Sources/Navigator/Audiobook/AudioNavigator.swift Reports loading and player failures.
Sources/Navigator/Audiobook/PublicationMediaLoader.swift Retains resources and forwards errors.
Sources/Shared/Toolkit/Data/ReadError.swift Preserves and classifies cancellation errors.
Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift Bounds read-ahead requests.
Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift Clamps ZIP entry ranges.
Tests/LCPTests/LCPDecryptionTests.swift Tests chunking and cancellation.
Tests/SharedTests/Toolkit/Data/ReadErrorTests.swift Tests error handling.
Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift Tests bounded read-ahead.
Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift Tests Minizip range behavior.
Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift Tests ZIPFoundation range behavior.
Review details

Suppressed comments (1)

Tests/LCPTests/LCPDecryptionTests.swift:92

  • As above, stream requires an @Sendable consumer, and Swift 6 rejects mutating the captured local array here. Using Mutex keeps this cancellation test compilable and safe.
            chunks.append(chunk)
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Tests/LCPTests/LCPDecryptionTests.swift Outdated
Comment thread Sources/Navigator/Audiobook/AudioNavigator.swift
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

3 participants