Skip to content

PlayerMessages positioned inside a server-side-inserted ad period are never delivered #3353

Description

@timrijckaert

Version

Media3 main branch

More version details

1.10.1 (latest stable at the time of writing)

Devices that reproduce the issue

Any.
Reproduced deterministically in a Robolectric unit test and on physical devices (Android 14) playing a live DASH stream with AWS MediaTailor server-side ad insertion via ServerSideAdInsertionMediaSource.

Devices that do not reproduce the issue

None

Reproducible in the demo app?

Not tested

Reproduction steps

Use case: client-side ad tracking for a server-side-stitched stream (e.g. AWS MediaTailor).
The stitcher tells the client at which stream positions its tracking beacons (impression, quartiles, complete) are due; createMessage().setPosition(...) is the natural API to be notified at those positions.
Every position that falls inside an ad period is silently ignored.

Prepare a ServerSideAdInsertionMediaSource wrapping a live, dynamic, multi-period timeline.
Mark one mid-window period as a single whole-period server-side ad group
Send a PlayerMessage with a position inside that ad period.
Play from the window start to past the ad period.
The message is never delivered.
A control message positioned inside a content period of the same stream is delivered normally.

import androidx.media3.common.AdPlaybackState
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil
import androidx.media3.test.utils.FakeMediaSource
import androidx.media3.test.utils.FakeTimeline
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition
import androidx.media3.test.utils.TestExoPlayerBuilder
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.collect.ImmutableMap
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicBoolean

@RunWith(AndroidJUnit4::class)
@Config(sdk = [33])
class Media3SsaiPlayerMessageReproTest {

    private lateinit var player: ExoPlayer

    @After
    fun tearDown() {
        player.release()
    }

    @Test
    fun `a player message inside a server-side inserted ad period is delivered when playback passes it`() {
        // window: ├─ p0 content (0..20s) ─┼─ p1 ad (20..40s) ─┼─ p2 content (40..60s) ─┤
        // message at window position 30s, inside the server-side ad period.
        prepareSsai()
        val delivered = AtomicBoolean(false)

        player
            .createMessage { _, _ -> delivered.set(true) }
            .setPosition(30_000)
            .setDeleteAfterDelivery(true)
            .send()
        TestPlayerRunHelper.play(player).untilPosition(0, 50_000)

        // FAILS
        assertTrue("message at 30s (inside the server-side-inserted ad period) was never delivered", delivered.get())
    }

    @Test
    fun `control - a player message in a content period is delivered when playback passes it`() {
        // Identical setup; the message sits at 10s, inside content period p0. PASSES.
        prepareSsai()
        val delivered = AtomicBoolean(false)

        player
            .createMessage { _, _ -> delivered.set(true) }
            .setPosition(10_000)
            .setDeleteAfterDelivery(true)
            .send()
        TestPlayerRunHelper.play(player).untilPosition(0, 15_000)

        assertTrue("message at 10s (inside a content period) was never delivered", delivered.get())
    }

    private fun prepareSsai() {
        player = TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build()
        val timeline = liveTimeline()
        val content = FakeMediaSource(timeline)
        val ssai = ServerSideAdInsertionMediaSource(content) { false }
        ssai.setAdPlaybackStates(adPlaybackStates(timeline, adPeriodIndex = 1), timeline)
        player.setMediaSource(ssai)
        player.prepare()
        TestPlayerRunHelper.advance(player).untilState(Player.STATE_READY)
    }

    /** FakeTimeline period uids are Pair(windowUid, periodIndex): stable across refreshes. */
    private fun liveTimeline(): Timeline =
        FakeTimeline(
            TimelineWindowDefinition.Builder()
                .setUid("window")
                .setLive(true)
                .setDynamic(true)
                .setSeekable(true)
                .setPeriodCount(3)
                .setDurationUs(60_000_000)
                .setWindowStartTimeUs(1_720_000_000_000_000)
                .setWindowPositionInFirstPeriodUs(0)
                .setDefaultPositionUs(0)
                .build(),
        )

    /** One state per period, keyed by period uid (`ServerSideAdInsertionTimeline` requires full coverage). */
    private fun adPlaybackStates(timeline: Timeline, adPeriodIndex: Int): ImmutableMap<Any, AdPlaybackState> {
        val builder = ImmutableMap.builder<Any, AdPlaybackState>()
        for (periodIndex in 0 until timeline.periodCount) {
            builder.put(
                timeline.getUidOfPeriod(periodIndex),
                if (periodIndex == adPeriodIndex) {
                    ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState(
                        AdPlaybackState(ADS_ID),
                        /* fromPositionUs = */ 0,
                        /* contentResumeOffsetUs = */ 20_000_000,
                        /* adDurationsUs... = */ 20_000_000,
                    )
                } else {
                    AdPlaybackState(ADS_ID)
                },
            )
        }
        return builder.build()
    }

    private companion object {
        const val ADS_ID = "repro-ads-id"
    }
}

Expected result

The message is delivered when playback passes its position.
Unlike client-side ad insertion, content time does not freeze during a server-side-inserted ad the stitched ad occupies real stream time and the playhead sweeps the message position.

Actual result

The message stays pending forever.

Media

Not applicable

Bug Report

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions