androidx / media

Jetpack Media3 support libraries for media use cases, including ExoPlayer, an extensible media player for Android
Apache License 2.0
1.41k stars 331 forks source link

Using test utilities to produce a fake media source which emulates buffering behaviour #1372

Open sampengilly opened 2 months ago

sampengilly commented 2 months ago

I'm trying to write some unit tests and using the existing tests as a guide (like this one: https://github.com/androidx/media/blob/d833d59124d795afc146322fe488b2c0d4b9af6a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/PlaybackStatsListenerTest.java)

I'm trying to test the behaviour of a custom Player.Listener that should react only once for each media item that starts playing. Using FakeMediaSource I can emulate most of the behaviours I'm interested in covering. However one behaviour I'd like to cover is content which buffers part way through.

If I set a real media item on the player from a remote mp3 url and observe the events, I get a tonne of onPlaybackStateChanged and onIsPlayingChanged events as it swaps between buffering and ready.

I'd like to have a test that covers this behaviour but if possible I'd like to do it using local resources rather than my current method of hitting a real mp3 on a remote server.

Is there a way to emulate this using FakeMediaSource or one of its subclasses? Or would I need to put a local mp3 file in my test resources and load that?

tonihei commented 2 months ago

I think this can be controlled by the FakeMediaPeriod.TrackDataFactory that is passed to the FakeMediaSource constructor. It produces the samples put into the queues for playback. By default it's a single sample following the the end-of-stream signal. See here. If you leave out the end-of-stream signal you create partially buffered sources that don't progress beyond a certain point.

If you need to let them load more data at a later point in the test, you'd need to go one level down and inject your own custom FakeSampleStream (by overriding createMediaPeriod and createSampleStream, see this example). FakeSampleStream has an append method to add more samples to the list as needed.

sampengilly commented 2 months ago

Hmm, it seems that creating a custom FakeMediaSource which overrides the sample stream to omit the end of stream signal, or to introduce more sample items doesn't seem to have an effect in the test. It doesn't appear to trigger the buffering behaviour that is expected.

In this setup the runUntilPlaybackState(player, Player.STATE_BUFFERING) call times out.

val mediaItem = MediaItem.Builder().setMediaId("TEST_ID").build()
val fakeMediaSource = object : FakeMediaSource(timelineForMediaItem(mediaItem, 10.seconds)) {
    override fun createMediaPeriod(
        id: MediaSource.MediaPeriodId,
        trackGroupArray: TrackGroupArray,
        allocator: Allocator,
        mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher,
        drmSessionManager: DrmSessionManager,
        drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
        transferListener: TransferListener?
    ): MediaPeriod = object : FakeMediaPeriod(
        trackGroupArray,
        allocator,
        { _, _ -> ImmutableList.of() },
        mediaSourceEventDispatcher,
        drmSessionManager,
        drmEventDispatcher,
        false
    ) {
        override fun createSampleStream(
            allocator: Allocator,
            mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher?,
            drmSessionManager: DrmSessionManager,
            drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
            initialFormat: Format,
            fakeSampleStreamItems: MutableList<FakeSampleStream.FakeSampleStreamItem>
        ): FakeSampleStream = FakeSampleStream(
            allocator,
            mediaSourceEventDispatcher,
            drmSessionManager,
            drmEventDispatcher,
            initialFormat,
            listOf(
                FakeSampleStreamItem.oneByteSample(5.seconds.inWholeMicroseconds),
                FakeSampleStreamItem.oneByteSample(5.seconds.inWholeMicroseconds)
            )
        )
    }
}
player.setMediaSource(fakeMediaSource)
player.prepare()
player.play()

TestPlayerRunHelper.playUntilPosition(player, 0, 1.seconds.inWholeMilliseconds)
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_BUFFERING)
tonihei commented 2 months ago

It's not running because

  1. The source doesn't publish any tracks, so the sample stream logic isn't even called. You can fix this by adding a Format to the FakeMediaSource constructor, e.g. FakeMediaSource(timelineForMediaItem(mediaItem, 10.seconds), ExoPlayerTestRunner.VIDEO_FORMAT)
  2. The first sample at least needs to be a keyframe to start playback, which can be set with FakeSampleStreamItem.oneByteSample(5.seconds.inWholeMicroseconds, C.BUFFER_FLAG_KEY_FRAME)

If I make both these changes, the test runs through as expected.

sampengilly commented 2 months ago

Thanks, I'll give that a try

sampengilly commented 2 months ago

Hmmm, I'm still not seeing the expected behaviour with those changes:

