PlayerMessages positioned inside a server-side-inserted ad period are never delivered
@marcbaechinger is already working on this.
Since Jul 29, 2026.
- Dominant language
- Java
- Stars
- 3k
- Forks
- 955
- Avg merge
- 12d 14h
- Merged PRs (30d)
- 2
Description
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
- You will email the zip file produced by
adb bugreportto android-media-github@google.com after filing this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.