Compare commits

...

6 Commits

Author SHA1 Message Date
dancho
a78d0c3994 Rollback of 73fa820828
PiperOrigin-RevId: 745618560
2025-04-09 09:25:55 -07:00
bachinger
cb0ea7fc95 Make sure subtitle is used without display title being set
PiperOrigin-RevId: 745616511
2025-04-09 09:19:47 -07:00
bachinger
6cae8ab8a0 Support X-SNAP with HLS interstitials
PiperOrigin-RevId: 745614349
2025-04-09 09:13:24 -07:00
aquilescanta
c5b6489d5d Add missing isDeviceMuted to EVENT_DEVICE_VOLUME_CHANGED docs
PiperOrigin-RevId: 745591130
2025-04-09 08:05:15 -07:00
bachinger
1b3658e357 Adjust AGGREGATES_CALLBACKS_WITHIN_TIMEOUT_MS and make it configurable
PiperOrigin-RevId: 745585764
2025-04-09 07:47:12 -07:00
tonihei
f9617e1f8d Clear surface from previous player when assigning a new player
The surface must only be used by one player at a time. To ensure
that, we can keep a reference to the previously used player
and clear its surface reference before assigning to a new one.

Note that we do not need to clear the surface in onDispose
of a DisposableEffect because the lifecycle management of the
surface is moved to the Player and the Player takes care of
unregistering its surface reference as soon as the surface is
destroyed (which happens when the AndroidView element is no longer
is the Composable tree).

PiperOrigin-RevId: 745558414
2025-04-09 06:14:47 -07:00
15 changed files with 1074 additions and 201 deletions

View File

@ -63,9 +63,13 @@
instead of `MediaCodec.BufferInfo`.
* IMA extension:
* Session:
* Lower aggregation timeout for platform `MediaSession` callbacks from 500
to 100 milliseconds and add an experimental setter to allow apps to
configure this value.
* UI:
* Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and
`CompositionPlayer`.
* Fix bug where `PlayerSurface` can't be recomposed with a new `Player`.
* Downloads:
* Add partial download support for progressive streams. Apps can prepare a
progressive stream with `DownloadHelper`, and request a

View File

