Skip to content

Three fixes for the cast path - #2

Merged
hypnotoad08 merged 3 commits into
hypnotoad08:feature/Media3-Conversionfrom
huggenknubbel:media3-cast-fixes
Sep 20, 2026
Merged

hypnotoad08 merged 3 commits into
hypnotoad08:feature/Media3-Conversionfrom
huggenknubbel:media3-cast-fixes

Conversation

@huggenknubbel

@huggenknubbel huggenknubbel commented Sep 12, 2026 •

Copy link
Copy Markdown

Three fixes for the cast path, found while testing the media3Debug flavor on a
Chromecast audio group. Each one is a separate commit on top of b34814d, and
each is backed by a measurement on the device rather than by reasoning alone.

Casting stops when the app is swiped away

Media3PlaybackService.onTaskRemoved closes playback whenever a session is held,
without looking at where it plays, so the receiver stops within a second:

22:50:23.346  onRecentTaskRemoved
22:50:23.954  ApiHandler: closePlaybackSession
22:50:23.961  Media3PlaybackService: Playback stopped. Syncing progress.
22:50:24.206  RemoteCastPlayer: Release
22:50:24.210  Device changed: playbackType=0, isCast=false
22:50:24.243  onSessionEnded: castStatusCode=SUCCESS
22:50:24.601  Process com.audiobookshelf.app.media3.debug has died

castStatusCode=SUCCESS is the point: the session is ended deliberately, not
lost. The final-sync work on this branch makes that shutdown clean, but it still
shuts down - swiping the app away is not a reason to stop a receiver that plays
on its own.

The check has to come before super.onTaskRemoved, which stops the service
itself when the player is not playing locally - and while casting it never is:

override fun onTaskRemoved(rootIntent: Intent?) {
  if (isCastActive && currentPlaybackSession != null) return
  super.onTaskRemoved(rootIntent)
  ...
}

With the guard in place the same swipe leaves the service running and playback
continued for another 25 minutes until I stopped it myself.

Crash when seeking with an empty queue

java.lang.IllegalArgumentException: Cannot coerce value to an empty range:
  maximum -1 is less than minimum 0
  at PlaybackController.seekTo

session.getCurrentTrackIndex().coerceIn(0, controller.mediaItemCount - 1)
throws while the controller holds no items, which it does for a few seconds
after joining a receiver session. Falling back to the position-only seek that is
already there for a session without tracks avoids it:

if (audioTracks.isEmpty() || controller.mediaItemCount == 0) {
  controller.seekTo(clampedPositionMs)
  return
}

The window is not exotic: the Cast SDK rejoins a running receiver on its own
(Connected to device. rejoinedApp: true), and the queue arrives from the
receiver a moment later, so any seek in between hits an empty controller.

Nothing reaches the server after the SDK rejoins a receiver

Cast a book, swipe the app away, open it again while the receiver keeps playing.
The Cast SDK rejoins on its own and playback is controllable, so this looks fine

  • but no progress or listening time is written any more:
08:28:20.351  Connected to device. rejoinedApp: true
08:28:20.971  Device changed: playbackType=1, isCast=true
08:28:30.543  PlayerListener: Notifying web app - isPlaying=true
              ... no /api/session/<id>/sync at all, over two minutes

The player is back but currentPlaybackSession is null, so every branch guarded
by it is skipped - not one Emit event in the whole run. The rejoin happens
below the app: the SDK restores the connection and CastPlayer fills its queue
from the receiver, but nothing maps that back to a session this app knows.

The queue is enough to identify it. PlaybackSessionMediaAdapter writes media
ids as "<sessionId>_<stableId>", and the default MediaItemConverter carries
them through the receiver, so the returned queue names the session.
PlaybackPositionModel.writeBackToSession already checks exactly that and
computes the position with the track offset, so the whole thing is:

override fun tryAdoptReceiverSession() {
  if (currentPlaybackSession != null || !isCastActive) return
  val saved = DeviceManager.getLastPlaybackSession() ?: return
  if (DeviceManager.serverConnectionConfigId != saved.serverConnectionConfigId) return
  PlaybackPositionModel(saved, player).writeBackToSession() ?: return

  switchPlaybackSession(saved, syncPreviousSession = false)
  startProgressSyncIfPlaying(saved)
}

Called from onEvents on EVENT_TIMELINE_CHANGED, which is when the queue
arrives. Nothing is pushed back to the receiver - reloading the queue there
would restart playback - and the server check keeps a session from another
server out. With it in place the same test gives:

09:22:57.351  Connected to device. rejoinedApp: true
09:22:58.078  tryAdoptReceiverSession: Adopting the session playing on the receiver
09:23:17     currentTime: 10853.931
09:23:32     currentTime: 10869.051
09:23:47     currentTime: 10884.175

How this was tested

On a Pixel 10 Pro XL, GrapheneOS with sandboxed Play Services, casting to a
Chromecast audio group. No automated tests - what is written above is what I
measured on the device:

  • Swipe-away, both ways. Without the guard the receiver stops within a
    second (castStatusCode=SUCCESS); with it the service survives and playback
    ran on for another 25 minutes. Verified that the build under test really did
    or did not contain the guard, by looking for its log string in the dex.
  • Adoption on a later track. Killed the process while casting at 10788s into
    a book, reopened 50s later: adopted 334ms after rejoinedApp: true, and the
    first sync afterwards carried 10853s - 70s of wall clock, 65s of position, so
    the track offset came out right.
  • A finished receiver session is left alone. Force-stopped the app while the
    cast session was paused, which ends the receiver application
    (onApplicationDisconnected: castStatusCode=SUCCESS). The app came back local,
    never rejoined, and adopted nothing over the next four minutes. Same result
    bringing the app to the foreground three times with no receiver playing.
  • Unattended run. 10h32m off charger with all three in place: 2472 progress
    syncs, every one successful, longest gap between syncs 31s, book position
    tracking wall clock at 0.997.

Happy to change any of this, or to drop a commit if you would rather solve it
differently.

The clamp on getCurrentTrackIndex throws while the controller holds no items,
which it does for a moment after joining a receiver session. Falling back to the
position-only seek already used for a session without tracks avoids it.
Playback on a cast device carries on without this app, so removing the task must
not close the session. The check has to come before super.onTaskRemoved, which
stops the service itself when the player is not playing locally.
The Cast SDK rejoins a running receiver, but the app never learns which of its
sessions plays there, so no progress reaches the server. The queue returns with
the media ids this app wrote, and those start with the playback session id, so
the receiver names the session and the stored one only has to agree.
@hypnotoad08
hypnotoad08 merged commit 86726b7 into hypnotoad08:feature/Media3-Conversion Sep 20, 2026
1 check passed
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.

2 participants