You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
First of all, thank you for the recent update enabling progressive streaming for LCP-protected audiobooks — it’s a highly valuable addition, and we appreciate the continued effort.
However, while implementing this feature in our iOS app using the swift-toolkit, we encountered a couple of issues that are affecting the user experience:
1. Delayed Playback with Large Audiobooks
We've noticed that even though the content is streamed in chunks (each of 6MB), the audio does not begin playing until all chunks have been fully downloaded into the buffer.
This behavior is particularly noticeable with large audiobooks that have a single-chapter structure, where the full download can take a significant amount of time, causing a delay before playback begins.
We were expecting playback to start after the first chunk or two becomes available, with the remaining chunks being fetched progressively as needed during playback, rather than all at once upfront.
However, it seems that BufferingResource waits for the entire file to be fully buffered before playback begins.
2. Playback State During Buffering
During this initial buffering period, the playback model returns .playing even though the audio hasn’t started yet.
Ideally, we’d expect a .loading or .buffering state so we can show a loading indicator or spinner to inform the user that playback is still being prepared.
❓ Questions:
Is this buffering behavior expected in the current implementation?
Is there any internal flag or value that we can use to detect when the audio is still buffering, even if model.playback.state == .playing?
Are there any recommended practices to improve the responsiveness of playback and the user feedback during this buffering stage?
Please don't hesitate to ask if you need any additional information to help reproduce or better understand the issue — I’d be happy to provide whatever is needed.
Best regards
How to reproduce?
Open the test app with a .lcpl audiobook file that:
Is this buffering behavior expected in the current implementation?
Maybe? I agree that is less than ideal and I spent some time investigating this issue without success for now.
We are using the native AVPlayer with a custom AVAssetResourceLoaderDelegate. What I observed is that most of the time, and despite declaring support for byte range requests, the resource loader will request the full length of the resource and will start the playback only when everything is loaded.
From what I gathered, it's could be caused by the length of the resource being under a certain length threshold, and caused by the file format of the resource. As it's closed source and the documentation is sparse, I don't have definite answers.
Maybe there's a solution or at least a workaround. If you want to take a stab at it, the relevant implementation is here:
Theoretically we should be in the state waitingToPlayAtSpecifiedRate. Maybe there's an event that we don't observe and that is missing, or an issue in AVPlayer itself. I guess we could also be smarter and check for the playback rate. It think it should be 0 when it's not really playing.
If you would like to give this a try, it would greatly be appreciated! Please feel free to discuss anything here if you need help.
I can confirm this is still present in 3.9.0 (iOS, physical device, not simulator).
In my case it's an LCP audiobook with 3 tracks (~6-8MB each, MP3, stored uncompressed
in the .audiobook zip), streamed directly from a .lcpl via PublicationOpener
following the documented streaming guide.
Symptom matches exactly: playbackDidChange reports state: playing immediately
after navigator.play(), but info.time never advances from 0.0, and no error is
ever surfaced (checked .AVPlayerItemNewErrorLogEntry — empty).
I spent some time ruling out server-side causes, which might be useful context:
Confirmed via curl that the origin server correctly handles HEAD, forward Range, suffix Range (bytes=-1024), and an over-range request clamped to the
actual file size — all return correct 206 Partial Content with accurate Content-Range.
Confirmed audio tracks are stored uncompressed in the zip (zip.Store, verified
via unzip -v).
Added the (previously missing) properties.encrypted.original_length and compression fields to the manifest — no change in behavior.
Ruled out URLCache / client-side caching (cleared cache, reinstalled the app).
This points pretty strongly at the resource loader always requesting the full length
before playback starts, as you suspected, rather than anything server/manifest related.
Not Roman, but we hit this in Palace and tested fix-issue-579. It works. Measurements below in case they are useful.
One piece of context worth passing on: our app falls back to downloading the whole .lcpa when streaming fails, so for a while this looked like slowness rather than a bug. That kind of fallback may be masking this for other consumers too.
Setup. LCP audiobook, 37 MP3 tracks, roughly 1.2 GB, all STORED, AES-256-CBC. We use our own AVAssetResourceLoaderDelegate doing ranged reads against the Resource from PublicationOpener, so this exercises the LCP decryptor and ZIP container range path, not the navigator.
Method. Three runs, identical app code and instrumentation, same book, same starting state, same tap. Only the swift-toolkit pin changed, and we verified the resolved version before each run.
3.9.0
3.11.0
fix-issue-579
loader requests
13
4
5
request pattern
offset=0 len=2 x13
offset=0 len=2 x4
2 byte probe, then advancing ranges
bytes served to AVPlayer
0
0
32,320,052
time to first byte
never
never
1.27 s
local archive on disk
present
present
none
On 3.9.0 and 3.11.0 the opening 2 byte probe is never satisfied, so AVPlayer never escalates to a real range. It reissues the same 2 byte read and playback never starts, which matches Roman's state: playing with info.time stuck at 0.0. On the branch the probe returns, ranges advance, and audio decrypts and plays.
The last row is what decides it. In the branch run there was no .lcpa on disk at any point, so every byte served came off the network.
Edit: we have since confirmed this end to end on a physical device (iPhone 17 Pro Max, iOS 26.5.2), with the app's background archive fetch disabled outright so no local copy could exist. The audiobook plays, audibly, streaming only. The numbers above were captured at the byte level, so we wanted to close that gap before you acted on them.
Caveats.
On the 3.9.0 and 3.11.0 runs, a background archive fetch we did not fully suppress did land an archive on disk. Zero bytes reached the player either way, so the negative result holds, but those two runs were not perfectly clean.
The 32 MB is far more than needed. That is our delegate asking for toEnd uncapped, not branch behavior.
We tested the branch as a whole and did not bisect.
Your original read, that the loader asks for the full resource length despite declaring range support, still reproduces on the branch. The requests are toEnd=true. The difference is that they get served now. So it unblocks playback without removing the pattern you described.
Happy to bisect, re-run against a specific commit, or share raw logs if that helps.
Thank you for the feedback @mauricecarrier7. Unfortunately this is too late to be fixed in v3, but it should be available in one of the upcoming v4 alpha/beta releases.
@mauricecarrier7 Would you mind checking on your side one last time before I merge the PR into develop? I reviewed it and rebased to develop. Thank you!
We re-validated LCP audiobook streaming against the rebased head (097b77d) on iOS.
Fulfilment leaves only the license, 2,569 bytes, no .lcpa, and playback starts from it directly. We exercised cancellation mid-stream specifically: repeated seeks across a chapter boundary, backgrounding, and closing the player during playback, with no spurious errors or stalls and no crashes. Looks good to me!
Activity
mickael-menu commentedon Apr 23, 2025
Maybe? I agree that is less than ideal and I spent some time investigating this issue without success for now.
We are using the native
AVPlayerwith a customAVAssetResourceLoaderDelegate. What I observed is that most of the time, and despite declaring support for byte range requests, the resource loader will request the full length of the resource and will start the playback only when everything is loaded.From what I gathered, it's could be caused by the length of the resource being under a certain length threshold, and caused by the file format of the resource. As it's closed source and the documentation is sparse, I don't have definite answers.
Maybe there's a solution or at least a workaround. If you want to take a stab at it, the relevant implementation is here:
swift-toolkit/Sources/Navigator/Audiobook/PublicationMediaLoader.swift
Line 111 in 55e52e7
And here are some additional resources that could be useful:
Not that I know of, but I didn't touch this code for a while. Here again we are relying on
AVPlayerand are directly reporting its internal status:swift-toolkit/Sources/Navigator/Audiobook/AudioNavigator.swift
Lines 516 to 529 in 55e52e7
Theoretically we should be in the state
waitingToPlayAtSpecifiedRate. Maybe there's an event that we don't observe and that is missing, or an issue inAVPlayeritself. I guess we could also be smarter and check for the playback rate. It think it should be0when it's not really playing.If you would like to give this a try, it would greatly be appreciated! Please feel free to discuss anything here if you need help.
RomanSoviak commentedon Jul 8, 2026
I can confirm this is still present in 3.9.0 (iOS, physical device, not simulator).
In my case it's an LCP audiobook with 3 tracks (~6-8MB each, MP3, stored uncompressed
in the .audiobook zip), streamed directly from a
.lcplviaPublicationOpenerfollowing the documented streaming guide.
Symptom matches exactly:
playbackDidChangereportsstate: playingimmediatelyafter
navigator.play(), butinfo.timenever advances from0.0, and no error isever surfaced (checked
.AVPlayerItemNewErrorLogEntry— empty).I spent some time ruling out server-side causes, which might be useful context:
curlthat the origin server correctly handlesHEAD, forwardRange, suffixRange(bytes=-1024), and an over-range request clamped to theactual file size — all return correct
206 Partial Contentwith accurateContent-Range.zip.Store, verifiedvia
unzip -v).properties.encrypted.original_lengthandcompressionfields to the manifest — no change in behavior.URLCache/ client-side caching (cleared cache, reinstalled the app).This points pretty strongly at the resource loader always requesting the full length
before playback starts, as you suspected, rather than anything server/manifest related.
mickael-menu commentedon Jul 10, 2026
@RomanSoviak Would you be able to check out the
fix-issue-579branch to see if it fixes the problems for you? Thank you!Keep media resources cached across loading requests
Do not report cancelled HTTP reads as loading errors
mickael-menu commentedon Jul 21, 2026
@RomanSoviak Did you have a chance to try this out?
mauricecarrier7 commentedon Jul 31, 2026
@mickael-menu
Not Roman, but we hit this in Palace and tested
fix-issue-579. It works. Measurements below in case they are useful.One piece of context worth passing on: our app falls back to downloading the whole
.lcpawhen streaming fails, so for a while this looked like slowness rather than a bug. That kind of fallback may be masking this for other consumers too.Setup. LCP audiobook, 37 MP3 tracks, roughly 1.2 GB, all
STORED, AES-256-CBC. We use our ownAVAssetResourceLoaderDelegatedoing ranged reads against theResourcefromPublicationOpener, so this exercises the LCP decryptor and ZIP container range path, not the navigator.Method. Three runs, identical app code and instrumentation, same book, same starting state, same tap. Only the
swift-toolkitpin changed, and we verified the resolved version before each run.fix-issue-579offset=0 len=2x13offset=0 len=2x4AVPlayerOn 3.9.0 and 3.11.0 the opening 2 byte probe is never satisfied, so
AVPlayernever escalates to a real range. It reissues the same 2 byte read and playback never starts, which matches Roman'sstate: playingwithinfo.timestuck at0.0. On the branch the probe returns, ranges advance, and audio decrypts and plays.The last row is what decides it. In the branch run there was no
.lcpaon disk at any point, so every byte served came off the network.Edit: we have since confirmed this end to end on a physical device (iPhone 17 Pro Max, iOS 26.5.2), with the app's background archive fetch disabled outright so no local copy could exist. The audiobook plays, audibly, streaming only. The numbers above were captured at the byte level, so we wanted to close that gap before you acted on them.
Caveats.
toEnduncapped, not branch behavior.toEnd=true. The difference is that they get served now. So it unblocks playback without removing the pattern you described.Happy to bisect, re-run against a specific commit, or share raw logs if that helps.
mickael-menu commentedon Aug 5, 2026
Thank you for the feedback @mauricecarrier7. Unfortunately this is too late to be fixed in v3, but it should be available in one of the upcoming v4 alpha/beta releases.
Clamp out-of-range ZIP reads and cap BufferingResource read-ahead
Stream LCP CBC resources in decrypted chunks
Report honest playback state and surface loading errors in AudioNavig…
Update CHANGELOG for #579
Remove #579 diagnostic logs
Keep media resources cached across loading requests
Do not report cancelled HTTP reads as loading errors
mickael-menu commentedon Sep 11, 2026
@mauricecarrier7 Would you mind checking on your side one last time before I merge the PR into
develop? I reviewed it and rebased todevelop. Thank you!#880
mauricecarrier7 commentedon Sep 14, 2026
@mickael-menu
We re-validated LCP audiobook streaming against the rebased head (097b77d) on iOS.
Fulfilment leaves only the license, 2,569 bytes, no .lcpa, and playback starts from it directly. We exercised cancellation mid-stream specifically: repeated seeks across a chapter boundary, backgrounding, and closing the player during playback, with no spurious errors or stalls and no crashes. Looks good to me!
mickael-menu commentedon Sep 14, 2026
Thanks, I appreciated it! It's merged.