@ -1623,7 +1623,7 @@ public interface Player {
/** {@link #getDeviceInfo()} changed. */
int EVENT_DEVICE_INFO_CHANGED = 29;
/** {@link #getDeviceVolume()} changed. */
/** {@link #getDeviceVolume()} or {@link #isDeviceMuted()} changed. */
int EVENT_DEVICE_VOLUME_CHANGED = 30;
/**

View File

@ -17,19 +17,13 @@ package androidx.media3.exoplayer.drm;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assume.assumeTrue;
import android.content.Context;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.DecoderCounters;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.mockwebserver.MockResponse;
@ -103,73 +97,4 @@ public final class DrmPlaybackTest {
getInstrumentation().waitForIdleSync();
assertThat(playbackException.get()).isNull();
}
@Test
public void clearkeyPlayback_withLateThresholdToDropDecoderInput_dropsInputBuffers()
throws Exception {
// The API 21 emulator doesn't have a secure decoder. Due to b/18678462 MediaCodecUtil pretends
// that there is a secure decoder so we must only run this test on API 21 - i.e. we cannot
// assumeTrue() on getDecoderInfos.
assumeTrue(Util.SDK_INT > 21);
Context context = getInstrumentation().getContext();
MockWebServer mockWebServer = new MockWebServer();
mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(CLEARKEY_RESPONSE));
mockWebServer.start();
MediaItem mediaItem =
new MediaItem.Builder()
.setUri("asset:///media/drm/sample_fragmented_clearkey.mp4")
.setDrmConfiguration(
new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setLicenseUri(mockWebServer.url("license").toString())
.build())
.build();
AtomicReference<ExoPlayer> player = new AtomicReference<>();
ConditionVariable playbackComplete = new ConditionVariable();
AtomicReference<PlaybackException> playbackException = new AtomicReference<>();
AtomicReference<DecoderCounters> decoderCountersAtomicReference = new AtomicReference<>();
getInstrumentation()
.runOnMainSync(
() -> {
player.set(
new ExoPlayer.Builder(
context,
new DefaultRenderersFactory(context)
.experimentalSetLateThresholdToDropDecoderInputUs(-100_000_000L),
new DefaultMediaSourceFactory(context)
.experimentalSetCodecsToParseWithinGopSampleDependencies(
C.VIDEO_CODEC_FLAG_H264))
.build());
player
.get()
.addListener(
new Player.Listener() {
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
if (playbackState == Player.STATE_ENDED) {
decoderCountersAtomicReference.set(
player.get().getVideoDecoderCounters());
playbackComplete.open();
}
}
@Override
public void onPlayerError(PlaybackException error) {
playbackException.set(error);
playbackComplete.open();
}
});
player.get().setMediaItem(mediaItem);
player.get().prepare();
player.get().play();
});
playbackComplete.block();
getInstrumentation().runOnMainSync(() -> player.get().release());
getInstrumentation().waitForIdleSync();
assertThat(playbackException.get()).isNull();
// Which input buffers are dropped first depends on the number of MediaCodec buffer slots.
// This means the asserts cannot be isEqualTo.
assertThat(decoderCountersAtomicReference.get().droppedInputBufferCount).isAtLeast(1);
}
}

View File

@ -1552,6 +1552,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer
// Make sure to decode and render the last frame.
return false;
}
if (buffer.isEncrypted()) {
// Commonly used decryption algorithms require updating the initialization vector for each
// block processed. Skipping input buffers before the decoder is not allowed.
return false;
}
boolean shouldSkipDecoderInputBuffer = isBufferBeforeStartTime(buffer);
if (!shouldSkipDecoderInputBuffer && !shouldDropDecoderInputBuffers) {
return false;

View File

@ -32,7 +32,11 @@ import static androidx.media3.common.util.Util.msToUs;
import static androidx.media3.common.util.Util.usToMs;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_IN;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.SNAP_TYPE_OUT;
import static java.lang.Math.abs;
import static java.lang.Math.max;
import static java.lang.Math.min;
import android.content.Context;
import android.net.Uri;
@ -633,7 +637,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
window.positionInFirstPeriodUs,
checkNotNull(insertedInterstitialIds.get(adsId)))
: mapInterstitialsForVod(
window.mediaItem,
window,
mediaPlaylist,
adPlaybackState,
checkNotNull(insertedInterstitialIds.get(adsId)));
@ -913,13 +917,15 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
ArrayList<Interstitial> interstitials = new ArrayList<>(mediaPlaylist.interstitials);
for (int i = 0; i < interstitials.size(); i++) {
Interstitial interstitial = interstitials.get(i);
long positionInPlaylistWindowUs =
interstitial.cue.contains(CUE_TRIGGER_PRE)
? 0L
: (interstitial.startDateUnixUs - mediaPlaylist.startTimeUs);
if (insertedInterstitialIds.contains(interstitial.id)
|| interstitial.cue.contains(CUE_TRIGGER_POST)
|| positionInPlaylistWindowUs < 0) {
|| interstitial.cue.contains(CUE_TRIGGER_POST)) {
continue;
}
long positionInPlaylistWindowUs =
resolveInterstitialStartTimeUs(interstitial, mediaPlaylist) - mediaPlaylist.startTimeUs;
if (positionInPlaylistWindowUs < 0 || mediaPlaylist.durationUs < positionInPlaylistWindowUs) {
// Ignore when behind the window including C.TIME_UNSET and C.TIME_END_OF_SOURCE.
// When not yet in the window we wait until the window advances.
continue;
}
long timeUs = windowPositionInPeriodUs + positionInPlaylistWindowUs;
@ -935,18 +941,18 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
isNewAdGroup = false;
break;
} else if (adGroup.timeUs < timeUs) {
// Insert at index after group before interstitial.
// Insert at index after group behind interstitial.
insertionIndex = adGroupIndex + 1;
break;
}
// Interstitial is before the ad group. Possible insertion index.
// Interstitial is behind the ad group. Possible insertion index.
insertionIndex = adGroupIndex;
}
if (isNewAdGroup) {
if (insertionIndex < getLowestValidAdGroupInsertionIndex(adPlaybackState)) {
Log.w(
TAG,
"Skipping insertion of interstitial attempted to be inserted before an already"
"Skipping insertion of interstitial attempted to be inserted behind an already"
+ " initialized ad group.");
continue;
}
@ -965,36 +971,49 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
private AdPlaybackState mapInterstitialsForVod(
MediaItem mediaItem,
Window window,
HlsMediaPlaylist mediaPlaylist,
AdPlaybackState adPlaybackState,
Set<String> insertedInterstitialIds) {
checkArgument(adPlaybackState.adGroupCount == 0);
checkArgument(adPlaybackState.adGroupCount == adPlaybackState.removedAdGroupCount);
ImmutableList<Interstitial> interstitials = mediaPlaylist.interstitials;
long clippedWindowStartTimeUs = mediaPlaylist.startTimeUs + window.positionInFirstPeriodUs;
long clippedWindowEndTimeUs = clippedWindowStartTimeUs + window.durationUs;
for (int i = 0; i < interstitials.size(); i++) {
Interstitial interstitial = interstitials.get(i);
long timeUs;
if (interstitial.cue.contains(CUE_TRIGGER_PRE)) {
timeUs = 0L;
} else if (interstitial.cue.contains(CUE_TRIGGER_POST)) {
timeUs = C.TIME_END_OF_SOURCE;
} else {
timeUs = interstitial.startDateUnixUs - mediaPlaylist.startTimeUs;
long interstitialStartTimeUs = resolveInterstitialStartTimeUs(interstitial, mediaPlaylist);
if (interstitialStartTimeUs < clippedWindowStartTimeUs
&& interstitial.cue.contains(CUE_TRIGGER_PRE)) {
// Declared pre roll: move to the start of the clipped window.
interstitialStartTimeUs = clippedWindowStartTimeUs;
} else if (interstitialStartTimeUs > clippedWindowEndTimeUs
&& interstitial.cue.contains(CUE_TRIGGER_POST)) {
// Declared post roll: move to the end of the clipped window.
interstitialStartTimeUs = clippedWindowEndTimeUs;
} else if (interstitialStartTimeUs < clippedWindowStartTimeUs
|| clippedWindowEndTimeUs < interstitialStartTimeUs) {
// Ignore interstitials before or after the window that are not explicit pre or post rolls.
continue;
}
long timeUs =
clippedWindowEndTimeUs == interstitialStartTimeUs
? C.TIME_END_OF_SOURCE
: interstitialStartTimeUs - mediaPlaylist.startTimeUs;
int adGroupIndex =
adPlaybackState.getAdGroupIndexForPositionUs(timeUs, mediaPlaylist.durationUs);
if (adGroupIndex == C.INDEX_UNSET) {
// There is no ad group before or at the interstitials position.
adGroupIndex = 0;
adPlaybackState = adPlaybackState.withNewAdGroup(/* adGroupIndex= */ 0, timeUs);
adGroupIndex = adPlaybackState.removedAdGroupCount;
adPlaybackState =
adPlaybackState.withNewAdGroup(adPlaybackState.removedAdGroupCount, timeUs);
} else if (adPlaybackState.getAdGroup(adGroupIndex).timeUs != timeUs) {
// There is an ad group before the interstitials. Insert after that index.
// There is an ad group before the interstitial. Insert after that index.
adGroupIndex++;
adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, timeUs);
}
adPlaybackState =
insertOrUpdateInterstitialInAdGroup(
mediaItem,
window.mediaItem,
interstitial,
adPlaybackState,
adGroupIndex,
@ -1021,7 +1040,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
adIndexInAdGroup = max(adGroup.count, 0);
// Append duration of new interstitial into existing ad durations.
long interstitialDurationUs =
getInterstitialDurationUs(interstitial, /* defaultDurationUs= */ C.TIME_UNSET);
resolveInterstitialDurationUs(interstitial, /* defaultDurationUs= */ C.TIME_UNSET);
long[] adDurations;
if (adIndexInAdGroup == 0) {
adDurations = new long[1];
@ -1081,7 +1100,8 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
return adPlaybackState.removedAdGroupCount;
}
private static long getInterstitialDurationUs(Interstitial interstitial, long defaultDurationUs) {
private static long resolveInterstitialDurationUs(
Interstitial interstitial, long defaultDurationUs) {
if (interstitial.playoutLimitUs != C.TIME_UNSET) {
return interstitial.playoutLimitUs;
} else if (interstitial.durationUs != C.TIME_UNSET) {
@ -1094,6 +1114,45 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
return defaultDurationUs;
}
private static long resolveInterstitialStartTimeUs(
Interstitial interstitial, HlsMediaPlaylist mediaPlaylist) {
if (interstitial.cue.contains(CUE_TRIGGER_PRE)) {
return mediaPlaylist.startTimeUs;
} else if (interstitial.cue.contains(CUE_TRIGGER_POST)) {
return mediaPlaylist.startTimeUs + mediaPlaylist.durationUs;
} else if (interstitial.snapTypes.contains(SNAP_TYPE_OUT)) {
return getClosestSegmentBoundaryUs(interstitial.startDateUnixUs, mediaPlaylist);
} else if (interstitial.snapTypes.contains(SNAP_TYPE_IN)) {
long resumeOffsetUs =
interstitial.resumeOffsetUs != C.TIME_UNSET
? interstitial.resumeOffsetUs
: resolveInterstitialDurationUs(interstitial, /* defaultDurationUs= */ 0L);
return getClosestSegmentBoundaryUs(
interstitial.startDateUnixUs + resumeOffsetUs, mediaPlaylist)
- resumeOffsetUs;
} else {
return interstitial.startDateUnixUs;
}
}
private static long getClosestSegmentBoundaryUs(long unixTimeUs, HlsMediaPlaylist mediaPlaylist) {
long positionInPlaylistUs = unixTimeUs - mediaPlaylist.startTimeUs;
if (positionInPlaylistUs <= 0 || mediaPlaylist.segments.isEmpty()) {
return mediaPlaylist.startTimeUs;
} else if (positionInPlaylistUs >= mediaPlaylist.durationUs) {
return mediaPlaylist.startTimeUs + mediaPlaylist.durationUs;
}
long segmentIndex =
min(
positionInPlaylistUs / mediaPlaylist.targetDurationUs,
mediaPlaylist.segments.size() - 1);
HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get((int) segmentIndex);
return positionInPlaylistUs - segment.relativeStartTimeUs
< abs(positionInPlaylistUs - (segment.relativeStartTimeUs + segment.durationUs))
? mediaPlaylist.startTimeUs + segment.relativeStartTimeUs
: mediaPlaylist.startTimeUs + segment.relativeStartTimeUs + segment.durationUs;
}
private class PlayerListener implements Player.Listener {
private final Period period = new Period();

View File

@ -265,6 +265,10 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXTINF:6,\n"
+ "main2.0.ts\n"
+ "#EXTINF:6,\n"
+ "main3.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -332,6 +336,108 @@ public class HlsInterstitialsAdsLoaderTest {
assertThat(actual).isEqualTo(expected);
}
@Test
public void handleContentTimelineChanged_clippedWindow_translatedToClippedWindow()
throws IOException {
AdPlaybackState actual =
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\nmain1.0.ts\n"
+ "#EXTINF:6,\nmain2.0.ts\n"
+ "#EXTINF:6,\nmain3.0.ts\n"
+ "#EXTINF:6,\nmain4.0.ts\n" // ends at 24_000_000 -> 21:56:04.000Z
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // pre roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "CUE=\"PRE\"," // Explicit pre roll. Aligned to clip start.
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\"," // ignored
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:42.999Z\"," // non-pre roll behind clipped window
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\"," // ignored
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:39.999Z\"," // snaps to the playlist window start
+ "X-SNAP=\"OUT\""
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad3-0\"," // mid roll at 15_000_000
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:55.000Z\","
+ "X-ASSET-URI=\"http://example.com/media-3-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad4-0\"," // post roll 0
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:56:00.321Z\"," // exact match at end of clip
+ "X-ASSET-URI=\"http://example.com/media-4-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad4-1\"," // ignored
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:56:00.322Z\"," // after end of clip
+ "X-ASSET-URI=\"http://example.com/media-4-1.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad4-2\"," // post roll 1
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2050-01-02T21:55:08.000Z\","
+ "CUE=\"POST\"," // explicit post roll
+ "X-ASSET-URI=\"http://example.com/media-4-2.m3u8\"\n",
adsLoader,
/* windowIndex= */ 1,
6_000_123L, // clipped to 6s after start of period
20_321_000L); // clipped to 4s before end of period
assertThat(actual)
.isEqualTo(
new AdPlaybackState("adsId", 6_000_123L, 15_000_000L, C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdCount(/* adGroupIndex= */ 2, 2)
.withAdId(0, 0, "ad0-0")
.withAdId(1, 0, "ad3-0")
.withAdId(2, 0, "ad4-0")
.withAdId(2, 1, "ad4-2")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-3-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-4-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 1,
new MediaItem.Builder()
.setUri("http://example.com/media-4-2.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void handleContentTimelineChanged_3preRolls_mergedIntoSinglePreRollAdGroup()
throws IOException {
@ -416,18 +522,21 @@ public class HlsInterstitialsAdsLoaderTest {
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "END-DATE=\"2020-01-02T21:55:46.000Z\"," // adds to resume offset
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-1\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "DURATION=1.1," // adds to resume offset
+ "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-2\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "PLANNED-DURATION=1.2," // adds to resume offset
+ "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\""
+ "\n";
@ -440,9 +549,9 @@ public class HlsInterstitialsAdsLoaderTest {
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 4_000_000L)
.withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET)
.withAdDurationsUs(/* adGroupIndex= */ 0, 2_000_000L, 1_100_000L, 1_200_000L)
.withAdCount(/* adGroupIndex= */ 0, 3)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 0L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 4_300_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, "ad0-2")
@ -472,51 +581,49 @@ public class HlsInterstitialsAdsLoaderTest {
@Test
public void handleContentTimelineChanged_3postRolls_mergedIntoSinglePostRollAdGroup()
throws IOException {
String playlistString =
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:30.000Z\","
+ "END-DATE=\"2020-01-02T21:55:31.000Z\","
+ "CUE=\"POST\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-1\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "CUE=\"POST\","
+ "START-DATE=\"2020-01-02T21:55:40.000Z\","
+ "DURATION=1.1,"
+ "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-2\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:51.000Z\","
+ "CUE=\"POST\","
+ "PLANNED-DURATION=1.2,"
+ "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\""
+ "\n";
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
playlistString,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:30.000Z\","
+ "CUE=\"POST\"," // cued as post roll
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-1\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.000Z\"," // exact match
+ "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-2\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.001Z\"," // late but snaps to post roll.
+ "X-SNAP=\"OUT\""
+ "X-ASSET-URI=\"http://example.com/media-0-2.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-3\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.001Z\"," // late and hence ignored.
+ "X-ASSET-URI=\"http://example.com/media-0-3.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L, 1_100_000L, 1_200_000L)
.withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET)
.withAdCount(/* adGroupIndex= */ 0, 3)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_300_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, "ad0-2")
@ -909,25 +1016,608 @@ public class HlsInterstitialsAdsLoaderTest {
}
@Test
public void handleContentTimelineChanged_noDurationSet_durationTimeUnset() throws IOException {
String playlistString =
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:41.123Z\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n";
public void handleContentTimelineChanged_snapOut_snapToClosestSegmentBoundaryOfStartPosition()
throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
playlistString,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.ts\n"
+ "#EXTINF:6.111,\nmain2.ts\n"
+ "#EXTINF:6.111,\nmain3.ts\n" // end of window: 18_333_000 -> 21:55:58.333
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // post roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:58.000Z\"," // snap to 21:55:58.333
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\"," // mid roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.000Z\"," // snap to 21:55:46.111
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad3-0\"," // pre roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:40.123Z\"," // snap to 21:55:40.000
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-3-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState(
"adsId", /* adGroupTimesUs...= */ 0L, 6_111_000L, C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdCount(/* adGroupIndex= */ 2, 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad3-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0")
.withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-3-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void handleContentTimelineChanged_snapOutLive_snapToClosestSegmentBoundaryOfStartPosition()
throws IOException {
assertThat(
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
adsLoader,
/* startAdsLoader= */ true,
/* windowOffsetInFirstPeriodUs= */ 2_000_123L, // window offset!
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-MEDIA-SEQUENCE:0\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // mid roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.000\"," // snap to 6.111 + 2_000_123
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\"," // mid roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:52.999Z\"," // snap to 12.222 + 2_000_123
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\"," // mid roll at end of window
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2050-01-02T21:56:04.000Z\"," // snap to 18.333 + 2_000_123
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n" // 2s offset in period
+ "#EXTINF:6.111,\nmain1.0.ts\n"
+ "#EXTINF:6.111,\nmain2.0.ts\n"
+ "#EXTINF:6.111,\nmain3.0.ts\n")) // window end time at 18.333 -> 21:55:58.333
.containsExactly(
new AdPlaybackState(
"adsId", /* adGroupTimesUs...= */ 8_111_123L, 14_222_123L, 20_333_123L)
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdCount(/* adGroupIndex= */ 2, 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0")
.withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad2-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-2-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void handleContentTimelineChanged_snapIn_snapToClosestSegmentBoundaryOfResumptionPosition()
throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.ts\n"
+ "#EXTINF:6.111,\nmain2.ts\n" // segment start at 6.111
+ "#EXTINF:6.111,\nmain3.ts\n" // end of window at 18.333
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:39.900\"," // snap to 12.222 - 12.222 -> 0L
+ "X-SNAP=\"IN\","
+ "DURATION=12.222,"
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:51.222\"," // aligned 12.222 - 3.222 -> 9_000L
+ "X-SNAP=\"IN\","
+ "DURATION=3.222,"
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:54.678\"," // snap to end of window - 4.333
+ "X-SNAP=\"IN\","
+ "DURATION=4.333,"
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L, 9_000_000L, 14_000_000L)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdCount(/* adGroupIndex= */ 2, 1)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 12_222_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 1, 3_222_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 2, 4_333_000L)
.withAdDurationsUs(/* adGroupIndex= */ 0, 12_222_000L)
.withAdDurationsUs(/* adGroupIndex= */ 1, 3_222_000L)
.withAdDurationsUs(/* adGroupIndex= */ 2, 4_333_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0")
.withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad2-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-2-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void
handleContentTimelineChanged_snapInLive_snapToClosestSegmentBoundaryOfResumptionPosition()
throws IOException {
assertThat(
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
adsLoader,
/* startAdsLoader= */ true,
/* windowOffsetInFirstPeriodUs= */ 123L,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-MEDIA-SEQUENCE:0\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:39.900\"," // snap to (12.222 - 12.222) -> 0L
+ "X-SNAP=\"IN\","
+ "X-RESUME-OFFSET=12.222,"
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:51.222\"," // snap to (12.222 - 3.22) -> 9_000L
+ "X-SNAP=\"IN\","
+ "X-RESUME-OFFSET=3.222,"
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:54.678\"," // snap to (end of window - 4.333)
+ "X-SNAP=\"IN\","
+ "DURATION=4.333,"
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.ts\n"
+ "#EXTINF:6.111,\nmain2.ts\n" // segment start at 6.111
+ "#EXTINF:6.111,\nmain3.ts\n")) // segment start at 12.222
.containsExactly(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 123L, 9_000_123L, 14_000_123L)
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdCount(/* adGroupIndex= */ 2, 1)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 12_222_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 1, 3_222_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 2, 4_333_000L)
.withAdDurationsUs(/* adGroupIndex= */ 2, 4_333_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0")
.withAdId(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0, "ad2-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 2,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-2-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void
handleContentTimelineChanged_snapInFarBeforeOrAfterWindow_snapToStartOfWindowAndPostRoll()
throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // pre roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"1990-01-02T00:00:00.000\"," // snap to start of window.
+ "DURATION=1.0,"
+ "X-RESUME-OFFSET=0.0," // with no offset SNAP_IN => SNAP_OUT
+ "X-SNAP=\"IN\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\"," // ignored
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"1990-01-02T00:00:00.000\"," // snap end to start of window
+ "DURATION=1.0," // translate start of ad back to 1s behind window
+ "X-SNAP=\"IN\","
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\"," // post roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2050-01-02T21:55:00.900\"," // snap to end of window
+ "X-SNAP=\"IN\","
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L, C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad2-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-2-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void
handleContentTimelineChanged_snapInLiveFarBeforeOrAfterWindow_snapToStartAndEndOfWindow()
throws IOException {
assertThat(
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
adsLoader,
/* startAdsLoader= */ true,
/* windowOffsetInFirstPeriodUs= */ 0,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-MEDIA-SEQUENCE:0\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // pre roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"1990-01-02T00:00:00.000\"," // snap to start of window
+ "DURATION=1.0,"
+ "X-RESUME-OFFSET=0.0," // with no offset SNAP_IN => SNAP_OUT
+ "X-SNAP=\"IN\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\"," // ignore
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"1990-01-02T00:00:00.000\"," // snap to start of window
+ "DURATION=1.0," // translate start of ad back to 1s behind window
+ "X-SNAP=\"IN\","
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad2-0\"," // mid roll at end of window (but not C.TIME_END_OF_SOURCE)
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2050-01-02T21:55:00.900\"," // snap end of window: 12_222_000
+ "X-SNAP=\"IN\"," // no duration or offset: SNAP_IN => SNAP_OUT
+ "X-ASSET-URI=\"http://example.com/media-2-0.m3u8\""
+ "\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.0.ts\n"
+ "#EXTINF:6.111,\nmain1.0.ts\n" // end of window: 12:222 - 21:55:52.222
+ "\n"))
.containsExactly(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0L, 12_222_000L)
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad2-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-2-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void
handleContentTimelineChanged_snapInOut_snapToSameSegmentBoundaryMergedIntoSameAdGroup()
throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.0.ts\n"
+ "#EXTINF:6.111,\nmain2.0.ts\n" // segment start at 6.111 -> 21:55:46.111
+ "#EXTINF:6.111,\nmain3.0.ts\n" // segment start at 12.222 -> 21:55:52.222
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\"," // mid roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:45.000\"," // snap tp 6_111_000
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-1\"," // mid roll
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:47.000\"," // snap to 6_111_000
+ "X-SNAP=\"IN\","
+ "DURATION=6.111," // ends at 21:55:53.111 -> 21:55:52.222 - 6.111
+ "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 6_111_000L)
.withAdCount(/* adGroupIndex= */ 0, 2)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 6_111_000L)
.withAdDurationsUs(/* adGroupIndex= */ 0, C.TIME_UNSET, 6_111_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 1,
new MediaItem.Builder()
.setUri("http://example.com/media-0-1.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void
handleContentTimelineChanged_snapInOutLive_snapToSameSegmentBoundaryMergedInSameAdGroup()
throws IOException {
assertThat(
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
adsLoader,
/* startAdsLoader= */ true,
/* windowOffsetInFirstPeriodUs= */ 0L,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-MEDIA-SEQUENCE:0\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:45.000\"," // -> 6_111_000L
+ "X-SNAP=\"OUT\","
+ "DURATION=2.222,"
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-1\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:47.000\"," // -> 6_111_000L
+ "X-SNAP=\"IN\","
+ "DURATION=6.111,"
+ "X-ASSET-URI=\"http://example.com/media-0-1.m3u8\""
+ "\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain1.0.ts\n"
+ "#EXTINF:6.111,\nmain2.0.ts\n" // segment start at 6.111 -> 21:55:46.111
+ "#EXTINF:6.111,\nmain3.0.ts\n" // segment start at 12.222 -> 21:55:52.222
+ "\n"))
.containsExactly(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 6_111_000L)
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withAdCount(/* adGroupIndex= */ 0, 2)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 8_333_000L)
.withAdDurationsUs(/* adGroupIndex= */ 0, 2_222_000L, 6_111_000L)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, "ad0-1")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 1,
new MediaItem.Builder()
.setUri("http://example.com/media-0-1.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void handleContentTimelineChanged_snapOut_snapToExactTargetDurationBoundaryInWindow()
throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6.111,\nmain0.ts\n"
+ "#EXTINF:6.111,\nmain1.ts\n"
+ "#EXTINF:6.111,\nmain2.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "X-SNAP=\"OUT\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:46.000Z\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:58.000Z\","
+ "X-SNAP=\"OUT\","
+ "X-ASSET-URI=\"http://example.com/media-1-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE))
.isEqualTo(
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 6_111_000L, C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAdId(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, "ad1-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build())
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-0.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()));
}
@Test
public void handleContentTimelineChanged_noDurationSet_durationTimeUnset() throws IOException {
assertThat(
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:41.123Z\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
@ -1467,6 +2157,8 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.ts\n"
+ "#EXTINF:81,\n"
+ "main0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -1597,6 +2289,8 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:16.001,\n"
+ "main0.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -1663,6 +2357,8 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:81,\n"
+ "main0.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -1752,8 +2448,10 @@ public class HlsInterstitialsAdsLoaderTest {
+ "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\""
+ "\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n";
+ "#EXTINF:9,\nmain0.0.ts\n"
+ "#EXTINF:9,\nmain1.0.ts\n"
+ "#EXTINF:9,\nmain2.0.ts\n"
+ "#EXTINF:3,\nmain3.0.ts\n";
when(mockPlayer.getContentPosition()).thenReturn(0L);
contentWindowDefinition =
contentWindowDefinition
@ -1767,7 +2465,7 @@ public class HlsInterstitialsAdsLoaderTest {
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
playlistString,
adsLoader,
/* windowIndex= */ 0,
/* windowIndex= */ 2,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE);
@ -1891,6 +2589,10 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -1935,6 +2637,8 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:81,\n"
+ "main0.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -2194,6 +2898,10 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:9,\n"
+ "main1.0.ts\n"
+ "#EXTINF:72,\n"
+ "main2.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -2306,6 +3014,10 @@ public class HlsInterstitialsAdsLoaderTest {
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXTINF:9,\n"
+ "main0.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
@ -3061,11 +3773,16 @@ public class HlsInterstitialsAdsLoaderTest {
initialWindows[windowIndex] =
contentWindowDefinition
.buildUpon()
.setPlaceholder(true)
.setDynamic(true)
.setDurationUs(C.TIME_UNSET)
.setDurationUs(durationUs)
.setWindowPositionInFirstPeriodUs(windowPositionInPeriodUs)
.build();
when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(initialWindows));
when(mockPlayer.getCurrentMediaItem()).thenReturn(contentWindowDefinition.mediaItem);
when(mockPlayer.getCurrentMediaItemIndex()).thenReturn(windowIndex);
when(mockPlayer.getCurrentPeriodIndex()).thenReturn(windowIndex);
// Set the player.
adsLoader.setPlayer(mockPlayer);
// Start the ad.
@ -3096,8 +3813,6 @@ public class HlsInterstitialsAdsLoaderTest {
.setAdPlaybackStates(ImmutableList.of(adPlaybackState.getValue()))
.build();
when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(windowsAfterTimelineChange));
when(mockPlayer.getCurrentMediaItemIndex()).thenReturn(windowIndex);
when(mockPlayer.getCurrentPeriodIndex()).thenReturn(windowIndex);
return adPlaybackState.getValue();
}

View File

@ -894,6 +894,8 @@ import java.util.concurrent.TimeoutException;
return metadata.writer;
case MediaMetadataCompat.METADATA_KEY_COMPOSER:
return metadata.composer;
case MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE:
return metadata.subtitle;
default:
return null;
}

View File

@ -61,6 +61,7 @@ public final class MediaBrowser extends MediaController {
private Looper applicationLooper;
private @MonotonicNonNull BitmapLoader bitmapLoader;
private int maxCommandsForMediaItems;
private long platformSessionCallbackAggregationTimeoutMs;
/**
* Creates a builder for {@link MediaBrowser}.
@ -78,6 +79,8 @@ public final class MediaBrowser extends MediaController {
connectionHints = Bundle.EMPTY;
listener = new Listener() {};
applicationLooper = Util.getCurrentOrMainLooper();
platformSessionCallbackAggregationTimeoutMs =
DEFAULT_PLATFORM_CALLBACK_AGGREGATION_TIMEOUT_MS;
}
/**
@ -156,6 +159,24 @@ public final class MediaBrowser extends MediaController {
return this;
}
/**
* Sets the timeout after which updates from the platform session callbacks are applied to the
* browser, in milliseconds.
*
* <p>The default is 100ms.
*
* @param platformSessionCallbackAggregationTimeoutMs The timeout, in milliseconds.
* @return The builder to allow chaining.
*/
@UnstableApi
@CanIgnoreReturnValue
public Builder experimentalSetPlatformSessionCallbackAggregationTimeoutMs(
long platformSessionCallbackAggregationTimeoutMs) {
this.platformSessionCallbackAggregationTimeoutMs =
platformSessionCallbackAggregationTimeoutMs;
return this;
}
/**
* Builds a {@link MediaBrowser} asynchronously.
*
@ -196,7 +217,8 @@ public final class MediaBrowser extends MediaController {
applicationLooper,
holder,
bitmapLoader,
maxCommandsForMediaItems);
maxCommandsForMediaItems,
platformSessionCallbackAggregationTimeoutMs);
postOrRun(new Handler(applicationLooper), () -> holder.setController(browser));
return holder;
}
@ -266,7 +288,8 @@ public final class MediaBrowser extends MediaController {
Looper applicationLooper,
ConnectionCallback connectionCallback,
@Nullable BitmapLoader bitmapLoader,
int maxCommandsForMediaItems) {
int maxCommandsForMediaItems,
long platformSessionCallbackAggregationTimeoutMs) {
super(
context,
token,
@ -275,7 +298,8 @@ public final class MediaBrowser extends MediaController {
applicationLooper,
connectionCallback,
bitmapLoader,
maxCommandsForMediaItems);
maxCommandsForMediaItems,
platformSessionCallbackAggregationTimeoutMs);
}
@Override
@ -286,12 +310,19 @@ public final class MediaBrowser extends MediaController {
SessionToken token,
Bundle connectionHints,
Looper applicationLooper,
@Nullable BitmapLoader bitmapLoader) {
@Nullable BitmapLoader bitmapLoader,
long platformSessionCallbackAggregationTimeoutMs) {
MediaBrowserImpl impl;
if (token.isLegacySession()) {
impl =
new MediaBrowserImplLegacy(
context, this, token, connectionHints, applicationLooper, checkNotNull(bitmapLoader));
context,
this,
token,
connectionHints,
applicationLooper,
checkNotNull(bitmapLoader),
platformSessionCallbackAggregationTimeoutMs);
} else {
impl = new MediaBrowserImplBase(context, this, token, connectionHints, applicationLooper);
}

View File

@ -64,8 +64,16 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
SessionToken token,
Bundle connectionHints,
Looper applicationLooper,
BitmapLoader bitmapLoader) {
super(context, instance, token, connectionHints, applicationLooper, bitmapLoader);
BitmapLoader bitmapLoader,
long platformSessionCallbackAggregationTimeoutMs) {
super(
context,
instance,
token,
connectionHints,
applicationLooper,
bitmapLoader,
platformSessionCallbackAggregationTimeoutMs);
this.instance = instance;
commandButtonsForMediaItems = ImmutableMap.of();
}

View File

@ -201,6 +201,8 @@ public class MediaController implements Player {
"MediaController method is called from a wrong thread."
+ " See javadoc of MediaController for details.";
@UnstableApi protected static final long DEFAULT_PLATFORM_CALLBACK_AGGREGATION_TIMEOUT_MS = 100L;
/** A builder for {@link MediaController}. */
public static final class Builder {
@ -211,6 +213,7 @@ public class MediaController implements Player {
private Looper applicationLooper;
private @MonotonicNonNull BitmapLoader bitmapLoader;
private int maxCommandsForMediaItems;
private long platformSessionCallbackAggregationTimeoutMs;
/**
* Creates a builder for {@link MediaController}.
@ -242,6 +245,8 @@ public class MediaController implements Player {
connectionHints = Bundle.EMPTY;
listener = new Listener() {};
applicationLooper = Util.getCurrentOrMainLooper();
platformSessionCallbackAggregationTimeoutMs =
DEFAULT_PLATFORM_CALLBACK_AGGREGATION_TIMEOUT_MS;
}
/**
@ -320,6 +325,24 @@ public class MediaController implements Player {
return this;
}
/**
* Sets the timeout after which updates from the platform session callbacks are applied to the
* browser, in milliseconds.
*
* <p>The default is 100ms.
*
* @param platformSessionCallbackAggregationTimeoutMs The timeout, in milliseconds.
* @return tThe builder to allow chaining.
*/
@UnstableApi
@CanIgnoreReturnValue
public Builder experimentalSetPlatformSessionCallbackAggregationTimeoutMs(
long platformSessionCallbackAggregationTimeoutMs) {
this.platformSessionCallbackAggregationTimeoutMs =
platformSessionCallbackAggregationTimeoutMs;
return this;
}
/**
* Builds a {@link MediaController} asynchronously.
*
@ -361,7 +384,8 @@ public class MediaController implements Player {
applicationLooper,
holder,
bitmapLoader,
maxCommandsForMediaItems);
maxCommandsForMediaItems,
platformSessionCallbackAggregationTimeoutMs);
postOrRun(new Handler(applicationLooper), () -> holder.setController(controller));
return holder;
}
@ -553,7 +577,8 @@ public class MediaController implements Player {
Looper applicationLooper,
ConnectionCallback connectionCallback,
@Nullable BitmapLoader bitmapLoader,
int maxCommandsForMediaItems) {
int maxCommandsForMediaItems,
long platformSessionCallbackAggregationTimeoutMs) {
checkNotNull(context, "context must not be null");
checkNotNull(token, "token must not be null");
Log.i(
@ -576,7 +601,14 @@ public class MediaController implements Player {
this.connectionCallback = connectionCallback;
this.maxCommandsForMediaItems = maxCommandsForMediaItems;
impl = createImpl(context, token, connectionHints, applicationLooper, bitmapLoader);
impl =
createImpl(
context,
token,
connectionHints,
applicationLooper,
bitmapLoader,
platformSessionCallbackAggregationTimeoutMs);
impl.connect();
}
@ -587,10 +619,17 @@ public class MediaController implements Player {
SessionToken token,
Bundle connectionHints,
Looper applicationLooper,
@Nullable BitmapLoader bitmapLoader) {
@Nullable BitmapLoader bitmapLoader,
long platformSessionCallbackAggregationTimeoutMs) {
if (token.isLegacySession()) {
return new MediaControllerImplLegacy(
context, this, token, connectionHints, applicationLooper, checkNotNull(bitmapLoader));
context,
this,
token,
connectionHints,
applicationLooper,
checkNotNull(bitmapLoader),
platformSessionCallbackAggregationTimeoutMs);
} else {
return new MediaControllerImplBase(context, this, token, connectionHints, applicationLooper);
}

View File

@ -93,8 +93,6 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
private static final String TAG = "MCImplLegacy";
private static final long AGGREGATES_CALLBACKS_WITHIN_TIMEOUT_MS = 500L;
/* package */ final Context context;
private final MediaController instance;
@ -104,6 +102,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
private final BitmapLoader bitmapLoader;
private final ImmutableList<CommandButton> commandButtonsForMediaItems;
private final Bundle connectionHints;
private final long platformSessionCallbackAggregationTimeoutMs;
@Nullable private MediaControllerCompat controllerCompat;
@Nullable private MediaBrowserCompat browserCompat;
@ -122,7 +121,8 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
SessionToken token,
Bundle connectionHints,
Looper applicationLooper,
BitmapLoader bitmapLoader) {
BitmapLoader bitmapLoader,
long platformSessionCallbackAggregationTimeoutMs) {
// Initialize default values.
legacyPlayerInfo = new LegacyPlayerInfo();
pendingLegacyPlayerInfo = new LegacyPlayerInfo();
@ -140,6 +140,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
this.token = token;
this.connectionHints = connectionHints;
this.bitmapLoader = bitmapLoader;
this.platformSessionCallbackAggregationTimeoutMs = platformSessionCallbackAggregationTimeoutMs;
currentPositionMs = C.TIME_UNSET;
lastSetPlayWhenReadyCalledTimeMs = C.TIME_UNSET;
// Always empty. Only supported for a MediaBrowser connected to a MediaBrowserServiceCompat.
@ -1992,7 +1993,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
return;
}
pendingChangesHandler.sendEmptyMessageDelayed(
MSG_HANDLE_PENDING_UPDATES, AGGREGATES_CALLBACKS_WITHIN_TIMEOUT_MS);
MSG_HANDLE_PENDING_UPDATES, platformSessionCallbackAggregationTimeoutMs);
}
}

View File

@ -315,7 +315,8 @@ public final class MediaMetadataCompat implements Parcelable {
METADATA_KEY_ALBUM_ARTIST,
METADATA_KEY_WRITER,
METADATA_KEY_AUTHOR,
METADATA_KEY_COMPOSER
METADATA_KEY_COMPOSER,
METADATA_KEY_DISPLAY_SUBTITLE
};
final Bundle mBundle;

View File

@ -266,6 +266,38 @@ public final class LegacyConversionsTest {
assertThat(convertedMediaItemWithDisplayTitleAndTitle.mediaMetadata.albumTitle).isNull();
}
@Test
public void
convertToMediaDescriptionCompat_withoutDisplayTitleWithSubtitle_subtitleUsedAsSubtitle() {
MediaMetadata metadata =
new MediaMetadata.Builder().setTitle("a_title").setSubtitle("a_subtitle").build();
MediaItem mediaItem =
new MediaItem.Builder().setMediaId("testId").setMediaMetadata(metadata).build();
MediaDescriptionCompat descriptionCompat =
LegacyConversions.convertToMediaDescriptionCompat(mediaItem, /* artworkBitmap= */ null);
assertThat(descriptionCompat.getTitle().toString()).isEqualTo("a_title");
assertThat(descriptionCompat.getSubtitle().toString()).isEqualTo("a_subtitle");
}
@Test
public void convertToMediaDescriptionCompat_withDisplayTitleAndSubtitle_subtitleUsedAsSubtitle() {
MediaMetadata metadata =
new MediaMetadata.Builder()
.setDisplayTitle("a_display_title")
.setSubtitle("a_subtitle")
.build();
MediaItem mediaItem =
new MediaItem.Builder().setMediaId("testId").setMediaMetadata(metadata).build();
MediaDescriptionCompat descriptionCompat =
LegacyConversions.convertToMediaDescriptionCompat(mediaItem, /* artworkBitmap= */ null);
assertThat(descriptionCompat.getTitle().toString()).isEqualTo("a_display_title");
assertThat(descriptionCompat.getSubtitle().toString()).isEqualTo("a_subtitle");
}
@Test
public void convertToQueueItemId() {
assertThat(LegacyConversions.convertToQueueItemId(C.INDEX_UNSET))

View File

@ -16,12 +16,17 @@
package androidx.media3.ui.compose
import android.content.Context
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import androidx.annotation.IntDef
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
@ -47,37 +52,53 @@ fun PlayerSurface(
modifier: Modifier = Modifier,
surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
) {
// Player might change between compositions,
// we need long-lived surface-related lambdas to always use the latest value
val currentPlayer by rememberUpdatedState(player)
when (surfaceType) {
SURFACE_TYPE_SURFACE_VIEW ->
AndroidView(
factory = {
SurfaceView(it).apply {
if (currentPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE))
currentPlayer.setVideoSurfaceView(this)
}
},
onReset = {},
modifier = modifier,
PlayerSurfaceInternal(
player,
modifier,
createView = { SurfaceView(it) },
setViewOnPlayer = { player, view -> player.setVideoSurfaceView(view) },
clearViewFromPlayer = { player, view -> player.clearVideoSurfaceView(view) },
)
SURFACE_TYPE_TEXTURE_VIEW ->
AndroidView(
factory = {
TextureView(it).apply {
if (currentPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE))
currentPlayer.setVideoTextureView(this)
}
},
onReset = {},
modifier = modifier,
PlayerSurfaceInternal(
player,
modifier,
createView = { TextureView(it) },
setViewOnPlayer = { player, view -> player.setVideoTextureView(view) },
clearViewFromPlayer = { player, view -> player.clearVideoTextureView(view) },
)
else -> throw IllegalArgumentException("Unrecognized surface type: $surfaceType")
}
}
@Composable
private fun <T : View> PlayerSurfaceInternal(
player: Player,
modifier: Modifier,
createView: (Context) -> T,
setViewOnPlayer: (Player, T) -> Unit,
clearViewFromPlayer: (Player, T) -> Unit,
) {
var view by remember { mutableStateOf<T?>(null) }
var registeredPlayer by remember { mutableStateOf<Player?>(null) }
AndroidView(factory = { createView(it).apply { view = this } }, onReset = {}, modifier = modifier)
view?.let { view ->
LaunchedEffect(view, player) {
registeredPlayer?.let { previousPlayer ->
if (previousPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE))
clearViewFromPlayer(previousPlayer, view)
registeredPlayer = null
}
if (player.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) {
setViewOnPlayer(player, view)
registeredPlayer = player
}
}
}
}
/**
* The type of surface used for media playbacks. One of [SURFACE_TYPE_SURFACE_VIEW] or
* [SURFACE_TYPE_TEXTURE_VIEW].

View File

@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.ui.compose.utils.TestPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
@ -28,6 +29,9 @@ import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.spy
/** Unit test for [PlayerSurface]. */
@RunWith(AndroidJUnit4::class)
@ -87,4 +91,30 @@ class PlayerSurfaceTest {
assertThat(player.videoOutput).isInstanceOf(SurfaceView::class.java)
}
@Test
fun playerSurface_withNewPlayer_unsetsSurfaceOnOldPlayerFirst() {
val player0 = TestPlayer()
val player1 = TestPlayer()
val spyPlayer0 = spy(ForwardingPlayer(player0))
val spyPlayer1 = spy(ForwardingPlayer(player1))
lateinit var playerIndex: MutableIntState
composeTestRule.setContent {
playerIndex = remember { mutableIntStateOf(0) }
PlayerSurface(
player = if (playerIndex.intValue == 0) spyPlayer0 else spyPlayer1,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
)
}
composeTestRule.waitForIdle()
playerIndex.intValue = 1
composeTestRule.waitForIdle()
assertThat(player0.videoOutput).isNull()
assertThat(player1.videoOutput).isNotNull()
val inOrder = inOrder(spyPlayer0, spyPlayer1)
inOrder.verify(spyPlayer0).clearVideoSurfaceView(any())
inOrder.verify(spyPlayer1).setVideoSurfaceView(any())
}
}