Playback
Playback is split into four layers, each with its own contract:
The engines only know about tracks (individual audio files) and report raw transport state. The whole-book timeline, chapters, resume and the error policy live in shared JS, so all three platforms behave identically.
The PlaybackService interface
src/playback/types.ts defines the engine contract:
-
load(tracks, startIndex, positionInTrack, chapters?, book?)replaces the queue.tracksarePlaybackTracks: a URL, optional authheaders, metadata and an optionalduration.chaptersis an optional list ofPlaybackChapterclips for the native lock screens' chapter view on both platforms (described below). The web accepts it and ignores it.book(BookRef: connection id, library id, path) optionally names the book. A native engine that outlives the JS, such as the Android service played from the car, can then say later which book its queue is.swapTotakes it too.
-
play,pause,seekTo(positionInTrack),skipToTrack(index, pos?),setRateandreset. -
swapTo?(…)is an optional gapless queue swap. It moves a streaming book onto its just-finished download without an audible gap. It resolves aSwapOutcome:'swapped','moved'when a seek or a move to another file came while the copy buffered (the caller may swap again from the new place), or'refused'. Its optionalonSwitchis called as the engine switches, before the first report from the new source (see Downloaded while streaming). -
setVolume(volume)is a required linear output gain (0-1). It applies to the engine's own volume, not the device volume. It exists for the sleep timer's fade-out on duration timers (The sleep timer).- It is deliberately not optional. Both engines implement it, and each engine degrades its one real "no volume here" case internally, as its own private business (Writing the gain).
- Callers pass a value that is already clamped.
usePlayer.setOutputVolumeis the only route in, and it clamps once.
-
configure(config)takes runtime tunables from the settings store: the auto-rewind window, the lock-screen skip intervals, and thesmartSpeedandvoiceBoostswitches (Smart speed and Voice boost). -
Optional native hooks, none of which the web has:
onRemoteMove(handler): the engine has already moved for something outside the JS API and landed at(trackIndex, positionInTrack)(see the module's events).onRateChange(handler): the OS changed the speed, and the engine already runs at it.onSilenceSaved(handler): Smart speed's running total. It is not playback state, so it is not in the snapshot.adoptPlace(snapshot): seeds the merged snapshot without emitting, for adopting a book the Android service loaded.
A bookmark button outside the app is not an engine hook. The car controller listens for the module's
onRemoteBookmarkitself (see Native integrations). -
getSnapshot()andsubscribe(listener)give a single mergedPlaybackSnapshot { state, trackIndex, position, duration, rate }, with per-track positions only. The states areidle | loading | ready | playing | paused | ended | error.
Metro resolves the implementation per platform: service.web.ts on web and
service.native.ts on iOS and Android. service.ts is a throwing fallback that
exists only so tsc can resolve the import. Nothing ever executes it.
Both createPlaybackService()s return their engine wrapped in the Cast route
switch (routed, src/cast/routed-service.ts). Behind the same interface, the
switch hands playback to a Cast receiver while a session is connected, and back
after it (see Chromecast).
Web engine (service.web.ts)
The web engine is a single HTMLAudioElement that it advances by hand on ended,
since there is no native queue. Key points:
-
The session token is already in the stream URL (
?token=, see State & data). The browser's own Range requests for seeking and scrubbing therefore authenticate without headers. -
Another origin's stream loads in CORS mode. Before every
srcchange (a load, a file skip, theswapToelement), the engine sets the element'scrossOriginfromcorsModeFor(src/lib/cors-mode.ts):'anonymous'for an http(s) URL on an origin other than the page's;- none for the player's own origin at
/web, the service worker's_offline/copies,blob:anddata:.
The server's
cors_originsdecides whether that load succeeds. CORS mode ignoresCross-Origin-Resource-Policy(CORS). -
The Media Session API wires the lock-screen and notification transport controls. It sets metadata per track, plus
play,pause,seekbackwardandseekforwardhandlers that use the configured jump intervals. Those seeks go back through the store (onRemoteSeek, which the store registers toseekInTrack), so a lock-screen seek lowers the resume floor and saves like any deliberate seek. -
A browser autoplay refusal is a pause, not an error.
play()without a user gesture, such as a cold/playerdeep link, rejects withNotAllowedError. The engine settles its snapshot onpausedand rethrows the error asAutoplayBlockedError(types.ts), which the store'sstartEnginecatches. -
A source that failed is a
PlaybackStartError('audio').playElementreads two errors as a failed source: aNotSupportedError("no supported sources"), and anAbortErrorfrom an element that has errored. It settles the snapshot onerrorand throwsPlaybackStartError(types.ts) with reasonaudio. AnAbortErrorfrom a healthy element is aplay()that a pause or the next source superseded, so nothing is thrown. -
A load ignores its own first
timeupdate. Settingsrcqueues atimeupdateatcurrentTime0 before any metadata arrives. The engine marks the elementplacinguntilloadedmetadatahas made the start seek, or the element firesseeked, and ignores itstimeupdates meanwhile. The snapshot keeps the load's position. A source that then fails keeps it too, so Retry starts from that place, never from the dead element's 0:00. -
A file advance is not a pause. At a file's natural end the element fires
pausejust beforeended. When another file follows, the engine ignores thatpause, and the store keeps its intent through the next file's load. A chapter sleep timer aimed at that boundary therefore finds the book live and pauses it. A load plays onloadedmetadataonly whilependingAutoplayis still set. Apause()during the load clears it and settles the snapshot onpaused. -
Auto-rewind on resume:
play()rewinds by up toautoRewindMaxseconds, scaled by how long the pause lasted. -
Voice boost routes the element through one Web Audio compressor, built lazily, where the browser allows it. It never does on Safari, and only for a source Web Audio can hear: a same-origin source, or another origin's loaded in CORS mode. See Smart speed and Voice boost.
-
Every element listener is guarded by an
active()check, so a second element thatswapTois buffering can't drive the snapshot until the switch commits. -
swapTobuffers the new (local) source on a separate element while the current one keeps playing. It switches only onceisSwapReadyholds:readyState >= HAVE_FUTURE_DATAand the playhead within about 1.5 s of the target.isSwapReadyis exported pure so it can be unit-tested.- It refuses outright when the target is a synthetic
…/_offline/…URL and no service worker controls the page, since the URL would 404 and kill playback. - It treats an 8 s buffering timeout as a failed swap.
- It reads its own intent after the copy has buffered, not before. A pause made meanwhile stands, whether the listener's or the tab's own giving way to another tab's play. A speed or a volume set meanwhile reached only the old element, so it is applied to the copy.
- A
loador aresetwhile the copy buffers (queueSeq) discards the copy and resolves'refused'. A move to another file (loadSeq) or a seek (seekSeq) discards it and resolves'moved', since the copy sits at the place before.
- It refuses outright when the target is a synthetic
-
A
playingfrom an element that is paused by then is not reported. The browser can queue aplayingbefore a pause lands. If the engine reported it, the store would read it as a play begun here and take the connection back from the tab that plays now. -
A transcoded track (
track.transcoded, web only) is re-requested on every seek instead of seeking the element, and its position is reported track-absolute (see Web transcode negotiation).
Native bridge (service.native.ts)
The native bridge is deliberately thin. It forwards calls to the AudiosiloPlayer
module and merges the module's three event streams (onState, onProgress,
onTrackChange) into one snapshot that it re-emits on every event. The
module's state strings match PlaybackState 1:1. Because a stale field rides along
with every fresh one, the store must not interpret individual engine events (see
the watchdog section).
It also listens for the module's newer events on every binary. An older binary never sends them.
onRemoteMoveupdates the snapshot'strackIndexandpositionbefore it calls the store's handler, so a save inside the handler saves the landed place.onRateChangeis passed on.onProgress's optionalsilenceSavedfeedsonSilenceSaved.
New functions are feature-detected. The JS bundle can be newer than the
installed binary, because a shipped store build lags, and an Expo function called
with more arguments than it declares throws. load's 5th argument, book, is
passed only when the binary also has getLoadedBook (moduleTakesBook), since the
two ship together. setVolume is detected the same way.
The native module (modules/audiosilo-player)
A local Expo module in Swift and Kotlin:
- Swift:
ios/AudiosiloPlayerModule.swift(the thin module definition),ios/AudioEngine.swift(the engine) and the files below. - Kotlin:
android/…/AudiosiloPlayerModule.ktandAudiosiloPlayerService.kt, with their helpers.
It owns the audio session, background audio, lock-screen and remote commands, gapless multi-file playback, pitch-corrected speed, the audio effects and the car.
A JS reload does not reload native code, so a change needs a native rebuild
(npx expo run:ios or run:android). Only its pure parts (TimelineMap, the
effects processors, VoiceBoostDSP, ChapterClips) have checks that need neither
a device nor the Simulator (Native checks). Its
TypeScript surface is modules/audiosilo-player/src/AudiosiloPlayer.types.ts and
AudiosiloPlayerModule.ts.
The native integrations added the surface below. Every function is optional on an older binary, and JS detects it.
| Name | What | |
|---|---|---|
| Event | onRemoteMove { trackIndex, position } | The engine moved for something outside the JS API: the lock screen or notification scrubber, its skip and chapter buttons, a headset, CarPlay or Android Auto, including their chapter lists. Sent once the move has landed, in file coordinates. Never sent for moves JS asked for (seekTo, skipToTrack, load), auto-rewind, Smart speed's skips, or a file running on into the next |
| Event | onRateChange { rate } | The OS changed the speed (iOS changePlaybackRateCommand, CarPlay's rate button, an Android controller). Already applied |
| Event | onRemoteBookmark { trackIndex, position, relPath?, connectionId?, libraryId?, path? } | A bookmark button outside the app (CarPlay Now Playing, Android Auto's custom action), with the engine's own book when it has one. The car controller listens for it, not the engine |
| Event | onCarConnection { connected } | CarPlay or Android Auto connected or left |
| Event | onCarPlayRequest { id } | The car asked for a book native can't start alone |
| Field | onProgress.silenceSaved | Book seconds Smart speed removed since the app's process started, summed over every player the service builds. Monotonic (Android only) |
| Function | setCarSnapshot(json) | Both platforms: the car snapshot |
| Function | getLoadedBook() | Android: the book the service has loaded (LoadedBook), else null. Its files? are the loaded files' library-relative paths in file-index order. iOS: null |
| Function | consumePendingBookmarks() | Android: bookmarks pressed while no JS ran, oldest first, cleared by the read. iOS: [] |
| Argument | load(..., chapters, book) | The BookRef. iOS keeps it to name the loaded book for CarPlay; Android stores it in each item's extras |
| Config | setConfig({ smartSpeed, voiceBoost }) | The effects switches |
Car and widget code is in Native integrations; the effects are in Smart speed and Voice boost.
iOS: AVQueuePlayer
AudioEngine (AudioEngine.swift) drives an AVQueuePlayer with a .playback
session, .spokenAudio mode and .longFormAudio policy.
audioTimePitchAlgorithm = .timeDomain gives pitch-corrected speed for speech.
The engine injects auth headers per asset through the undocumented
"AVURLAssetHTTPHeaderFieldsKey" option. It is the only mechanism AVFoundation
offers, so if Apple changes it, native stream auth breaks.
Each behavior below guards against a specific OS quirk:
- Deferred start seek (
pendingSeek/applyPendingSeek). AVFoundation silently drops a seek on a freshly createdAVPlayerItemthat has not reached.readyToPlay, especially for streaming assets. This made resume start from 0.rebuildQueuestores the target aspendingSeekand applies it through a status KVO once the item is ready. wantsPlaygating. Ifplay()arrives while apendingSeekis still in flight, the engine records the intent, reportsloading, and starts the player only in the seek's completion handler. Audio therefore never plays briefly from 0 before jumping.skip(to:)routes throughplay()for the same reason.- Progress suppression during (re)load. The 1 Hz progress timer emits nothing
while
pendingSeek != 0or while the current item isn't.readyToPlay. A fresh item readscurrentTime() == 0, and emitting that would overwrite the saved position in JS. It once made a retry after a failed reload resume from the start. - One real-state toggle for remote commands. A single earbud or headset press
is a toggle, but iOS delivers it as a discrete Play or Pause. iOS chooses
which from its own idea of the app's play state, and a third-party app cannot
correct that idea. A device ignores
MPNowPlayingInfoCenter.playbackState, so iOS infers the state itself and can get stuck on "paused". (The engine sets it anyway for the Simulator, see below.) When iOS guesses wrong, it sends Play while the book is already playing, and the press does nothing. This was the "pause needs two presses" bug. The fix:playCommand,pauseCommandandtogglePlayPauseCommandall route throughtogglePlayback(), which flips from the realtimeControlStatus. A pendingwantsPlaycounts as playing. - Interruption auto-resume only when it should. The engine captures
wasPlayingBeforeInterruptionbefore pausing on.began..endedauto-resumes only if that flag is set and the interruption carries.shouldResume. Without the flag, the charging chime resumed books the user had paused: it is a brief system interruption whose.endedcarries.shouldResume. - Failures are reported as sustained
loading, nevererror. A failed item parks the player at.paused, which would read as a user pause. The engine therefore watches the item's.failedstatus,failedToPlayToEndTimeandplaybackStalled, and reportsloadingfor all of them. The shared JS watchdog owns the promotion toerrorafter a uniform grace. An instant native error caused a rapid-retry race. - Rate re-assertion (
reassertRateWhenReady). AVPlayer can silently drop a rate set on a not-yet-ready item back to 1.0 once the item becomes ready. This showed when a mid-playback download swap replaced the streaming item. The engine watches the fresh item and re-asserts the intended rate. - Other details:
- Unplugging headphones (
oldDeviceUnavailable) pauses. - A queue rebuild sets a
rebuildingflag that suppresses the transient state and track eventsremoveAllItemsfires. - The engine maintains Now Playing metadata and artwork by hand. It fetches the
artwork with the auth headers through
URLRequest.
- Unplugging headphones (
Now Playing and remote commands on iOS
The chapter lock screen matches Android's. load keeps the chapter clips
(ChapterClips.swift), the same clips Android plays as items. If any clip names a
file outside the tracks, the engine drops back to file mode, as buildChapterClips
does.
With 2 or more clips, Now Playing shows the chapter:
- title: the chapter's title, else the track's;
- album: the book;
- artist: the author;
- duration: the clip's length;
- elapsed: the position in the file minus the clip start;
- plus
MPNowPlayingInfoPropertyChapterNumberandChapterCount.
The 1 s tick updates only elapsed and rate, unless the playhead has crossed into another clip. That crossing rewrites the whole info, and so does every seek, play, pause, rate change, rebuild or file advance. The commands work in chapters:
changePlaybackPositionCommand's time is chapter-relative. The engine maps it into the shown clip and clamps it inside the clip.nextTrackCommandgoes to the next clip, across files throughskip(to:position:). At the last clip it does nothing.previousTrackCommandrestarts the clip when more than 3 s in (ChapterClips.restartThreshold, Media3'sseekToPreviousrule), else goes to the previous one.
With 0 or 1 clips, Now Playing shows whole-file info, and next and previous go to the next or previous file. iOS chooses whether the lock screen draws the skip buttons or the track buttons. Headsets and CarPlay send the track commands.
Remote moves. Every command handler above that moves the playhead passes
remote: true. onRemoteMove goes out only once the move has landed:
- for a seek, after the seek's completion (and its
onProgress); - for a rebuild, after the deferred start seek (
remoteMovePending), or at once for a 0 target, which has no deferred seek.
Auto-rewind in play() sends nothing.
Rate command. changePlaybackRateCommand has supportedPlaybackRates [0.75, 1,
1.25, 1.5, 1.75, 2], which CarPlay's rate button needs. It applies the rate
(setRateFromRemote) and sends onRateChange once. The engine's speed is rate.
Now Playing shows it as PlaybackRate while playing and as DefaultPlaybackRate
always, so CarPlay's button doesn't read 0× while paused.
MPNowPlayingInfoCenter.playbackState. The engine sets it from the real
transport state (syncPlaybackState) on every timeControlStatus change, every
whole-info rewrite and every reset. A device ignores it, but the Simulator needs
it. Without it, CarPlay's Simulator window reads the app as paused.
Android: Media3 / ExoPlayer
Playback lives in a MediaLibraryService (AudiosiloPlayerService), so it survives
backgrounding. Android Auto browses the same service
(Native integrations). The Expo
module talks to it through a MediaController on the main thread. The controller
connects with the audiosilo.app hint, so the service can tell the app's own
commands from every other controller's. That is how the service reports
remote moves. Media3 renders the
notification and lock-screen UI itself.
Chapters are clipped media items (Audible-parity lock screen). When the JS side
passes chapter clips to load, each chapter becomes a MediaItem with a
ClippingConfiguration over its file's URL (toClipItem), titled with the
chapter. The system scrubber is therefore chapter-relative, and the standard
COMMAND_SEEK_TO_{NEXT,PREVIOUS}_MEDIA_ITEM buttons become previous and next
chapter with no extra code.
A very long MP4-family file plays whole (ClipBudget in MediaItems.kt).
Media3's Mp4Extractor keeps the file's whole sample table on the Java heap:
about 33 bytes per AAC frame, or about 120 MB for a 23.5 h m4b. Every clip is its
own extractor, and two are alive at each chapter boundary, since the next is
prepared while the current one plays. A long single-file m4b therefore ran out of
memory there.
A file whose two tables plus 64 MB don't fit Runtime.maxMemory() drops its clips
and plays as one whole-file item:
- For that file, the lock screen and notification have no chapter-relative scrubber and no previous or next chapter.
- The app's own chapters, the chapter list and the sleep timer's end of chapter are unchanged, since the JS side is file-based anyway.
- Other files keep their clips.
- A file of unknown duration counts as long as its clips reach (the last chapter's end).
The module's manifest sets android:largeHeap="true". On the phones measured, that
puts the line at about 41 hours of audio in one file, against about 4 without it.
The heap varies by device. Known limit: two very long files that meet at a
boundary can still overflow, since the budget counts one file's extractors.
A queue replaced by load is released first. Setting new items on a prepared
player keeps the old items' extractors alive while the new ones load, a heap peak a
small device can't hold. So load stops a prepared player before it sets the new
queue. That stop's events (an idle, the old place) are the old queue's and never reach
JS (replacing). The controller reports the stop as an idle only, with no
onIsPlayingChanged(false) for a queue that was playing, so load stops the 1 s
progress loop itself; the new queue's playing starts it again.
TimelineMapkeeps the bridge contract file-based. The JS store and iOS think in(fileIndex, positionInFile); the Android engine plays clip items.TimelineMap.fileToItemmaps a file-relative position to(clip index, clip-relative ms), anditemToFilemaps back.- The map is built from each item's own extras (
fileIndex,startInFile, the file's duration;MediaItems.entryOf). It is therefore right both for a queue the module loaded and for one the service loaded itself (the car). load,seekTo,skipToTrack, the progress loop andonMediaItemTransitionall translate through it. The reported positions and durations are indistinguishable from file mode, and the wire contract between JS and native stays file-based.- Each item's extras also carry the
BookRef, fromload'sbookor the car's play spec. One sharedMediaItems.buildQueueandtoClipItembuild the items.
- The skip buttons are custom session commands (
audiosilo.SEEK_BACKandaudiosilo.SEEK_FORWARD). They are granted inMediaSession.Callback.onConnectand run inonCustomCommandasplayer.seekBack()andseekForward().- They are not the standard
COMMAND_SEEK_BACK/FORWARD. Those map to the legacyACTION_REWINDandACTION_FAST_FORWARD, which the modern Android media UI silently ignores (dumpsys media_sessionshowedcustom actions=[]and no buttons). - They seek by the listener's skip lengths (
PlayerConfig). - Each wears the nearest of Media3's predefined
CommandButtonicons (ICON_SKIP_BACK_5/10/15/30andICON_SKIP_FORWARD_*, available since Media3 1.5.0). The app ships no drawable for them, and newer Android has no icon-less action to drop.
- They are not the standard
- Registered with
setCustomLayout, notsetMediaButtonPreferences. The slot-based preferences API capped the Media3 notification at 3 actions, because it drops the secondary slots (verified withdumpsys notification,actions=3). WithsetCustomLayout, the notification provider emits the standard[prev, play/pause, next]row automatically, from the player's available seek-to-previous and seek-to-next commands. It also emits the custom skip buttons: all 5 actions alongside the draggable chapter scrubber, verified on a Pixel (actions=5). AudiobookPlayer(aForwardingPlayer) wraps the ExoPlayer, so audiobook behavior applies wherever a command comes from: the lock screen, the notification, a headset or the JS bridge.- Auto-rewind on resume lives in its
play(), which reads the live Settings value fromPlayerConfig. prepare()resets the pause baseline, so a fresh book never inherits the previous one's pause time.- Previous and next are hidden only when there is a single media item: a chapterless single-file book, where "previous" could only restart the book.
- Auto-rewind on resume lives in its
SimpleCachekeeps clipped streaming gapless. Chapter clips of a single-file m4b re-open the same URL at each boundary. A process-lifetime 64 MB LRUSimpleCachewith aCacheDataSourcemeans those re-opens hit bytes already downloaded and the parsed container header instead of the network, so there is no audible gap (verified on a device). Localfile://sources bypass the cache.- Auth headers go only to the book's origin. A
ResolvingDataSourceinjects them at request time fromAuthHolder, which holds one bearer token per book, set on everyload. It adds them only for a request to the origin of the loaded book's tracks, never to another host that a media item or artwork URI names. The exported service also refuses playable items from any controller but the app's own (see Native integrations). - The app logo is the notification small icon
(
DefaultMediaNotificationProvider.setSmallIconwithres/drawable/ic_notification.xml). onTaskRemovedrecords a "swiped away from recents" flag in shared prefs. On foreground, the JS layer reads it throughconsumeTaskRemoved()and resets to Home, matching iOS's cold-start behavior. iOS'sconsumeTaskRemovedalways returnsfalse; it exists only for bridge parity.
Android has no deferred-seek problem: Media3's
setMediaItems(items, startIndex, startPositionMs) honors the start position
natively.
Building the queue: book-queue.ts
buildBookQueue(api, libraryId, book, chapterData?, local?, virtualChapterInterval?, transcode?)
turns a book and its /chapters response into a BookQueue { tracks, offsets, total, chapters, chapterClips, syntheticChapters }.
Track building rules (bookFileSpecs). bookFileSpecs is the single source of
truth for a book's playable files. The download engine shares it, so download order
and play order are always the same. It takes the first of these that exists:
- an explicit file list (
chapterData.files, elsebook.files), sorted byseq; - else the distinct
file_paths referenced by the chapters, in first-seen order, with durations estimated from the largest chapterendper file; - else the book's own
rel_pathas a single file.
A track URL must be a real audio file (a chapter's file_path or a
BookFile.rel_path), never a folder or book path. Streaming a folder path is what
produced the iOS MediaToolbox -12864 failures. This is invariant
#4 of the workspace golden rules.
When local is supplied (the book is downloaded), each file's track points at its
local URI instead of api.streamUrl(...), and local tracks drop the auth headers.
On web, tracks carry no headers at all, since the token is in the URL. On native
they carry api.authHeaders().
playBook decides transcode, on web only
(Web transcode negotiation). With it,
every non-local track's URL is streamUrl(…, { transcode: true }) and the track is
flagged transcoded: true. The flag is present only when set, so a direct stream's
track is exactly what it always was. A local file is never transcoded.
Other queue math that lives here:
-
chapterBookOffsetrecomputes every chapter's whole-book offset from the client's own file durations plus the in-filestart. It locates the file byfile_pathfirst, with a bounds-checkedfile_indexfallback. It deliberately ignores the server'sbook_offset, which comes back 0 for every chapter on some on-demand-indexed books. That made chapter detection resolve to the last chapter. -
buildChapterClips(specs, chapters)produces the native clip list. Android plays the clips as items; iOS uses them for its chapter lock screen.- Each chapter becomes one clip mapped to
(fileIndex, [startInFile, endInFile]). - The last chapter in each file clips "to end" (
endInFile = 0), so an inaccurate finalendcan't cut off the file's tail. - It returns
[]for 0 or 1 chapters, and the engine then plays one item per file (plain file mode). - It returns
[]if any chapter can't be mapped to a file, so a partial clip queue can never strand playback.
Returning
[]for single-file books is also the documented safety fallback if a future device regresses on gapless clips. - Each chapter becomes one clip mapped to
-
synthesizeChaptersoverlays evenly spaced virtual chapters on a long, chapterless single-file book, so previous and next chapter and the chapter-relative seek bar have somewhere to go. The interval is a user setting, 30 min by default. Synthetic chapters are computed afterchapterClipsand never fed to it, so native playback is unchanged.BookQueue.syntheticChaptersflags them. -
locate(offsets, bookPosition)andtoBookPosition(offsets, index, positionInTrack)convert between the whole-book timeline and per-track coordinates. -
chapterAtfinds the active chapter bybook_offset. -
chapterCountdownsfeeds the sleep sheet's "Or stop after" rows, with wall-clock times scaled by the playback rate (rate.tswallClockSeconds).nextChapterEndanswers the sleep timer's question "where does the next worthwhile chapter end?". Both use the samechapterEndPosition, so the list the listener picks from and the boundary a shake retargets cannot disagree.Neither assumes the chapters ascend by position.
chapterBookOffset's fallback for an out-of-rangefile_indexdegrades to 0 preceding files, so stale or duplicated metadata yields ends that are not monotonic.chapterCountdownstherefore locates the current chapter withchapterAt, andnextChapterEndtakes the nearest qualifying end rather than the first qualifying array element.
total is the largest of the book's reported duration, the summed file durations
and the furthest chapter end. Metadata with duration: 0 then degrades instead of
breaking the seek bar.
The player store (store.ts)
usePlayer (Zustand) is the only consumer of the engine. Its snapshot is
per-track, and the selectors map it to the whole-book timeline:
selectBookPositionistoBookPosition(queue.offsets, snapshot.trackIndex, snapshot.position).selectCurrentChapteroverlaysqueue.chapterson that position bybook_offset. The full player's seek bar is chapter-relative.
The actions are playBook, toggle, pause, retry, seekBook, seekInTrack,
goToTrack, skipSeconds, setRate and stop. seekInTrack and goToTrack
exist for books whose file durations are unknown, which have no reliable whole-book
timeline. Speed is clamped to 0.5–2×.
More store behaviours. Each of these has its own regression tests.
playBookdecides the web transcode flag.playBooktakes astartSpeed. Without one, a book plays at its saved speed, else at the default. That holds also when it starts at an explicit place: a chapter tap, a bookmark, Previously on. There the resume lookup that normally supplies the speed is skipped, soknownSpeedreads the cached progress and the local mirror. Otherwise the default would be saved over the book's own speed.loadingBookis thecontentKeyof a bookplayBookjust swapped in whose engine load hasn't landed.- Until the load lands, the snapshot still holds the previous book's place. Mapping that through the new queue would place the listener somewhere they have never been.
- The spoiler gates wait it out through
selectPlacedBookKey(use-listening-position.ts). Without it, switching books could reveal the new book's characters by the old book's position. adoptLoadedalso sets it while it reloads the engine in the adopted queue's order. Until then a tick still counts in the engine's old order (The engine's file order).- A download that lands meanwhile is not swapped in until the load is over
(
swapDue), so the swap starts from the new book's place (What happens during the switch).
startEngineis the play step ofplayBook,toggleandretry.- It treats
AutoplayBlockedError, a browser refusingplay()without a user gesture (a cold/playerdeep link), as a pause. It clears the play intent so the watchdog can't turn it intoerror, and settles onpaused. - Every other failure rejects as a
PlaybackStartErrorthat says why (startFailure) and carries the book'sconnectionId. The reason isofflinewhen the loaded book streams from a server known to be unreachable (isReachable; never for a downloaded book). Otherwise it is the engine's own reason (the web engine'saudio), elseunknown. playBookrejects with it, and every caller of a start shows a toast throughplayFailedToast(src/components/player/play-failed.ts). The title is the caller's, by defaultlibrary.bookActions.playFailed. The description is the reason:player.startFailed.offlinenaming the server, orplayer.startFailed.audio.unknowngets no description.toggleandretrynever reject (startLoadedEnginelogs instead), because the error state already shows Retry and its error line.
- It treats
- On the web the store registers
onRemoteSeekon the engine, so the OS media controls' seeks go throughseekInTrack. They lower the resume floor and save like any other seek. - On native it registers
onRemoteMove. The engine has already moved (the module's event), so the store callsuserMovedwith the landed whole-book position, and persists.lowerFloorTolowers the resume floor to it, andlocalMovescounts it, so a place reconcile in flight stands back. Without this, a lock-screen scrub back by more than the slip tolerance would never save. onRateChangesetsrateand persists. The rate is clamped, and only an out-of-range speed goes back to the engine.onSilenceSavedfeeds time saved, which is also flushed when playback halts.- Every
loadandswapTopasses the book (bookRefOf(nowPlaying)). adoptLoaded(book, reread)(Android) handles adopting a book the service loaded.clampRatelives inrate.ts, shared with the time-left helpers.maybeAutoDownloadCurrentasksdownload()with the'auto'origin (The end of a book).
usePlayer(selector), the hook, does not redraw for a progress tick while the app
is in the background. getState() and subscribe() stay live
(Off screen, a tick is not redrawn).
Everything else around playback sits outside the store and reads it through
selectors and usePlayer.subscribe:
what plays next,
jump undo,
drift-offs and
time left.
One important gate lives in the screens, not the store. Playback starts only
after the chapters and files query has settled, because the player screen waits
for useChapters. Starting early made multi-file books stream the folder path and
lose chapter info.
The stall-to-error watchdog
This is the part of the store that has broken most often. Its design rules, in order of importance:
-
It is armed by the play or retry action, not by interpreting engine events.
beginPlaybackAttempt()is called fromplayBook,retry, the play half oftoggle(not at the end of a download that ends early,atPartialEnd, see A download that ends early), and a playing adoption's reload (reloadAdopted). It sets two module-level flags,wantsPlayback(we intend to be playing) andstartingPlayback(an attempt is in flight), and starts aSTALL_GRACE_MS(3 s) timer. When the timer fires, the only test is "are weplaying?", so no transient state the bridge left behind can stop it from firing. If not playing, the store synthesizesstate: 'error', clears the intent, and persists progress. -
While
startingPlaybackis set, every incoming state exceptplayinganderrorcollapses toloading. On a resume or retry,service.native.tsre-emits its merged snapshot for every event, and iOS deliverstimeControlStatusand status KVO asynchronously. The store sees a jumble ofready, frozenonProgressticks carryingloading, and a spuriouspaused. (Thatpausedis an async.pausedfrom the queue rebuild that escapes the nativerebuildingguard.) Interpreting those individually failed in three separate ways:- an error that flashed, then reverted;
- a dead
readyplay button; - a spinner whose watchdog never armed, because the spurious
pausedcleared the intent.
Collapsing to
loadingshows a spinner and lets the action-armed watchdog guarantee resolution. A genuine pause from the user or the lock screen always arrives afterplaying, whenstartingPlaybackis already false, so it still reads aspaused. -
A surfaced
erroris held against everything exceptplaying. After the watchdog, or a real web or Android engine error, lands, the engine keeps re-reporting around the dead stream. iOS sends frozenloadingticks. Android sendsonPlayerError, thenSTATE_IDLEasidle, plus progress ticks.subscribedrops every incoming state exceptplayingwhile the previous state iserrorand no retry is in flight. The rule is suppress-all-but-playing, deliberately not an allow-list. Enumerating the noisy states (loading,ready,idle,paused, …) broke repeatedly, as in the Android loop of an error flash, then a spinner. A retry releases the hold (wantsPlaybackflips true), and so does a genuineplaying. -
The engines never decide
erroron iOS. iOS reports every failure shape as sustainedloading: a.faileditem, a failure to play to the end, a buffer stall. The web and Android engines may emit a realerrordirectly, with the watchdog as the backstop for a buffer that never resolves. Recovery isretry(), which reloads the queue at the known-good position, sinceplay()alone cannot revive a deadAVPlayerItem. -
One more normalization. An engine
loadingthat arrives when we do not intend to play is read aspaused. No attempt is in flight then, so no watchdog is armed. iOS reports a failed item asloadingeven while the user has the book paused, and leaving it would strand an endless spinner with no retry button.
Keep all of this in shared JS; do not re-add a per-engine native timer.
In the diagram, connecting is the store's startingPlayback window. The snapshot
the UI sees during it is loading.
Resume protection
The player enforces "never restart an in-progress book from 0" twice: once on the way in and once on the way out.
On the way in: loadInitialProgress (progress-sync.ts). It returns a
discriminated ResumeLookup that reconciles three sources by updated_at, newest
wins: the server's record, a durable local mirror, and the offline replay
queue. The result is one of:
progress: a saved position exists somewhere.playBookresumes from it and restores the saved playback speed. The exception is a finished book, which starts again at 0 (resumeStart).- That rule lives in
src/playback/book-source.ts, withbookSourceOfandlocalFromManifest.playBook,startBookInPlace,adoptLoadedand the car share the module. bookSourceOfgives a book's item, chapters and local files: the download's copy, else through the query cache.
- That rule lives in
empty: the server answered (HTTP 200) and there is no record anywhere. This is a genuinely new book, so it starts at 0.failed: the server was unreachable and there is no local record.- For a streaming book,
playBookfails safe. Instead of playing, it setsstate: 'error'and recordsresumeLookupFailed, soretry()re-runs the lookup rather than reloading at a stale 0. Starting at 0 here would restart the book, and a later save could overwrite the real position. - For a downloaded book,
failedmeans offline-first and never started, so 0 is correct.
- For a streaming book,
A lookup whose place was never checked against the server is marked unchecked
(The replay guard lists which ones). On the
PUT path, that session's saves carry the replay guard until one of them is PUT.
The mirror (writeMirror, key audiosilo.progressMirror) is written:
- on every save;
- every 15 s while a book plays, whatever the save's pace (The periodic save's pace);
- on every successful server read.
It keeps the newest record by updated_at. Unlike the replay queue, it is never
pruned on sync. It exists so a flaky resume fetch can't lose the position, after
a beta report of a book that "restarted from the beginning".
On the way out: the resumeFloor save guard (store.ts). The store keeps the
whole-book position it actually resumed from as a running high-water mark.
persist refuses to save a position more than SLIP_TOLERANCE (60 s) below that
floor.
- Only a deliberate seek or jump by the listener lowers the floor:
lowerFloorToinseekBook,seekInTrackandgoToTrack, and a nativeonRemoteMove, which is the listener's own move made outside the app. - The server is last-write-wins. Without the guard, a restart at 0 that slipped through with a fresh timestamp would overwrite real progress for good. The guard makes that write impossible.
retry()also reloads atmax(resumeFloor, currentPosition), so a transient 0 in the snapshot can't be loaded again.
Picking up another device's place (place-reconcile.ts)
A book this device holds loaded resumes from its engine's place on every play, without asking the server. A place another device saved meanwhile would therefore be skipped, then overwritten, since the server is last-write-wins.
startPlaceReconcile, started from the root layout, asks the server for the loaded
book's progress whenever the book is picked up again:
- when the app comes to the foreground, or the book's server comes back, while the book is loaded and settled;
- when the book plays again after
LONG_PAUSE_MS(60 s) of pause from any source. The store'sonPickedUpAgainreports it before that play's first save. A play reported while a load holds the store (loadingBook) is told once that hold is released (pickUpDue).
While the check is out, the store holds its saves (holdSaves, 5 s at most), so the
stale place is never written.
The reconcile moves only when the server's record meets all of these:
- another
device_idwrote it; - it is newer than anything this device's mirror knows for the book;
- it is not finished;
- it is more than
MOVE_THRESHOLD_S(30 s) from the engine's place.
A seek of the listener's own while the check was out wins (localMoveCount). The
move is seekBook, so the resume floor and the undo chip see it, and a toast
("Picked up your place from another device") offers Undo. When the move is made,
the row it moved to counts as seen. On a listening_sync server, its version is
acknowledged (ackVersion, see the next section).
This module is the only place that reconciles a loaded book. Never make toggle()
look anything up.
Offering another device's place (Sync v2)
On a server with listening_sync, the server never moves a place back silently. A
span made without seeing the stored row is diverged. The server keeps the further
place, answers the other one, and keeps it on the row as other_place
(POST /api/v1/me/listening/sync).
The player offers such a place and never moves to it by itself.
offerOf(row, deviceId, ackVersion, enginePosition, answered) (pure) finds it, in
order:
- the kept row itself, when another device wrote it at a version above this
device's acknowledged one (
ack_version), the book isn't finished, it is more thanMOVE_THRESHOLD_S(30 s) from here, and it is not answered yet; - else the row's
other_place, when it is another device's, more than 30 s from here, and not answered yet. The mirror keeps the answereddevice_id|updated_atkeys per book (answered).
offerLoadedPlace shows the offer for the loaded book as a toast, On {device} you were at {place}. Jump there?. describePlace gives the chapter and the time into
it, or "another device" when the server has no name. The toast has two answers:
- Jump acknowledges the row, then calls
seekBookthere. The next span is ajump, ormoved_backwhen the place is behind. - Stay, the close button, acknowledges, then calls
standByPlace. The next span begins as ajump, so the server applies this device's place over the other.
A toast that times out acknowledges nothing. It only notes the place's key
(ignoreOffer), so the same place is not offered again while this device's next
spans still never move the other place back.
The player checks for an offer:
- on each sync answer for the loaded book (
onSyncAnswer), never moving a playing book; - on the foreground, reconnect and picked-up checks above, where
placeToMoveTofinds nothing to move to.
Each place is shown once per launch, and a device's places at most once per book
every 10 minutes (OFFER_REPEAT_MS), since a device that plays on sends a new
place with every span. The book page shows the same offer as a line
(ListeningOfferLine, Book page),
on a server with the flag only.
On a server without the flag, the auto-move with Undo above stays. Its only offer is
the loaded book's toast for a save the
replay guard held (onHeldSave,
offerLoadedPlace(..., { held: true })). There Stay also lifts the guard for the
session and saves this device's place at once (saveLoadedPlace). That save and the
session's later ones go through even when the other device has saved again since the
offer. A timed-out toast lifts nothing. The book page shows no line there.
Listening spans (Sync v2)
On a server with listening_sync, the place reaches the server as listening
events instead of saves, and the capture only keeps this device's mirror.
usesListeningSync decides which path applies. It reads the flag from the cached
/server answer, which a launch seeds from
the kept /server answer.
While the flag is unknown, the player sends events too (see
the outbox).
A span runs from where playing began to where it ended. It carries:
- both ends in whole-book seconds and, for each end, the playing track's audio file (from its track id) and the seconds into it;
- the audio's length (
queue.audio, not the timeline's, because the server fingerprints it). A queue over a download that ends early sends the book's length on its server instead, or none when that is not known (bookAudioOf, see A download that ends early); - the speed and the device's times;
- why it began (
start); - the version this device has acknowledged for the book (
base_version), read when the span closes.baseAtClosetakes the higher of the one it opened with and the one now.
A span's life:
- Opens when the engine starts playing.
startsays why:resume: the loaded or resumed place;jumpormoved_back: a seek, a chapter, a bookmark, Undo, an offer's Jump or Stay, a lock-screen scrub;restart: starting again from 0 (a finished book first sendsunfinish);continue: after a cut.
- Cut: the span closes, and a
continuespan opens where it ended. A cut happens:- on each tick of the save loop while the server is reachable, every 15 s or every 60 s (The periodic save's pace). The cut is what keeps a live session going;
- on a speed change;
- when the server comes back;
- when the app goes to the background (native) or the web page goes
hidden.
- Closed on a pause, a move, a stop, a book switch, a sleep stop and the end of
the book.
- After a move, the new place opens the next span with the move's reason. A move while paused is sent at once as a span that moved nowhere, as a save would have been.
- The end of the book then sends
finishonce. The finish goes into the outbox only after the last span's close has landed there, including a close already on its way. (The web reportspausedjust beforeended;closesInFlightholds that close, and the finish takes over its send.) One send then follows both (finishAfter): one POST carrieslistenthenfinish, so the server has the book's session before its finish. - The book page's Mark as finished goes the same way (
recordFinishEventinfinish-route.ts). - Either finish is dated after its span's end is stamped (
takeSpan). The server applies a batch in time order, with request order only breaking ties, so a finish dated earlier would be applied before the book's last span. - The web's
pagehidecloses the span and sends what waits withkeepalive.
- Offline, the tick leaves the span open until it is an hour long
(
MAX_SPAN_MS), so a day offline is a handful of events. Offline means the server is marked unreachable (Reachability), which on a phone follows the system's network. - The open span is stored every 5 s (
OPEN_SPAN_KEEP_MS), online or offline, with the event id it took when it opened (OpenSpan.id), so a span closed twice is one event.- A kill therefore loses at most 5 s. The next launch closes the stored span into
the outbox (
recoverOpenSpan) and brings this device's own record of the book up to its end (What a kill leaves behind). - The keep never moves the span's end back, since a native lock-screen move's landed place reaches the store before the move does. A save and the loop's cut take where the engine is.
- An event never claims more than 23 h of wall clock (
MAX_EVENT_SPAN_MS), for timers that stood still while the app slept.
- A kill therefore loses at most 5 s. The next launch closes the stored span into
the outbox (
- A play with no span open gets one at the 5 s keep (
openMissingSpan). A play's own span cannot open while its place is held by a load, the hot-swap onto a download or an adoption's reload. The keeper opens it, since the save loop's tick may be a minute away and what plays before a span opens is in no listening event. A play on its way to a pause opens none. - The persist guards (Resume protection) keep a suspicious place out of a span too. The span then ends where it was last seen. A place that is not a number (an engine reporting NaN) never goes in a span.
The events go to the outbox and are sent from there
(Offline). On this path the player posts no
history spans, because the server writes history from the listening, and saves no
PUT.
One tab plays at a time (web)
Two tabs of the web player are one device to the server. They share its device id
and the listening outbox. If they played at once, their seconds would be recorded
twice and the saved place would go back and forth. So one tab plays at a time per
connection, and the newest play wins (src/playback/playing-claim.ts).
None of this runs in the apps, and it holds on any server.
The channel (tabChannel, src/lib/tab-channel.ts) carries messages between
the tabs of one browser profile on one origin. A tab never hears its own messages.
BroadcastChannelwhere the browser has it. It needs no secure context, so a plain-http address has it too.- Else
storageevents: the message is written tolocalStorageunderaudiosilo-tab:<name>and removed at once, and every other tab is told of the write. The message must be JSON. - Null off the web, or in a browser with neither.
The claims and their answers go over the channel named audiosilo-playing
(PLAYING_CHANNEL).
The claim (PlayingClaim) has four fields:
| Field | Holds |
|---|---|
connectionId | the connection of the loaded book |
tab | the tab's id for claims, a random id made when the tab first opens the channel |
askedAt | when the play was asked for (playAskedAt): a press here (beginPlaybackAttempt), else the moment the engine began by itself (the OS media controls) |
sentAt | when the tab began playing, which is when the claim was made |
The store announces a claim on every move into playing, before the listening
span opens (claimPlaying in store.ts).
Who gives way (givesWayTo, pure). A tab gives way to another tab's claim
when all of these hold:
- it plays, or means to (
wantsPlayback: a play asked for and still starting); - its loaded book is on the claim's connection;
- the claim's play was asked for later than its own. In the same millisecond the greater tab id wins.
"Newest" is when the play was asked for, not which claim arrives last. A tab still loading whose play is the newer one keeps going, and the other tab gives way once it hears that tab's claim. Of two tabs that start together exactly one stands. The rule is per connection, not per book, so two connections may play side by side. A tab that is not playing does nothing visible.
The tab that gives way (giveWayTo in store.ts) does six things:
- It saves the place it gives way at, and notes that another tab plays (The tabs stay at one place). It saves nothing of its own after that. A tab that was itself behind another tab's play (the note already standing, or a move to the shared place still out) saves nothing here either.
- It notes the play request that gave way (
yieldedAttempt). A start or a Retry still loading then does not play once it has loaded (startEngine, the one place every start passes, returns false). - It closes its listening span there and then, dated no later than the claim's
sentAt, so two tabs never record the same seconds. The pause that follows finds no span left, so the span is one event. - It answers the claim with the place its span ended at (The tab that gives way says where it stopped).
- It drops the play intent and pauses the engine, whatever the snapshot reads.
A play asked for a moment ago, whose first engine event has not arrived, must
not go on to
playing. With the intent dropped it would read as a new play and take the connection back. - It tells the listener (
onGaveWayToTab).
The tab keeps its book, and a play there claims the connection back.
A tab whose stream died (error, no Retry out) is not playing, so it gives way to
nothing and shows no notice. Its engine is paused all the same, because a stream
that came back by itself would read as a new play and take the connection back.
The notice (startOtherTabNotice in other-tab-notice.ts, one of the root's
services) is a toast, "Playing in another tab" (player.otherTab.title).
- It is shown once the tab is looked at (
whenActive), since the tab is usually hidden behind the one that plays. - It is one notice, however often the tab gave way while hidden.
- A tab that was hidden and plays again by the time it is looked at shows none.
- On the web the root's services start after the first paint. The store keeps a
give-way nobody was there to hear (
gaveWayUntold) until the service registers.
The tabs stay at one place
Two tabs are never at two places of one book (owner decision). A tab that plays after another tab played the same book first moves to the place the tabs share.
The note (otherTabPlayed). A tab with a book of the claim's connection
loaded notes the claim when it gave way to it, or was not playing (paused by
hand, a dead stream). The note keeps the time of the earliest such claim, since
the tab's place may be behind from then.
- While the note stands, the tab saves nothing of its own place
(
behindOtherTabinpersist). A finish still saves. - The reason: a paused tab can save a place dated now (a speed change, the place
reconcile's flush on the foreground). That save would be the newest record in
the mirror the tabs share, over the other tab's. On a server without
listening_syncitsPUTwould move the server's place back too. - The note is cleared when the tab plays (
claimPlaying: the others follow it from here). - A move of the listener's own clears it too (
userMoved). A seek in the paused tab stands, and every book switch is such a move.
A play is noted once (notedPlay: the claim's tab and askedAt). A tab
claims on every move into playing, so a play that goes on claims again with the
askedAt it began with. That happens at the next file of a multi-file book, after
a stall it came back from, and when a transcoded stream is requested again at
&t=. A tab that already noted that play leaves its note as it stands, so a seek
the listener made there since is still the place it plays from. Only a new play
(a press, the OS media controls) is noted again, over a seek made since.
switchBook forgets the play. A book loaded after the claim resumed from the
shared place as of its load, so it notes the play's next claim and follows it.
The shared place (otherTabsPlace) is the mirror's record of the loaded
book: what a fresh load resumes from, by its file when the queue holds it. The
tab takes it when both of these hold:
- this browser's
device_idsaved it, at or after the noted claim; - it is more than 2 s (
SAME_PLACE_S) from the engine's place, earlier or later. The other tab is the latest listening, so the tab moves back as well as forward.
Another book played in the other tab leaves this book's record as it was, so nothing moves. Another device's record is left to the place reconcile.
| The tab | Takes the shared place |
|---|---|
is played by a press (toggle) | before the engine starts (catchUpWithOtherTab) |
is retried (retry) | by reloading there |
began playing by itself (the OS media controls, !wantsPlayback at playing) | as it plays (catchUpPlaying). No span or history opens, and nothing is saved, until it has moved (catchingUp) |
| is paused and brought to the front | at once, still paused (showOtherTabsPlace, on the foreground from other-tab-notice.ts). The note stays, so the play that follows reads the place again |
The move (moveToTabPlace) is the engine's seek, not seekBook:
adoptTabPlacesets the resume floor to the place, and the next span begins there as aresume.- No
jumpormoved_backevent is sent. The server is never told that the listener chose the place the tab was at. - The Undo chip follows its own rule. It
reads the snapshot stream, so a move of more than a minute offers the way
back. That Undo is the listener's own
seekBook. - A book the other tab finished starts again from 0, as a
restartwith theunfinishowed, as a fresh load of a finished book does. Not when this tab is at the end itself, and not in a paused tab brought to the front, which stays where it is. - A seek the engine refuses puts back what
adoptTabPlaceset. The play goes on from where the engine is, because a floor left at a place far ahead would refuse its every save and span. - A book switch drops a move still out (
switchBookclearscatchingUp), so it holds none of the next book's saves or spans.
The tab that gives way says where it stopped
The mirror holds the other tab's last record of its place. A playing tab writes
it every 15 s, hidden or playing a download too (placeLoop,
The periodic save's pace). Only what such a tab sends
the server slows, to every 60 s. So a tab that takes over from one still playing
starts up to 15 s behind it. A paused tab saved as it paused, so its record is its
exact place. A press is never kept waiting for more. The tab plays at once, and
moves when it hears where the other tab stopped.
The answer (GaveWay in playing-claim.ts, sent by tabClaims().answer on
the same channel, over both transports) is told from a claim by its kind:
| Field | Holds |
|---|---|
kind | 'gave-way' (a claim has none) |
connectionId, libraryId, path | the book the tab was playing |
position | where its span ended, in whole-book seconds |
file, filePos | the playing file and the seconds into it, when known |
finished | whether that place is the book's end |
tab | the tab that gave way |
to, askedAt | the play it answers: the tab that claimed and when that play was asked for |
Only a tab whose own place was the latest answers. A tab that was itself behind
another tab's play sends nothing: one with the note standing, or one still moving
to the shared place (catchingUp).
An answer belongs to a play, not to one claim message. A tab claims on every move
into playing, so one play can claim twice a few milliseconds apart, and the
answer to the first claim can arrive after the second. The tab that gives way
answers a play once (it sends nothing for a play it already noted, notedPlay).
The tab that claimed matches the answer on to and askedAt against its own
play (ownPlay), which a second claim of the same play leaves untouched.
The tab that claimed (onGaveWayAnswer) moves there by moveToTabPlace,
the same path as the mirror's place:
- It drops the listening it began at the older place (
dropListening: the open span, stored or not, and the history's start), with no event. - It begins again at the place taken, as a
resume. - It saves the place at once.
So the spans are one chain (one tab's begins where the other's ended), and the
saved place never goes back. The answer is read against the place the play began
at (ownPlay.pos, moved with each tab place taken), not where it has played to
since.
An answer is ignored when it:
- answers an older play than the tab's current one, or comes more than 5 s
(
GAVE_WAY_ANSWER_MS) after that play's first claim; - is for another connection or another book, or comes while a book loads;
- would move a place the listener chose here (
placeChosen: an explicit start or a seek, until another tab plays); - comes when yet another tab plays by now;
- is within 2 s of the place the play began at;
- says the book was finished. A book is not started again under a listener hearing it, and the next resume reads the save.
A play that began by itself is still moving to the mirror's place when the answer
comes, so the answer waits for that move (catchingUp). The web engine places a
seek made before its metadata where the load starts (placeAt), so an answer
during a load lands there.
Known limits
- A tab on a build before the rule (one left open across an update) neither claims nor gives way, and sends no answer.
- Two windows both in view never get a foreground event. The paused one shows the place it stopped at until it plays, and the play takes the other's place exactly.
- After a seek the engine refused (its
seekToorskipToTrackrejected), the tab plays and saves from where it is, not from the shared place. - An Undo of a finish can wait on another tab's mark (Offline).
- The channel reaches one browser profile on one origin. Another browser, a private window or the same server at another address is another device, and is not paused.
What the tabs share of the listening outbox, an Undo of a finish included, is in Offline.
Progress autosave and sync triggers
These are the saves on a server without listening_sync. A server with it gets
listening spans instead; the loops are the same.
-
A periodic save loop (
saveLoop, every 15 s or every 60 s, see The periodic save's pace) runs only while actually playing. The engine'splayingtransition starts it, andpaused,endedorerrorstops it (throughhaltAndPersist). That covers lock-screen pauses and books that finish without astop()call. -
More saves fire on every seek (
seekBook,seekInTrack,goToTrack), onsetRateand onstop. -
Saves go through
saveProgress(progress-sync.ts): the mirror first, then the network if reachable, else the offline replay queue (latest save per book).finishedis set within 5 s of the end.- Each save carries
version: 0, a per-installdevice_idand a capture-timeupdated_at. The server's last-write-wins reconciliation then orders saves correctly, offline replays included.
Details are in State & data.
-
Listening history spans are recorded around the same transitions. A span opens on
playingand closes on leavingplaying, or on a track change mid-playback, so each file logs as it finishes. Spans under 20 s are ignored, and none is recorded while the server is unreachable. -
On the web, the history span is also written as the page goes (
watchPageHide,flushHistoryOnHide), posted with akeepaliverequest that outlives the page:pagehide(the tab closed, reloaded or navigated off) ends the span;- the document going
hiddensplits the span once it is long enough to keep, and a new span opens while playback goes on. This covers a phone's browser, which may close a tab with nopagehide.
Ending a span clears it first, so it is never posted twice. Every span on the web goes as a
keepaliverequest, because a hard navigation mid-play pauses the audio beforepagehide, and the span that pause ended would otherwise be aborted with the page. Only the hide path skips the history refresh. -
When playback halts, the store invalidates the
['progress', 'all']query, so the Home and Browse "continue listening" and "finished" lists re-read from the server. It does not invalidate on every periodic save.
One more store responsibility: when a download completes for the book that is
currently streaming, the store hot-swaps playback onto the local files
(switchCurrentBookToLocal, preferring the engine's gapless swapTo).
Offline covers it.
The periodic save's pace
Three loops run while a book plays (startSaveLoop in store.ts). Each is an
engineTicker (engine-ticks.ts), so the engine's own events keep it going where
Android has paused the JS timers.
| Loop | Every | Does | Uses the network |
|---|---|---|---|
saveLoop | 15 s or 60 s (saveIntervalMs) | the save: the legacy PUT, or on a listening_sync server the span's cut and its send | yes |
placeLoop | 15 s, always | writes this device's own record of the place, the mirror (persist({ local: true })) | no |
spanKeeper | 5 s (OPEN_SPAN_KEEP_MS) | stores the open span, and opens one for a play that has none | no |
saveIntervalMs answers 15 s while the app is in front and the playing file
streams from the server. It answers 60 s otherwise:
- The app is away (
isAwayinsrc/lib/app-activity.ts):AppStateisbackgroundorinactive. That is a locked phone, another app in front, iOS's app switcher or a system alert, and on the web a hidden tab. - The playing file is one this device holds (
PlaybackTrack.local, set bybuildBookQueuefor a download's file), with the app in front too.
Streaming in front, the radio is up for the stream anyway. In every other case each save would wake it for itself, 240 requests an hour. A Cast receiver streams whatever the phone holds, so a downloaded book cast with the app in front keeps 15 s. Away, casting is 60 s like everything else. The watch app keeps 15 s throughout.
The pace is the network's alone. The mirror and the open span are storage writes, so slowing them would save no radio and only leave a kill further behind.
- On a
listening_syncserverpersistonly ever writes the mirror, soplaceLoopkeeps it at 15 s whatever the cut's pace. - On the
PUTpath thelocalcapture writes the mirror and sends nothing. While the server is known to be away it goes throughsaveProgresswhole, which sends nothing either and queues the save, so the queue holds the place the mirror does. - A save writes the mirror itself and re-bases
placeLoop, and a save due at the momentplaceLoopis goes in its place. The two never write the mirror twice for one tick, and at the 15 s paceplaceLoopnever writes. - Every guard of
persistholds for the local capture too.
A new periodic job follows the same split. A request goes on saveLoop; a storage
write does not.
The pace is read at every check, not set by an event. The next save is due that
long after the last one. A change mid-play therefore drops no save and doubles
none, whether it is the hot-swap onto a download, a next file of the other kind, or
the app leaving or coming back. Two edges do act (onAwayChange, told once per change by onAppStateEdge,
so iOS's inactive then background is one leaving):
- Leaving the front, the place is saved at once and the loops are re-based, so
the save the 15 s pace owed never waits a minute. A listening span is cut as the
app leaves the front, iOS's
inactiveincluded, or the page hides (Listening spans). - Coming back, a save the 15 s pace already owes goes at once.
Neither does anything while nothing plays. Nothing else re-bases a running loop:
loading does not stop it, so a play back from a buffer leaves the next save due
from the last one. Otherwise a book that buffers more often than the period would
never save.
Only the periodic save slows. A pause, a move, a speed change, a stop, the end
of the book, a tab giving way and the app leaving the front still save at once. On
a listening_sync server so does a page closing, which closes the span and sends
it. On the PUT path a closing page sends no PUT of its own, only its history
span. What waits up to a minute is what the server holds, and so what other
devices, the console and Listening in the house see.
The server reads a session as playing for 60 s after its last save or span
(livePlaying, how sessions are derived).
The 60 s pace sits on that window, so a book played in a pocket can read as paused
for a moment before its next save lands. This lasts until the server widens the
window. The server's resume_behind backstop is not
touched by the pace.
What a kill leaves behind
The worst case when the OS or the listener kills the app mid-play, in playing time. Multiply by the speed for book seconds.
| Where | Holds | Behind by at most |
|---|---|---|
This device, a listening_sync server | the open span, once it is recovered: at the next launch in the app, later on the web | 5 s |
| This device, a play that starts before the span is recovered | the mirror | 15 s |
This device, a server without listening_sync | the mirror | 15 s |
Other devices, a listening_sync server | the last cut, until this device launches again, recovers the span and sends it | 60 s, then 5 s |
Other devices, a server without listening_sync | the last PUT, until this device saves again | 60 s |
At the next launch recoverOpenSpan closes the stored span into the outbox, and
mirrorSpanEnd (progress-sync.ts) brings the mirror's record of the book up to
the span's end. An offline start then resumes there. Online, the span reaches the
server with the launch's send and the resume reads it back.
mirrorSpanEnd treats the span's end as the capture it would have been when the
span was last kept, under the mirror's own rule that the newest record wins:
- only a record older than the span's end is changed, and only forward;
- never a finished book's record, and never a book with no record;
- never finished at the end of a download that ends early (
ProgressSave.partial, ordownloadEndsBeforeunless the span ended in the book's last file; see A download that ends early); - so a place written since stands: the listener's own move, another tab's play, a server row.
On the web it also runs for a span another tab left behind, dated when that tab kept it.
On the web the recovery can wait 30 s
In the app every span a launch finds is a previous launch's, so it is recovered at
once. A web page cannot tell a crashed tab's span from one a live tab still keeps
until it has gone unkept for 30 s (LIVE_SPAN_MS in listening-outbox.ts,
the stored span).
- A page opened after those 30 s recovers the span as it launches.
- A page opened sooner leaves the span alone and looks again 30 s later. Then it closes the span, brings the mirror up and sends the event.
- Until then this device resumes from the mirror, up to 15 s behind, and other devices hold the last cut, up to 60 s behind.
Web transcode negotiation (transcode.ts)
Browsers can't decode some codecs a book may use (AC-3, E-AC-3, ALAC...), nor the
Matroska container (.mka, indexed from server 2.4.0). The server marks those books
direct_playable: false. When it has ffmpeg (capability transcode), it streams
them re-encoded to MP3 with ?transcode=1&t=<seconds>
(cross-repo contract §5).
The exception is an .mka that holds MP3, on a server with ffmpeg. The server
remuxes it to MP3 at the
same URL and reports true, so it plays like any MP3: Range seeks, no &t=, and
browser download allowed.
The web player negotiates all this by itself. Native engines never transcode, because they rely on the platform's own decoders (there is no FFmpeg extension on Android). A downloaded (local) file is never transcoded either.
The rule is pure and lives in src/playback/transcode.ts:
isBrowserUndecodable(book, chapterData?): only an explicitdirect_playable === falsecounts. The chapters response is preferred, since it is fetched fresh for playback. An older server omits the field and an unprobed codec reads as playable; both stream exactly as before.needsWebTranscode(book, chapterData, canTranscode)holds when the platform is web (Platform.OS, read inside throughmayNeedWebTranscode), the capability istrue, and the book is undecodable. An unknown capability (/servernot loaded) reads as no. The player then streams directly, and the error and retry path speaks up if the browser can't play it.
Reading the capability lives in src/playback/transcode-capability.ts, kept
apart so the rule stays framework-free. The pieces:
mayNeedWebTranscode(intranscode.ts) is the cheap synchronous pre-check. It is false off the web and for every ordinary book, so those skip the lookup and its await.resolveWebTranscodereadscapabilities.transcodethroughfetchCapabilities(api/hooks.ts). That is the shared/serverentry viafetchFailFast, normally a cache hit. A failed read falls back to the cached flags, else "no".webTranscodeFromCacheis the synchronous form, for the download path.useNeedsWebTranscodeis the UI's form, for the book page's note, the download controls and the Downloads page's failed row.
Where it's decided. playBook decides once, before buildBookQueue, and only
for a streaming book (!local && mayNeedWebTranscode(…) && await resolveWebTranscode(…)).
The queue then carries transcoded tracks, so every later reload (retry, seeks)
reuses them without asking again.
The web engine (service.web.ts) is where the work is. Transcoded output has no
byte ranges and no length the <audio> element can know (duration reads
Infinity or NaN), so:
sourceFor(track, positionInTrack)(pure, exported) returns the element's source and the offset itscurrentTime0 stands for. A direct stream is its own URL at offset 0. A transcoded one is requested at the position, with thattas the offset.transcodeUrlAt(url, t)replaces anytand keeps every other param, the media token included.- A transcoded stream is never requested inside its last second
(
transcodeStartAt). There is nothing left to encode there, and the element errors instead of ending. - Every seek, every auto-rewind, and every resume after a pause longer than
TRANSCODE_STALE_PAUSE_MS(30 s) goes throughreloadTranscodedAt, which re-runsloadTrackat the target. After such a pause, the server or a proxy may have dropped the paused, back-pressured transcode.loadSeqmakes a staleloadedmetadatahandler do nothing, when a second seek comes before the first source loaded.pendingAutoplaykeeps the play intent across theloadingwindow, which now recurs on every seek.
- The snapshot's
positionstays track-absolute.transcodedTrackPositionreturnscurrentTime + offset, clamped to the queue's known file duration. The encoder can run a frame past it, which would read as the next file's first instant. The duration always comes from the queue (track.duration), never the element. - The rate is applied as
defaultPlaybackRateandplaybackRate(applyRate). The media element load algorithm resetsplaybackRateon everysrcchange, and a transcoded seek is one. - An
endedwell before the known end is the connection dying, which an unsized stream reports as a normal end.isEarlyTranscodeEndcatches an end more thanEARLY_END_SLACK_S(10 s) short. The engine then reloads at the position instead of skipping the rest of the file. A second early end withoutEARLY_END_MIN_PROGRESS_S(5 s) of progress counts as the real end: a file whose audio is shorter than its probed duration. - The Media Session gets a
seektohandler for transcoded tracks only; direct streams keep the browser default. The handler goes through the store's seek, so the OS scrubber re-requests too. The engine also sets an explicitsetPositionStatewith the track-absolute position and known duration, and clears it again once a direct track plays.
Everything above the engine assumes snapshot.position is the position in the
file: selectBookPosition (toBookPosition(offsets, trackIndex, position)),
chapter detection, the resumeFloor save guard, progress saves, the sleep timer's
chapter targets and jump-undo's detector. If a transcoded seek let currentTime
restart at 0, every one of them would see the book jump backwards on each seek. The
save guard would refuse the saves, the chapter would read as the first, and a jump
chip would appear. Folding the offset in at the engine keeps the transcoder
invisible to the store.
Downloads. A book that streams transcoded on the web must not be downloaded raw, since its files would not play offline in that browser.
useDownloads.download()refuses it (webTranscodeFromCache). That covers every path: the book page, the automatic download and keep-ahead.useDownloadControlsexposesneedsTranscode, so the control says "Can't download in this browser" instead of the generic "Downloads unavailable".- A download already on disk stays removable. A failed one is blocked like a new one: only the Downloads page can clear it, with no Retry (see Offline).
The book page shows TranscodeNote (src/components/library/transcode-note.tsx)
under the stats ("AC-3 audio is converted to MP3 for this browser"). It shows
exactly when needsWebTranscode holds, and never for a downloaded book.
codecLabel maps ffprobe names (ac3 to AC-3, wmav2 to WMA...) and falls back to
upper case.
Elsewhere
- The end of a book: finishing a book, what plays next, the automatic download of the book you start.
- Podcasts: an episode not on its server yet starts through the
episode-fetch controller (
src/playback/episode-fetch.ts), neverplayBook;NowPlaying.episode(a view field fromnowPlayingOf) marks an episode for the cast, credits and download rules. - The sleep timer: the timer, drift-offs, the grace card.
- Player UI: jump undo, time left, the companion, the controls, sheets, and the compact and full players.
- Smart speed and Voice boost: the audio effects on every engine, and time saved.
- Native integrations: CarPlay, Android Auto, the widgets and the sleep timer Live Activity.