private class FakeBufferingMediaSource(
    mediaItem: MediaItem
) : FakeMediaSource(timelineForMediaItem(mediaItem, 10.seconds), ExoPlayerTestRunner.VIDEO_FORMAT) {

    override fun createMediaPeriod(
        id: MediaSource.MediaPeriodId,
        trackGroupArray: TrackGroupArray,
        allocator: Allocator,
        mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher,
        drmSessionManager: DrmSessionManager,
        drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
        transferListener: TransferListener?
    ): MediaPeriod = object : FakeMediaPeriod(
        trackGroupArray,
        allocator,
        { _, _ -> ImmutableList.of() },
        mediaSourceEventDispatcher,
        drmSessionManager,
        drmEventDispatcher,
        false
    ) {
        override fun createSampleStream(
            allocator: Allocator,
            mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher?,
            drmSessionManager: DrmSessionManager,
            drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
            initialFormat: Format,
            fakeSampleStreamItems: MutableList<FakeSampleStreamItem>
        ): FakeSampleStream = FakeSampleStream(
            allocator,
            mediaSourceEventDispatcher,
            drmSessionManager,
            drmEventDispatcher,
            initialFormat,
            listOf(
                FakeSampleStreamItem.oneByteSample(5.seconds.inWholeMicroseconds, C.BUFFER_FLAG_KEY_FRAME),
                FakeSampleStreamItem.oneByteSample(5.seconds.inWholeMicroseconds),
                FakeSampleStreamItem.END_OF_STREAM_ITEM
            )
        )
    }

}

Logs during test output (I have a listener set on the player which prints the different events) image

The logs I expect to see with content that buffers (from a real remote mp3 media source) image

With the changes suggested above, removing the END_OF_STREAM_ITEM from the list causes the test to timeout (waiting for STATE_ENDED when it will never come), so clearly the sample list is having some effect here.

tonihei commented 2 months ago

That sounds like working as intended I think. If you add the END_OF_STREAM_ITEM sample, the player will just play these sample and then go the ended state. If you omit END_OF_STREAM_ITEM., then the player will stay in a buffering state and can't make further progress (and in particular never reaching STATE_ENDED).

As per my original comment, you can change the sample stream later with the append method. So you can have a test setup like

// Create player and set up FakeMediaSource without END_OF_STREAM_ITEM
player.prepare();
player.play();
runUntilPlaybackState(player, Player.STATE_BUFFERING);

// Make assertions about your app here that depend on this buffering.

fakeSampleStream.append(END_OF_STREAM_ITEM); // Allow the stream to end.
runUntilPlaybackState(player, Player.STATE_ENDED);
sampengilly commented 1 month ago

Even using append I'm a bit lost here.

I've updated my FakeBufferingMediaSource to allow for appending sample items (including calling writeData on the sample stream which seems to be needed for things given to the append function to be written).

internal class FakeBufferingMediaSource(
    timeline: Timeline
) : FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) {

    private lateinit var sampleStream: FakeSampleStream

    fun appendNextSample(item: FakeSampleStreamItem) {
        sampleStream.append(listOf(item))
        sampleStream.writeData(0)
    }

    override fun createMediaPeriod(
        id: MediaSource.MediaPeriodId,
        trackGroupArray: TrackGroupArray,
        allocator: Allocator,
        mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher,
        drmSessionManager: DrmSessionManager,
        drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
        transferListener: TransferListener?
    ): MediaPeriod = object : FakeMediaPeriod(
        trackGroupArray,
        allocator,
        { _, _ -> ImmutableList.of() },
        mediaSourceEventDispatcher,
        drmSessionManager,
        drmEventDispatcher,
        false
    ) {
        override fun createSampleStream(
            allocator: Allocator,
            mediaSourceEventDispatcher: MediaSourceEventListener.EventDispatcher?,
            drmSessionManager: DrmSessionManager,
            drmEventDispatcher: DrmSessionEventListener.EventDispatcher,
            initialFormat: Format,
            fakeSampleStreamItems: MutableList<FakeSampleStreamItem>
        ): FakeSampleStream = FakeSampleStream(
            allocator,
            mediaSourceEventDispatcher,
            drmSessionManager,
            drmEventDispatcher,
            initialFormat,
            listOf()
        ).also { sampleStream = it }
    }

}

In my test though, nothing at all happens until an END_OF_STREAM_ITEM is appended.

fun `play event triggered once for content that buffers midway through`() {
    val mediaItem = MediaItem.Builder().setMediaId("TEST_ID").build()
    val source = FakeBufferingMediaSource(
        timeline = timelineForMediaItem(mediaItem, 10.seconds)
    )
    player.setMediaSource(source)
    player.prepare()
    player.play()
    TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player)

    source.appendNextSample(oneByteSample(2.seconds.inWholeMicroseconds, C.BUFFER_FLAG_KEY_FRAME))

//   TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_BUFFERING)
//   source.appendNextSample(oneByteSample(2.seconds.inWholeMicroseconds, C.BUFFER_FLAG_KEY_FRAME))

//    TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_BUFFERING)
//    source.appendNextSample(END_OF_STREAM_ITEM)

    TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED)

    playEvents.map { it.mediaItem } shouldContainExactly listOf(mediaItem)
}

With the END_OF_STREAM_ITEM line commented out, nothing even begins to play, it times out waiting for a STATE_ENDED event that never comes but beyond that there isn't even a STATE_READY event that occurs. image

If I uncomment just that line appending the END_OF_STREAM_ITEM suddenly the READY state occurs: image

The other commented lines appending new samples have no effect

tonihei commented 3 weeks ago

Sorry, I didn't get back to this conversation. I haven't tried it yet, but I think your test may be blocked on the (Default)LoadControl requiring a minimum amount of buffered data to become 'ready'.

sampengilly commented 1 week ago

I tried playing around a little with the LoadControl on the fake player, setting different parameters on it and specifying larger and larger blocks of sample data in the sample stream. Doesn't seem to have had an effect :(