Skip to content

Fix dead skip-unavailable-track logic - #493

Closed
skorokithakis wants to merge 4 commits into
GrakovNe:mainfrom
skorokithakis:fix/skip-unavailable-tracks
Closed

Fix dead skip-unavailable-track logic#493
skorokithakis wants to merge 4 commits into
GrakovNe:mainfrom
skorokithakis:fix/skip-unavailable-tracks

Conversation

@skorokithakis

@skorokithakis skorokithakis commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The LLM flagged this and I figured I'd clean up. Details follow:

The skip-unavailable-track handling in PlaybackNavigationService.onPositionDiscontinuity never ran. Two independent reasons:

1. The DetailedItem tag never existed. PlaybackService.bookToChapterMediaItems calls .setTag(book) but never setUri. In Media3, Builder.setTag only writes into LocalConfiguration, and build() creates one only when uri != null:

if (uri != null) { localConfiguration = new LocalConfiguration(uri, ..., tag, ...); }

So the tag was discarded at construction and currentMediaItem?.localConfiguration?.tag as? DetailedItem was always null, returning early every time.

2. LissenMediaSourceFactory also dropped mediaId and requestMetadata. BasePlayer.getCurrentMediaItem() and getMediaItemAt(i) both read getCurrentTimeline().getWindow(i, window).mediaItem, which comes from the MediaSource the factory returned, not from the item passed to setMediaItems. ClippingMediaSource delegates getMediaItem() to its child, and ConcatenatingMediaSource2's window uses the item from Builder.setMediaItem(). The factory forwarded only mediaMetadata, so mediaId and the FILE_SEGMENTS extras were lost, and isTrackAvailable returned false for every track.

The existing mediaMetadata forwarding is what kept PlaybackSynchronizationService.getProgress working via CHAPTER_START_MS, which is good evidence this behaviour was already hit once empirically.

Both parts have to be fixed together. Restoring only the tag would let the guard pass while isTrackAvailable still reported everything unavailable, so findAvailableTrackIndex would return null and the player would pause() + cancelSynchronization() on every chapter transition.

Changes

  • LissenMediaSourceFactory carries the chapter item's identity onto the sources it builds: the single-segment item is now mediaItem.buildUpon().setUri(...), and the original mediaItem is passed straight to ConcatenatingMediaSource2.Builder.setMediaItem. This also removes the hand-rolled rebuild that was there before, so the factory is slightly smaller than it was.
  • PlaybackNavigationService drops the DetailedItem guard. It was a redundant "is this one of our items?" check, the value was never used, and the mediaId parse in isTrackAvailable already serves that purpose.
  • PlaybackService drops the no-op .setTag(book).
  • Comments added at both sites recording why the identity must survive, and the known limitation below.

Scope and behaviour

LissenMediaProvider.provideFileUri only fails when force-cache is on and the file is absent from disk, so streaming users keep isTrackAvailable == true and see no change. Only offline users with partly downloaded books get the newly live skip behaviour.

Chapters with zero file segments become SilenceMediaSource, whose mediaId is fixed, so they read as unavailable and get skipped. That seems right for an empty chapter.

getMediaItemAt(i).mediaId is now chapter:<bookId>:<n> rather than the empty default, so it becomes visible to media controllers. MediaLibrarySessionCallback and MediaRepository do not read mediaId from the player, so no regression is expected there.

Deliberately left alone, to keep this focused:

  • An unavailable first chapter is still not skipped and fails on load. Setting the playlist raises onTimelineChanged, not a discontinuity, so it never reaches this callback.
  • onPositionDiscontinuity still ignores reason, so seek-triggered skipping stays in place.
  • The pre-existing direction bug, where a backward wrap from index 0 to N-1 is classified FORWARD, is untouched. It is unreachable without repeat mode.
  • The main-thread File.exists() in isTrackAvailable is untouched. Worst case is one stat per clip during a skip search.

Testing

:app:testDebugUnitTest, :app:assembleDebug, :app:assembleDebugAndroidTest and :app:lintKotlin all pass locally; 858 unit tests, no failures.

Two tests added to LissenMediaSourceFactoryTest covering the single-segment and multi-segment paths. The instrumented tests were not executed locally, as no emulator was available, so they run for the first time in CI here.

The skip-unavailable-track handling in PlaybackNavigationService never
ran. Two reasons:

1. bookToChapterMediaItems called setTag(book) without setUri. Media3
   only builds a LocalConfiguration, which holds the tag, when a uri is
   present, so the tag was discarded at construction and the
   DetailedItem guard was always null.

2. The player reports the MediaItem of the MediaSource that
   LissenMediaSourceFactory returns, not the one it was given, since
   getMediaItemAt reads Timeline.Window.mediaItem. The factory forwarded
   only mediaMetadata, so mediaId and the FILE_SEGMENTS extras were lost
   and isTrackAvailable returned false for every track.

Carry the chapter item's identity onto the sources the factory builds,
and drop the redundant tag guard along with the no-op setTag. Both parts
are needed together: restoring only the tag would let the guard pass
while isTrackAvailable still failed, pausing playback on every chapter
transition.
val mediaItem = chapterMediaItem(arrayListOf(FileClip("file-1", 0.0, 30.0)))
lissenMediaSourceFactory.createMediaSource(mediaItem)

assertEquals(mediaItem.mediaId, capturedItem.captured.mediaId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we need to implement more of the mocked mediaSourceFactory to make the test clearer?

  @Before
  fun setUp() {
    mediaSourceFactory = mockk(relaxed = true)
    every {
      mediaSourceFactory.createMediaSource(any())
    } answers {
      val expectedMediaItem = firstArg<MediaItem>()

      mockk<MediaSource>(relaxed = true) {
        every { mediaItem } returns expectedMediaItem.buildUpon().build()
      }
    }

    lissenMediaSourceFactory = LissenMediaSourceFactory(mediaSourceFactory)
  }
  
  @Test
  fun media_id_and_request_metadata_preserved_for_single_segment_chapter() {
    val mediaItem = chapterMediaItem(arrayListOf(FileClip("file-1", 0.0, 30.0)))
    val reportedItem = lissenMediaSourceFactory.createMediaSource(mediaItem).mediaItem

    assertEquals(mediaItem.mediaId, reportedItem.mediaId)
    assertEquals(mediaItem.requestMetadata, reportedItem.requestMetadata)
    assertEquals(mediaItem.mediaMetadata, reportedItem.mediaMetadata)
  }

@golinski

golinski commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Probably even more changes are needed. Right now in #496 it turns out we depend on the fact that lissenMediaSourceFactory.createMediaSource(baseMediaItem).mediaItem looks like baseMediaItem. I feel, we should treat this test now more like an integration test it really is: change the parameter in the contructor of the LissenMediaSourceFactory to a DataSourceFactory (which can then be mocked in the tests):

@UnstableApi
class LissenMediaSourceFactory(
  dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
  private val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)

If someone changes the DefaultMediaSourceFactory (an implementation detail) to something else, we still test for the required condition to hold. @skorokithakis I can provide a PR to your branch if needed.

@skorokithakis

Copy link
Copy Markdown
Contributor Author

@golinski that would be great, as I'm not intimately familiar with the internals here and I don't know what the right thing to do is.

@golinski

Copy link
Copy Markdown
Contributor

PR here skorokithakis#2

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