From d6bf2fb71a38fe8d75f1c8ce9dc0b48c16049fdb Mon Sep 17 00:00:00 2001 From: Riccardo Spagni Date: Thu, 13 Aug 2026 12:16:33 +0200 Subject: [PATCH 01/11] Add timeOffsetUs to MediaItem.SubtitleConfiguration --- .../androidx/media3/common/MediaItem.java | 48 ++++++++++++++++++- .../androidx/media3/common/MediaItemTest.java | 5 ++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java index 6be88f395a3..3d30ef454f7 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java @@ -1567,6 +1567,7 @@ public static final class Builder { private @C.RoleFlags int roleFlags; @Nullable private String label; @Nullable private String id; + private long timeOffsetUs; /** * Constructs an instance. @@ -1585,6 +1586,7 @@ private Builder(SubtitleConfiguration subtitleConfiguration) { this.roleFlags = subtitleConfiguration.roleFlags; this.label = subtitleConfiguration.label; this.id = subtitleConfiguration.id; + this.timeOffsetUs = subtitleConfiguration.timeOffsetUs; } /** Sets the {@link Uri} to the subtitle file. */ @@ -1636,6 +1638,34 @@ public Builder setId(@Nullable String id) { return this; } + /** + * Sets the offset that is added to the timestamps of the cues in this subtitle track, in + * microseconds. + * + *

A positive value shifts the cues to be displayed later relative to the media, a negative + * value shifts them to be displayed earlier. + * + *

The offset can be changed during playback by passing an updated {@link MediaItem} to + * {@code Player.replaceMediaItem(int, MediaItem)}. If only the time offsets of the {@link + * SubtitleConfiguration} instances are changed, playback continues uninterrupted and the new + * offsets apply to cues that have not been read by the renderer yet. To also apply the new + * offset to the cues currently on screen, disable and re-enable the text track, for example + * with {@code TrackSelectionParameters.Builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, + * boolean)}. + * + *

The offset only takes effect if the subtitles are parsed during extraction (the default + * behaviour of {@code DefaultMediaSourceFactory}), and is ignored by the deprecated legacy + * subtitle decoding path. + * + *

The default value is 0. + */ + @CanIgnoreReturnValue + @UnstableApi + public Builder setTimeOffsetUs(long timeOffsetUs) { + this.timeOffsetUs = timeOffsetUs; + return this; + } + /** Creates a {@link SubtitleConfiguration} from the values of this builder. */ public SubtitleConfiguration build() { return new SubtitleConfiguration(this); @@ -1671,6 +1701,12 @@ private Subtitle buildSubtitle() { */ @Nullable public final String id; + /** + * The offset that is added to the timestamps of the cues in this subtitle track, in + * microseconds. See {@link Builder#setTimeOffsetUs(long)} for details. + */ + @UnstableApi public final long timeOffsetUs; + private SubtitleConfiguration( Uri uri, String mimeType, @@ -1686,6 +1722,7 @@ private SubtitleConfiguration( this.roleFlags = roleFlags; this.label = label; this.id = id; + this.timeOffsetUs = 0; } private SubtitleConfiguration(Builder builder) { @@ -1696,6 +1733,7 @@ private SubtitleConfiguration(Builder builder) { this.roleFlags = builder.roleFlags; this.label = builder.label; this.id = builder.id; + this.timeOffsetUs = builder.timeOffsetUs; } /** Returns a {@link Builder} initialized with the values of this instance. */ @@ -1720,7 +1758,8 @@ public boolean equals(@Nullable Object obj) { && selectionFlags == other.selectionFlags && roleFlags == other.roleFlags && Objects.equals(label, other.label) - && Objects.equals(id, other.id); + && Objects.equals(id, other.id) + && timeOffsetUs == other.timeOffsetUs; } @Override @@ -1732,6 +1771,7 @@ public int hashCode() { result = 31 * result + roleFlags; result = 31 * result + (label == null ? 0 : label.hashCode()); result = 31 * result + (id == null ? 0 : id.hashCode()); + result = (int) (31L * result + timeOffsetUs); return result; } @@ -1742,6 +1782,7 @@ public int hashCode() { private static final String FIELD_ROLE_FLAGS = Util.intToStringMaxRadix(4); private static final String FIELD_LABEL = Util.intToStringMaxRadix(5); private static final String FIELD_ID = Util.intToStringMaxRadix(6); + private static final String FIELD_TIME_OFFSET_US = Util.intToStringMaxRadix(7); /** Restores a {@code SubtitleConfiguration} from a {@link Bundle}. */ @UnstableApi @@ -1753,6 +1794,7 @@ public static SubtitleConfiguration fromBundle(Bundle bundle) { @C.RoleFlags int roleFlags = bundle.getInt(FIELD_ROLE_FLAGS, 0); @Nullable String label = bundle.getString(FIELD_LABEL); @Nullable String id = bundle.getString(FIELD_ID); + long timeOffsetUs = bundle.getLong(FIELD_TIME_OFFSET_US, 0); SubtitleConfiguration.Builder builder = new SubtitleConfiguration.Builder(uri); return builder @@ -1762,6 +1804,7 @@ public static SubtitleConfiguration fromBundle(Bundle bundle) { .setRoleFlags(roleFlags) .setLabel(label) .setId(id) + .setTimeOffsetUs(timeOffsetUs) .build(); } @@ -1787,6 +1830,9 @@ public Bundle toBundle() { if (id != null) { bundle.putString(FIELD_ID, id); } + if (timeOffsetUs != 0) { + bundle.putLong(FIELD_TIME_OFFSET_US, timeOffsetUs); + } return bundle; } } diff --git a/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java b/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java index 60084362b52..27d8d946b82 100644 --- a/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java @@ -328,6 +328,7 @@ public void builderSetSubtitleConfigurations() { .setRoleFlags(C.ROLE_FLAG_ALTERNATE) .setLabel("label") .setId("id") + .setTimeOffsetUs(500_000) .build()); MediaItem mediaItem = @@ -396,6 +397,7 @@ public void createSubtitleConfigurationInstance_roundTripViaBundle_yieldsEqualIn .setRoleFlags(C.ROLE_FLAG_ALTERNATE) .setLabel("label") .setId("id") + .setTimeOffsetUs(-300_000) .build(); MediaItem.SubtitleConfiguration subtitleConfigurationFromBundle = @@ -800,6 +802,7 @@ public void createLocalConfigurationInstance_roundTripViaBundle_yieldsEqualInsta .setRoleFlags(C.ROLE_FLAG_ALTERNATE) .setLabel("label") .setId("id") + .setTimeOffsetUs(250_000) .build())) .setDrmConfiguration( new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID) @@ -953,6 +956,7 @@ public void buildUpon_individualSetters_equalsToOriginal() { .setRoleFlags(C.ROLE_FLAG_ALTERNATE) .setLabel("label") .setId("id") + .setTimeOffsetUs(250_000) .build())) .setTag(new Object()) .build(); @@ -1010,6 +1014,7 @@ public void buildUpon_wholeObjectSetters_equalsToOriginal() { .setRoleFlags(C.ROLE_FLAG_ALTERNATE) .setLabel("label") .setId("id") + .setTimeOffsetUs(250_000) .build())) .setRequestMetadata( new RequestMetadata.Builder() From 5f8c28f380da0b2fdc5545a5f4d1c3c6a50c9c16 Mon Sep 17 00:00:00 2001 From: Riccardo Spagni Date: Thu, 13 Aug 2026 12:16:33 +0200 Subject: [PATCH 02/11] Allow updating the offset of TimeOffsetMediaPeriod after creation --- .../source/TimeOffsetMediaPeriod.java | 27 +++++++++++----- .../source/TimeOffsetMediaPeriodTest.java | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java index 9c72c0cd7ba..d38f99c4e9f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java @@ -31,12 +31,12 @@ import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -/** A {@link MediaPeriod} that applies a fixed time offset to all timestamps */ +/** A {@link MediaPeriod} that applies a time offset to all timestamps */ /* package */ final class TimeOffsetMediaPeriod implements MediaPeriod, MediaPeriod.Callback { private final MediaPeriod mediaPeriod; - private final long timeOffsetUs; + private long timeOffsetUs; private @MonotonicNonNull Callback callback; /** @@ -56,6 +56,21 @@ public MediaPeriod getWrappedMediaPeriod() { return mediaPeriod; } + /** + * Updates the offset that is applied to all timestamps coming from the wrapped period. + * + *

The new offset applies to all future interactions with this period and its {@linkplain + * SampleStream sample streams}. Data already read with the previous offset is unaffected. To + * apply the new offset to the data at the current playback position, the tracks of this period + * need to be re-selected (or seeked) so that the wrapped period re-reads this data. + * + * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped period, in + * microseconds. + */ + public void updateTimeOffsetUs(long timeOffsetUs) { + this.timeOffsetUs = timeOffsetUs; + } + @Override public void prepare(Callback callback, long positionUs) { this.callback = callback; @@ -102,7 +117,7 @@ public long selectTracks( streams[i] = null; } else if (streams[i] == null || ((TimeOffsetSampleStream) streams[i]).getChildStream() != childStream) { - streams[i] = new TimeOffsetSampleStream(childStream, timeOffsetUs); + streams[i] = new TimeOffsetSampleStream(childStream); } } return startPositionUs + timeOffsetUs; @@ -192,14 +207,12 @@ public long setEndPositionUs(long endPositionUs) { : actualEndPositionUs + timeOffsetUs; } - private static final class TimeOffsetSampleStream implements SampleStream { + private final class TimeOffsetSampleStream implements SampleStream { private final SampleStream sampleStream; - private final long timeOffsetUs; - public TimeOffsetSampleStream(SampleStream sampleStream, long timeOffsetUs) { + public TimeOffsetSampleStream(SampleStream sampleStream) { this.sampleStream = sampleStream; - this.timeOffsetUs = timeOffsetUs; } public SampleStream getChildStream() { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java index d4dc94ebebb..d3a6e5b69b7 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java @@ -83,6 +83,37 @@ public void selectTracks_createsSampleStreamCorrectingOffset() throws Exception assertThat(readEndOfStreamBuffer).isTrue(); } + @Test + public void updateTimeOffsetUs_appliesNewOffsetToExistingSampleStreamsAndSeeks() throws Exception { + FakeMediaPeriod fakeMediaPeriod = + createFakeMediaPeriod( + ImmutableList.of( + oneByteSample(/* timeUs= */ 8000, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 12000, C.BUFFER_FLAG_KEY_FRAME), + END_OF_STREAM_ITEM)); + MediaPeriod spyPeriod = spy(fakeMediaPeriod); + TimeOffsetMediaPeriod timeOffsetMediaPeriod = + new TimeOffsetMediaPeriod(spyPeriod, /* timeOffsetUs= */ -3000); + prepareMediaPeriodSync(timeOffsetMediaPeriod, /* positionUs= */ 0); + FormatHolder formatHolder = new FormatHolder(); + DecoderInputBuffer inputBuffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + SampleStream sampleStream = selectTracksOnMediaPeriodAndTriggerLoading(timeOffsetMediaPeriod); + sampleStream.readData(formatHolder, inputBuffer, FLAG_REQUIRE_FORMAT); + sampleStream.readData(formatHolder, inputBuffer, /* readFlags= */ 0); + long firstReadBufferTimeUs = inputBuffer.timeUs; + + timeOffsetMediaPeriod.updateTimeOffsetUs(/* timeOffsetUs= */ 2000); + sampleStream.readData(formatHolder, inputBuffer, /* readFlags= */ 0); + long secondReadBufferTimeUs = inputBuffer.timeUs; + long seekResultTimeUs = timeOffsetMediaPeriod.seekToUs(/* positionUs= */ 7000); + + assertThat(firstReadBufferTimeUs).isEqualTo(5000); + assertThat(secondReadBufferTimeUs).isEqualTo(14000); + verify(spyPeriod).seekToUs(5000); + assertThat(seekResultTimeUs).isEqualTo(7000); + } + @Test public void setEndPositionUs_isForwardedWithTimeOffset() throws Exception { FakeMediaPeriod fakeMediaPeriod = From 680cb45269a8392fad277e9ad2a1d503063a631a Mon Sep 17 00:00:00 2001 From: Riccardo Spagni Date: Thu, 13 Aug 2026 12:16:33 +0200 Subject: [PATCH 03/11] Apply sideloaded subtitle time offsets and allow updating them during playback --- RELEASENOTES.md | 5 + .../source/DefaultMediaSourceFactory.java | 19 +- .../SideloadedSubtitlesMediaSource.java | 101 +++++++++ .../source/TimeOffsetMediaSource.java | 85 ++++++++ .../e2etest/SubtitlePlaybackTest.java | 194 ++++++++++++++++++ .../source/DefaultMediaSourceFactoryTest.java | 61 +++++- 6 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7a896e91958..647d93d774e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -87,6 +87,11 @@ * Fix reporting of late video frames with identical release timestamps so that they are reported as dropped instead of skipped. * Text: + * Add `MediaItem.SubtitleConfiguration.timeOffsetUs` to shift the + timestamps of sideloaded subtitles relative to the media. The offset + can be changed during playback with + `Player.replaceMediaItem(int, MediaItem)` without interrupting playback + ([#1976](https://github.com/androidx/media/issues/1976)). * Metadata: * Image: * DataSource: diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java index e7974d00e54..95ada38b50b 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java @@ -600,8 +600,12 @@ public MediaSource createMediaSource(MediaItem mediaItem) { if (!subtitleConfigurations.isEmpty()) { MediaSource[] mediaSources = new MediaSource[subtitleConfigurations.size() + 1]; mediaSources[0] = mediaSource; + TimeOffsetMediaSource[] subtitleSources = + parseSubtitlesDuringExtraction + ? new TimeOffsetMediaSource[subtitleConfigurations.size()] + : null; for (int i = 0; i < subtitleConfigurations.size(); i++) { - if (parseSubtitlesDuringExtraction) { + if (subtitleSources != null) { Format format = new Format.Builder() .setSampleMimeType(subtitleConfigurations.get(i).mimeType) @@ -636,9 +640,12 @@ public MediaSource createMediaSource(MediaItem mediaItem) { if (loadErrorHandlingPolicy != null) { progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); } - mediaSources[i + 1] = - progressiveMediaSourceFactory.createMediaSource( - MediaItem.fromUri(subtitleConfigurations.get(i).uri.toString())); + subtitleSources[i] = + new TimeOffsetMediaSource( + progressiveMediaSourceFactory.createMediaSource( + MediaItem.fromUri(subtitleConfigurations.get(i).uri.toString())), + subtitleConfigurations.get(i).timeOffsetUs); + mediaSources[i + 1] = subtitleSources[i]; } else { SingleSampleMediaSource.Factory singleSampleMediaSourceFactory = new SingleSampleMediaSource.Factory(dataSourceFactory); @@ -652,6 +659,10 @@ public MediaSource createMediaSource(MediaItem mediaItem) { } mediaSource = new MergingMediaSource(mediaSources); + if (subtitleSources != null) { + mediaSource = + new SideloadedSubtitlesMediaSource(mediaSource, subtitleConfigurations, subtitleSources); + } } return maybeWrapWithAdsMediaSource( mediaItem, maybeClipMediaSource(mediaItem, mediaSource, enableClippingInMediaPeriod)); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java new file mode 100644 index 00000000000..0348119293a --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import java.util.List; + +/** + * A {@link MediaSource} wrapping a merged content source and its sideloaded subtitle sources, that + * handles {@link MediaItem} updates affecting the {@linkplain MediaItem.SubtitleConfiguration + * subtitle configurations}. + * + *

{@linkplain MediaItem.SubtitleConfiguration.Builder#setTimeOffsetUs(long) Time offset} changes + * are forwarded to the corresponding {@link TimeOffsetMediaSource} instances without interrupting + * playback. Updates that change the subtitle configurations in any other way are rejected from + * {@link #canUpdateMediaItem}, so that the player falls back to re-preparing the item. + */ +/* package */ final class SideloadedSubtitlesMediaSource extends WrappingMediaSource { + + private final TimeOffsetMediaSource[] subtitleSources; + + private List subtitleConfigurations; + + /** + * Creates the media source. + * + * @param mediaSource The wrapped {@link MediaSource} merging the content source with one {@link + * TimeOffsetMediaSource} per subtitle configuration. + * @param subtitleConfigurations The {@link MediaItem.SubtitleConfiguration} instances the + * subtitle sources were created from. + * @param subtitleSources The {@link TimeOffsetMediaSource} instances wrapping the sideloaded + * subtitle sources, in the same order as {@code subtitleConfigurations}. + */ + public SideloadedSubtitlesMediaSource( + MediaSource mediaSource, + List subtitleConfigurations, + TimeOffsetMediaSource[] subtitleSources) { + super(mediaSource); + checkArgument(subtitleConfigurations.size() == subtitleSources.length); + this.subtitleConfigurations = subtitleConfigurations; + this.subtitleSources = subtitleSources; + } + + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + return super.canUpdateMediaItem(mediaItem) && canUpdateSubtitleConfigurations(mediaItem); + } + + @Override + public void updateMediaItem(MediaItem mediaItem) { + super.updateMediaItem(mediaItem); + List newSubtitleConfigurations = + checkNotNull(mediaItem.localConfiguration).subtitleConfigurations; + for (int i = 0; i < subtitleSources.length; i++) { + subtitleSources[i].setTimeOffsetUs(newSubtitleConfigurations.get(i).timeOffsetUs); + } + subtitleConfigurations = newSubtitleConfigurations; + } + + private boolean canUpdateSubtitleConfigurations(MediaItem mediaItem) { + @Nullable MediaItem.LocalConfiguration localConfiguration = mediaItem.localConfiguration; + if (localConfiguration == null + || localConfiguration.subtitleConfigurations.size() != subtitleConfigurations.size()) { + return false; + } + for (int i = 0; i < subtitleConfigurations.size(); i++) { + if (!equalsIgnoringTimeOffset( + localConfiguration.subtitleConfigurations.get(i), subtitleConfigurations.get(i))) { + return false; + } + } + return true; + } + + private static boolean equalsIgnoringTimeOffset( + MediaItem.SubtitleConfiguration subtitleConfiguration, + MediaItem.SubtitleConfiguration other) { + return subtitleConfiguration + .buildUpon() + .setTimeOffsetUs(other.timeOffsetUs) + .build() + .equals(other); + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java new file mode 100644 index 00000000000..b1ac6239e91 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source; + +import androidx.media3.exoplayer.upstream.Allocator; +import java.util.ArrayList; + +/** + * A {@link MediaSource} that applies a time offset to the timestamps of a wrapped {@link + * MediaSource}, and allows updating the offset during playback. + * + *

A positive offset shifts the samples of the wrapped source to later positions on the playback + * timeline, a negative offset shifts them to earlier positions. + * + *

The {@link androidx.media3.common.Timeline} of the wrapped source is not adjusted, so this + * source is intended to be merged with another source that defines the timeline, for example in a + * {@link MergingMediaSource}. + */ +/* package */ final class TimeOffsetMediaSource extends WrappingMediaSource { + + private final ArrayList activeMediaPeriods; + + private long timeOffsetUs; + + /** + * Creates the time offset source. + * + * @param mediaSource The wrapped {@link MediaSource}. + * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in + * microseconds. + */ + public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { + super(mediaSource); + this.timeOffsetUs = timeOffsetUs; + this.activeMediaPeriods = new ArrayList<>(); + } + + /** + * Updates the offset that is applied to all timestamps coming from the wrapped source. + * + *

Must be called on the playback thread. + * + *

The new offset is applied to all future interactions with this source and its active + * {@linkplain MediaPeriod media periods}. Data already read from the sample streams of active + * periods is unaffected, see {@link TimeOffsetMediaPeriod#updateTimeOffsetUs(long)}. + * + * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in + * microseconds. + */ + public void setTimeOffsetUs(long timeOffsetUs) { + this.timeOffsetUs = timeOffsetUs; + for (int i = 0; i < activeMediaPeriods.size(); i++) { + activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + TimeOffsetMediaPeriod mediaPeriod = + new TimeOffsetMediaPeriod( + mediaSource.createPeriod(id, allocator, startPositionUs - timeOffsetUs), timeOffsetUs); + activeMediaPeriods.add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; + activeMediaPeriods.remove(timeOffsetMediaPeriod); + mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java index 30b2a7038dd..3ccfeb911b7 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java @@ -16,6 +16,7 @@ package androidx.media3.exoplayer.e2etest; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.advance; +import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.truth.Truth.assertThat; import android.content.Context; @@ -28,6 +29,7 @@ import androidx.media3.common.MimeTypes; import androidx.media3.common.ParserException; import androidx.media3.common.Player; +import androidx.media3.common.text.CueGroup; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.analytics.AnalyticsListener; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; @@ -62,6 +64,198 @@ public class SubtitlePlaybackTest { public ShadowMediaCodecConfig mediaCodecConfig = ShadowMediaCodecConfig.withAllDefaultSupportedCodecs(); + // https://github.com/androidx/media/issues/1976 + @Test + public void sideloadedSubtitle_withPositiveTimeOffset_cuesShiftedLater() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext, clock); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, capturingRenderersFactory) + .setClock(clock) + .build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + List cueChangeTimesUs = new ArrayList<>(); + List cueTexts = new ArrayList<>(); + player.addListener(createNonEmptyCueGroupCollectingListener(cueChangeTimesUs, cueTexts)); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("asset:///media/mp4/preroll-5s.mp4") + .setSubtitleConfigurations( + ImmutableList.of( + new MediaItem.SubtitleConfiguration.Builder( + Uri.parse("asset:///media/webvtt/typical")) + .setMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .setTimeOffsetUs(300_000) + .build())) + .build(); + + player.setMediaItem(mediaItem); + player.prepare(); + advance(player).untilState(Player.STATE_READY); + advance(player).untilFullyBuffered(); + player.play(); + advance(player).untilState(Player.STATE_ENDED); + player.release(); + surface.release(); + + assertThat(cueChangeTimesUs).containsExactly(300_000L, 2_645_000L).inOrder(); + assertThat(cueTexts) + .containsExactly("This is the first subtitle.", "This is the second subtitle.") + .inOrder(); + } + + // https://github.com/androidx/media/issues/1976 + @Test + public void sideloadedSubtitle_withNegativeTimeOffset_cuesShiftedEarlier() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext, clock); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, capturingRenderersFactory) + .setClock(clock) + .build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + List cueChangeTimesUs = new ArrayList<>(); + List cueTexts = new ArrayList<>(); + player.addListener(createNonEmptyCueGroupCollectingListener(cueChangeTimesUs, cueTexts)); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("asset:///media/mp4/preroll-5s.mp4") + .setSubtitleConfigurations( + ImmutableList.of( + new MediaItem.SubtitleConfiguration.Builder( + Uri.parse("asset:///media/webvtt/typical")) + .setMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .setTimeOffsetUs(-2_000_000) + .build())) + .build(); + + player.setMediaItem(mediaItem); + player.prepare(); + advance(player).untilState(Player.STATE_READY); + advance(player).untilFullyBuffered(); + player.play(); + advance(player).untilState(Player.STATE_ENDED); + player.release(); + surface.release(); + + // The first cue ends before the start of the media, so only the second one is shown. + assertThat(cueChangeTimesUs).containsExactly(345_000L); + assertThat(cueTexts).containsExactly("This is the second subtitle."); + } + + // https://github.com/androidx/media/issues/1976 + @Test + public void sideloadedSubtitle_timeOffsetUpdatedDuringPlayback_playbackContinuesWithShiftedCues() + throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext, clock); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, capturingRenderersFactory) + .setClock(clock) + .build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + List cueChangeTimesUs = new ArrayList<>(); + List cueTexts = new ArrayList<>(); + player.addListener(createNonEmptyCueGroupCollectingListener(cueChangeTimesUs, cueTexts)); + List playbackStates = new ArrayList<>(); + player.addListener( + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + playbackStates.add(playbackState); + } + }); + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse("asset:///media/webvtt/typical")) + .setMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("asset:///media/mp4/preroll-5s.mp4") + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + + player.setMediaItem(mediaItem); + player.prepare(); + advance(player).untilState(Player.STATE_READY); + advance(player).untilFullyBuffered(); + advance(player).untilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 2000); + // Shift the subtitles two seconds later and re-enable the text track to apply the new offset + // to the cues around the current position. + player.replaceMediaItem( + /* index= */ 0, + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of( + subtitleConfiguration.buildUpon().setTimeOffsetUs(2_000_000).build())) + .build()); + player.setTrackSelectionParameters( + player + .getTrackSelectionParameters() + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, /* disabled= */ true) + .build()); + player.setTrackSelectionParameters( + player + .getTrackSelectionParameters() + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, /* disabled= */ false) + .build()); + player.play(); + advance(player).untilState(Player.STATE_ENDED); + long updatedTimeOffsetUs = + checkNotNull(player.getCurrentMediaItem().localConfiguration) + .subtitleConfigurations + .get(0) + .timeOffsetUs; + player.release(); + surface.release(); + + assertThat(updatedTimeOffsetUs).isEqualTo(2_000_000); + // The first cue is shown with the initial zero offset, then again when the text track is + // re-enabled at two seconds with the new offset, followed by the shifted second cue. + assertThat(cueChangeTimesUs).containsExactly(0L, 2_000_000L, 4_345_000L).inOrder(); + assertThat(cueTexts) + .containsExactly( + "This is the first subtitle.", + "This is the first subtitle.", + "This is the second subtitle.") + .inOrder(); + // The media item update must not interrupt playback with a re-preparation. + assertThat(playbackStates) + .containsExactly(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED) + .inOrder(); + } + + private static Player.Listener createNonEmptyCueGroupCollectingListener( + List cueChangeTimesUs, List cueTexts) { + return new Player.Listener() { + @Override + public void onCues(CueGroup cueGroup) { + if (!cueGroup.cues.isEmpty()) { + cueChangeTimesUs.add(cueGroup.presentationTimeUs); + cueTexts.add(String.valueOf(cueGroup.cues.get(0).text)); + } + } + }; + } + // https://github.com/androidx/media/issues/1721 @Test public void multipleSideloadedSubtitles_noneSelected_noneLoaded() throws Exception { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java index 1ba66100260..e35700c8a78 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java @@ -29,6 +29,7 @@ import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import java.util.Arrays; import java.util.List; import org.junit.Test; @@ -75,7 +76,7 @@ public void createMediaSource_withPath_progressiveSource() { } @Test - public void createMediaSource_withSubtitle_isMergingMediaSource() { + public void createMediaSource_withSubtitle_isSideloadedSubtitlesMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); List subtitleConfigurations = @@ -97,7 +98,63 @@ public void createMediaSource_withSubtitle_isMergingMediaSource() { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource).isInstanceOf(MergingMediaSource.class); + assertThat(mediaSource).isInstanceOf(SideloadedSubtitlesMediaSource.class); + } + + @Test + public void canUpdateMediaItem_withChangedSubtitleTimeOffset_returnsTrue() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage("en") + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(URI_MEDIA) + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + MediaItem updatedMediaItem = + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of( + subtitleConfiguration.buildUpon().setTimeOffsetUs(1_000_000).build())) + .build(); + + assertThat(mediaSource.canUpdateMediaItem(updatedMediaItem)).isTrue(); + } + + @Test + public void canUpdateMediaItem_withStructurallyChangedSubtitleConfigurations_returnsFalse() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage("en") + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(URI_MEDIA) + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + MediaItem mediaItemWithChangedLanguage = + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of(subtitleConfiguration.buildUpon().setLanguage("de").build())) + .build(); + MediaItem mediaItemWithoutSubtitles = + mediaItem.buildUpon().setSubtitleConfigurations(ImmutableList.of()).build(); + + assertThat(mediaSource.canUpdateMediaItem(mediaItemWithChangedLanguage)).isFalse(); + assertThat(mediaSource.canUpdateMediaItem(mediaItemWithoutSubtitles)).isFalse(); } @Test From 0369877c8a7f486d64d10eef08f18cd83a2bff5e Mon Sep 17 00:00:00 2001 From: Riccardo Spagni Date: Fri, 14 Aug 2026 16:41:26 +0200 Subject: [PATCH 04/11] Fold TimeOffsetMediaSource into SideloadedSubtitlesMediaSource --- .../source/DefaultMediaSourceFactory.java | 36 +++-- .../SideloadedSubtitlesMediaSource.java | 126 +++++++++++++++--- .../source/TimeOffsetMediaSource.java | 85 ------------ 3 files changed, 126 insertions(+), 121 deletions(-) delete mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java index 95ada38b50b..cc0c7c9b045 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java @@ -598,14 +598,9 @@ public MediaSource createMediaSource(MediaItem mediaItem) { List subtitleConfigurations = castNonNull(mediaItem.localConfiguration).subtitleConfigurations; if (!subtitleConfigurations.isEmpty()) { - MediaSource[] mediaSources = new MediaSource[subtitleConfigurations.size() + 1]; - mediaSources[0] = mediaSource; - TimeOffsetMediaSource[] subtitleSources = - parseSubtitlesDuringExtraction - ? new TimeOffsetMediaSource[subtitleConfigurations.size()] - : null; - for (int i = 0; i < subtitleConfigurations.size(); i++) { - if (subtitleSources != null) { + if (parseSubtitlesDuringExtraction) { + MediaSource[] subtitleMediaSources = new MediaSource[subtitleConfigurations.size()]; + for (int i = 0; i < subtitleConfigurations.size(); i++) { Format format = new Format.Builder() .setSampleMimeType(subtitleConfigurations.get(i).mimeType) @@ -640,13 +635,17 @@ public MediaSource createMediaSource(MediaItem mediaItem) { if (loadErrorHandlingPolicy != null) { progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); } - subtitleSources[i] = - new TimeOffsetMediaSource( - progressiveMediaSourceFactory.createMediaSource( - MediaItem.fromUri(subtitleConfigurations.get(i).uri.toString())), - subtitleConfigurations.get(i).timeOffsetUs); - mediaSources[i + 1] = subtitleSources[i]; - } else { + subtitleMediaSources[i] = + progressiveMediaSourceFactory.createMediaSource( + MediaItem.fromUri(subtitleConfigurations.get(i).uri.toString())); + } + mediaSource = + new SideloadedSubtitlesMediaSource( + mediaSource, subtitleConfigurations, subtitleMediaSources); + } else { + MediaSource[] mediaSources = new MediaSource[subtitleConfigurations.size() + 1]; + mediaSources[0] = mediaSource; + for (int i = 0; i < subtitleConfigurations.size(); i++) { SingleSampleMediaSource.Factory singleSampleMediaSourceFactory = new SingleSampleMediaSource.Factory(dataSourceFactory); if (loadErrorHandlingPolicy != null) { @@ -656,12 +655,7 @@ public MediaSource createMediaSource(MediaItem mediaItem) { singleSampleMediaSourceFactory.createMediaSource( subtitleConfigurations.get(i), /* durationUs= */ C.TIME_UNSET); } - } - - mediaSource = new MergingMediaSource(mediaSources); - if (subtitleSources != null) { - mediaSource = - new SideloadedSubtitlesMediaSource(mediaSource, subtitleConfigurations, subtitleSources); + mediaSource = new MergingMediaSource(mediaSources); } } return maybeWrapWithAdsMediaSource( diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index 0348119293a..706956b0281 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -20,15 +20,16 @@ import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.upstream.Allocator; +import java.util.ArrayList; import java.util.List; /** - * A {@link MediaSource} wrapping a merged content source and its sideloaded subtitle sources, that - * handles {@link MediaItem} updates affecting the {@linkplain MediaItem.SubtitleConfiguration - * subtitle configurations}. + * A {@link MediaSource} merging a content source with its sideloaded subtitle sources, applying + * the {@linkplain MediaItem.SubtitleConfiguration.Builder#setTimeOffsetUs(long) time offsets} of + * the subtitle configurations and handling {@link MediaItem} updates that change them. * - *

{@linkplain MediaItem.SubtitleConfiguration.Builder#setTimeOffsetUs(long) Time offset} changes - * are forwarded to the corresponding {@link TimeOffsetMediaSource} instances without interrupting + *

Time offset changes are forwarded to the affected subtitle sources without interrupting * playback. Updates that change the subtitle configurations in any other way are rejected from * {@link #canUpdateMediaItem}, so that the player falls back to re-preparing the item. */ @@ -41,19 +42,27 @@ /** * Creates the media source. * - * @param mediaSource The wrapped {@link MediaSource} merging the content source with one {@link - * TimeOffsetMediaSource} per subtitle configuration. - * @param subtitleConfigurations The {@link MediaItem.SubtitleConfiguration} instances the - * subtitle sources were created from. - * @param subtitleSources The {@link TimeOffsetMediaSource} instances wrapping the sideloaded - * subtitle sources, in the same order as {@code subtitleConfigurations}. + * @param contentMediaSource The content {@link MediaSource}. + * @param subtitleConfigurations The {@link MediaItem.SubtitleConfiguration} instances of the + * sideloaded subtitles. + * @param subtitleMediaSources The sideloaded subtitle {@link MediaSource} instances, in the same + * order as {@code subtitleConfigurations}. */ public SideloadedSubtitlesMediaSource( - MediaSource mediaSource, + MediaSource contentMediaSource, List subtitleConfigurations, - TimeOffsetMediaSource[] subtitleSources) { - super(mediaSource); - checkArgument(subtitleConfigurations.size() == subtitleSources.length); + MediaSource[] subtitleMediaSources) { + this( + subtitleConfigurations, + createTimeOffsetSources(subtitleConfigurations, subtitleMediaSources), + contentMediaSource); + } + + private SideloadedSubtitlesMediaSource( + List subtitleConfigurations, + TimeOffsetMediaSource[] subtitleSources, + MediaSource contentMediaSource) { + super(createMergingMediaSource(contentMediaSource, subtitleSources)); this.subtitleConfigurations = subtitleConfigurations; this.subtitleSources = subtitleSources; } @@ -98,4 +107,91 @@ private static boolean equalsIgnoringTimeOffset( .build() .equals(other); } + + private static TimeOffsetMediaSource[] createTimeOffsetSources( + List subtitleConfigurations, + MediaSource[] subtitleMediaSources) { + checkArgument(subtitleConfigurations.size() == subtitleMediaSources.length); + TimeOffsetMediaSource[] timeOffsetSources = + new TimeOffsetMediaSource[subtitleMediaSources.length]; + for (int i = 0; i < subtitleMediaSources.length; i++) { + timeOffsetSources[i] = + new TimeOffsetMediaSource( + subtitleMediaSources[i], subtitleConfigurations.get(i).timeOffsetUs); + } + return timeOffsetSources; + } + + private static MergingMediaSource createMergingMediaSource( + MediaSource contentMediaSource, TimeOffsetMediaSource[] subtitleSources) { + MediaSource[] mediaSources = new MediaSource[subtitleSources.length + 1]; + mediaSources[0] = contentMediaSource; + System.arraycopy(subtitleSources, 0, mediaSources, 1, subtitleSources.length); + return new MergingMediaSource(mediaSources); + } + + /** + * A {@link MediaSource} that applies a time offset to the timestamps of a wrapped {@link + * MediaSource}, and allows updating the offset during playback. + * + *

A positive offset shifts the samples of the wrapped source to later positions on the + * playback timeline, a negative offset shifts them to earlier positions. The {@link + * androidx.media3.common.Timeline} of the wrapped source is not adjusted, so this source relies + * on being merged with another source that defines the timeline. + */ + private static final class TimeOffsetMediaSource extends WrappingMediaSource { + + private final ArrayList activeMediaPeriods; + + private long timeOffsetUs; + + /** + * Creates the time offset source. + * + * @param mediaSource The wrapped {@link MediaSource}. + * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in + * microseconds. + */ + public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { + super(mediaSource); + this.timeOffsetUs = timeOffsetUs; + this.activeMediaPeriods = new ArrayList<>(); + } + + /** + * Updates the offset that is applied to all timestamps coming from the wrapped source. + * + *

Must be called on the playback thread. + * + *

The new offset is applied to all future interactions with this source and its active + * {@linkplain MediaPeriod media periods}. Data already read from the sample streams of active + * periods is unaffected, see {@link TimeOffsetMediaPeriod#updateTimeOffsetUs(long)}. + * + * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in + * microseconds. + */ + public void setTimeOffsetUs(long timeOffsetUs) { + this.timeOffsetUs = timeOffsetUs; + for (int i = 0; i < activeMediaPeriods.size(); i++) { + activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + TimeOffsetMediaPeriod mediaPeriod = + new TimeOffsetMediaPeriod( + mediaSource.createPeriod(id, allocator, startPositionUs - timeOffsetUs), + timeOffsetUs); + activeMediaPeriods.add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; + activeMediaPeriods.remove(timeOffsetMediaPeriod); + mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + } + } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java deleted file mode 100644 index b1ac6239e91..00000000000 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaSource.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package androidx.media3.exoplayer.source; - -import androidx.media3.exoplayer.upstream.Allocator; -import java.util.ArrayList; - -/** - * A {@link MediaSource} that applies a time offset to the timestamps of a wrapped {@link - * MediaSource}, and allows updating the offset during playback. - * - *

A positive offset shifts the samples of the wrapped source to later positions on the playback - * timeline, a negative offset shifts them to earlier positions. - * - *

The {@link androidx.media3.common.Timeline} of the wrapped source is not adjusted, so this - * source is intended to be merged with another source that defines the timeline, for example in a - * {@link MergingMediaSource}. - */ -/* package */ final class TimeOffsetMediaSource extends WrappingMediaSource { - - private final ArrayList activeMediaPeriods; - - private long timeOffsetUs; - - /** - * Creates the time offset source. - * - * @param mediaSource The wrapped {@link MediaSource}. - * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in - * microseconds. - */ - public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { - super(mediaSource); - this.timeOffsetUs = timeOffsetUs; - this.activeMediaPeriods = new ArrayList<>(); - } - - /** - * Updates the offset that is applied to all timestamps coming from the wrapped source. - * - *

Must be called on the playback thread. - * - *

The new offset is applied to all future interactions with this source and its active - * {@linkplain MediaPeriod media periods}. Data already read from the sample streams of active - * periods is unaffected, see {@link TimeOffsetMediaPeriod#updateTimeOffsetUs(long)}. - * - * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in - * microseconds. - */ - public void setTimeOffsetUs(long timeOffsetUs) { - this.timeOffsetUs = timeOffsetUs; - for (int i = 0; i < activeMediaPeriods.size(); i++) { - activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); - } - } - - @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { - TimeOffsetMediaPeriod mediaPeriod = - new TimeOffsetMediaPeriod( - mediaSource.createPeriod(id, allocator, startPositionUs - timeOffsetUs), timeOffsetUs); - activeMediaPeriods.add(mediaPeriod); - return mediaPeriod; - } - - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; - activeMediaPeriods.remove(timeOffsetMediaPeriod); - mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); - } -} From d16b77cf744436a0cb034ccc90dd90dfc6ea9103 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 15:09:40 +0100 Subject: [PATCH 05/11] Format with google-java-format and polish style --- .../androidx/media3/common/MediaItem.java | 4 ++-- .../SideloadedSubtitlesMediaSource.java | 6 +++--- .../e2etest/SubtitlePlaybackTest.java | 20 +++++++++---------- .../source/DefaultMediaSourceFactoryTest.java | 3 +-- .../source/TimeOffsetMediaPeriodTest.java | 3 ++- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java index 3d30ef454f7..1ba5e573638 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java @@ -1654,7 +1654,7 @@ public Builder setId(@Nullable String id) { * boolean)}. * *

The offset only takes effect if the subtitles are parsed during extraction (the default - * behaviour of {@code DefaultMediaSourceFactory}), and is ignored by the deprecated legacy + * behavior of {@code DefaultMediaSourceFactory}), and is ignored by the deprecated legacy * subtitle decoding path. * *

The default value is 0. @@ -1771,7 +1771,7 @@ public int hashCode() { result = 31 * result + roleFlags; result = 31 * result + (label == null ? 0 : label.hashCode()); result = 31 * result + (id == null ? 0 : id.hashCode()); - result = (int) (31L * result + timeOffsetUs); + result = 31 * result + (int) (timeOffsetUs ^ (timeOffsetUs >>> 32)); return result; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index 706956b0281..0a7de92a964 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -25,9 +25,9 @@ import java.util.List; /** - * A {@link MediaSource} merging a content source with its sideloaded subtitle sources, applying - * the {@linkplain MediaItem.SubtitleConfiguration.Builder#setTimeOffsetUs(long) time offsets} of - * the subtitle configurations and handling {@link MediaItem} updates that change them. + * A {@link MediaSource} merging a content source with its sideloaded subtitle sources, applying the + * {@linkplain MediaItem.SubtitleConfiguration.Builder#setTimeOffsetUs(long) time offsets} of the + * subtitle configurations and handling {@link MediaItem} updates that change them. * *

Time offset changes are forwarded to the affected subtitle sources without interrupting * playback. Updates that change the subtitle configurations in any other way are rejected from diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java index 3ccfeb911b7..345ca6777f6 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java @@ -170,14 +170,6 @@ public void sideloadedSubtitle_timeOffsetUpdatedDuringPlayback_playbackContinues List cueChangeTimesUs = new ArrayList<>(); List cueTexts = new ArrayList<>(); player.addListener(createNonEmptyCueGroupCollectingListener(cueChangeTimesUs, cueTexts)); - List playbackStates = new ArrayList<>(); - player.addListener( - new Player.Listener() { - @Override - public void onPlaybackStateChanged(@Player.State int playbackState) { - playbackStates.add(playbackState); - } - }); MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(Uri.parse("asset:///media/webvtt/typical")) .setMimeType(MimeTypes.TEXT_VTT) @@ -195,6 +187,14 @@ public void onPlaybackStateChanged(@Player.State int playbackState) { advance(player).untilState(Player.STATE_READY); advance(player).untilFullyBuffered(); advance(player).untilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 2000); + List playbackStatesAfterUpdate = new ArrayList<>(); + player.addListener( + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + playbackStatesAfterUpdate.add(playbackState); + } + }); // Shift the subtitles two seconds later and re-enable the text track to apply the new offset // to the cues around the current position. player.replaceMediaItem( @@ -238,9 +238,7 @@ public void onPlaybackStateChanged(@Player.State int playbackState) { "This is the second subtitle.") .inOrder(); // The media item update must not interrupt playback with a re-preparation. - assertThat(playbackStates) - .containsExactly(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED) - .inOrder(); + assertThat(playbackStatesAfterUpdate).containsExactly(Player.STATE_ENDED); } private static Player.Listener createNonEmptyCueGroupCollectingListener( diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java index e35700c8a78..0beae68604a 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java @@ -30,7 +30,6 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; -import java.util.Arrays; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -80,7 +79,7 @@ public void createMediaSource_withSubtitle_isSideloadedSubtitlesMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); List subtitleConfigurations = - Arrays.asList( + ImmutableList.of( new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) .setMimeType(MimeTypes.APPLICATION_TTML) .setLanguage("en") diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java index d3a6e5b69b7..a2c07db78e1 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriodTest.java @@ -84,7 +84,8 @@ public void selectTracks_createsSampleStreamCorrectingOffset() throws Exception } @Test - public void updateTimeOffsetUs_appliesNewOffsetToExistingSampleStreamsAndSeeks() throws Exception { + public void updateTimeOffsetUs_appliesNewOffsetToExistingSampleStreamsAndSeeks() + throws Exception { FakeMediaPeriod fakeMediaPeriod = createFakeMediaPeriod( ImmutableList.of( From 5b20540f20f1dafee90a04759b860161bfbf4010 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 18:00:23 +0100 Subject: [PATCH 06/11] Address review comments --- .../SideloadedSubtitlesMediaSource.java | 169 ++++++++---------- .../source/TimeOffsetMediaPeriod.java | 4 +- .../e2etest/SubtitlePlaybackTest.java | 83 ++++++++- .../source/DefaultMediaSourceFactoryTest.java | 56 ------ .../SideloadedSubtitlesMediaSourceTest.java | 103 +++++++++++ 5 files changed, 256 insertions(+), 159 deletions(-) create mode 100644 libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSourceTest.java diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index 0a7de92a964..a65cd61eb9c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -35,9 +35,8 @@ */ /* package */ final class SideloadedSubtitlesMediaSource extends WrappingMediaSource { - private final TimeOffsetMediaSource[] subtitleSources; - - private List subtitleConfigurations; + private final long[] timeOffsetsUs; + private final ArrayList[] activeMediaPeriods; /** * Creates the media source. @@ -53,18 +52,24 @@ public SideloadedSubtitlesMediaSource( List subtitleConfigurations, MediaSource[] subtitleMediaSources) { this( + contentMediaSource, subtitleConfigurations, - createTimeOffsetSources(subtitleConfigurations, subtitleMediaSources), - contentMediaSource); + subtitleMediaSources, + createTimeOffsetsUs(subtitleConfigurations), + createActiveMediaPeriodsArray(subtitleMediaSources.length)); } private SideloadedSubtitlesMediaSource( + MediaSource contentMediaSource, List subtitleConfigurations, - TimeOffsetMediaSource[] subtitleSources, - MediaSource contentMediaSource) { - super(createMergingMediaSource(contentMediaSource, subtitleSources)); - this.subtitleConfigurations = subtitleConfigurations; - this.subtitleSources = subtitleSources; + MediaSource[] subtitleMediaSources, + long[] timeOffsetsUs, + ArrayList[] activeMediaPeriods) { + super( + createMergingMediaSource( + contentMediaSource, subtitleMediaSources, timeOffsetsUs, activeMediaPeriods)); + this.timeOffsetsUs = timeOffsetsUs; + this.activeMediaPeriods = activeMediaPeriods; } @Override @@ -77,21 +82,29 @@ public void updateMediaItem(MediaItem mediaItem) { super.updateMediaItem(mediaItem); List newSubtitleConfigurations = checkNotNull(mediaItem.localConfiguration).subtitleConfigurations; - for (int i = 0; i < subtitleSources.length; i++) { - subtitleSources[i].setTimeOffsetUs(newSubtitleConfigurations.get(i).timeOffsetUs); + for (int i = 0; i < timeOffsetsUs.length; i++) { + long newTimeOffsetUs = newSubtitleConfigurations.get(i).timeOffsetUs; + timeOffsetsUs[i] = newTimeOffsetUs; + for (int j = 0; j < activeMediaPeriods[i].size(); j++) { + activeMediaPeriods[i].get(j).updateTimeOffsetUs(newTimeOffsetUs); + } } - subtitleConfigurations = newSubtitleConfigurations; } private boolean canUpdateSubtitleConfigurations(MediaItem mediaItem) { @Nullable MediaItem.LocalConfiguration localConfiguration = mediaItem.localConfiguration; + @Nullable + MediaItem.LocalConfiguration currentLocalConfiguration = getMediaItem().localConfiguration; if (localConfiguration == null - || localConfiguration.subtitleConfigurations.size() != subtitleConfigurations.size()) { + || currentLocalConfiguration == null + || localConfiguration.subtitleConfigurations.size() + != currentLocalConfiguration.subtitleConfigurations.size()) { return false; } - for (int i = 0; i < subtitleConfigurations.size(); i++) { + for (int i = 0; i < currentLocalConfiguration.subtitleConfigurations.size(); i++) { if (!equalsIgnoringTimeOffset( - localConfiguration.subtitleConfigurations.get(i), subtitleConfigurations.get(i))) { + localConfiguration.subtitleConfigurations.get(i), + currentLocalConfiguration.subtitleConfigurations.get(i))) { return false; } } @@ -108,90 +121,56 @@ private static boolean equalsIgnoringTimeOffset( .equals(other); } - private static TimeOffsetMediaSource[] createTimeOffsetSources( - List subtitleConfigurations, - MediaSource[] subtitleMediaSources) { - checkArgument(subtitleConfigurations.size() == subtitleMediaSources.length); - TimeOffsetMediaSource[] timeOffsetSources = - new TimeOffsetMediaSource[subtitleMediaSources.length]; - for (int i = 0; i < subtitleMediaSources.length; i++) { - timeOffsetSources[i] = - new TimeOffsetMediaSource( - subtitleMediaSources[i], subtitleConfigurations.get(i).timeOffsetUs); + private static long[] createTimeOffsetsUs( + List subtitleConfigurations) { + long[] timeOffsetsUs = new long[subtitleConfigurations.size()]; + for (int i = 0; i < subtitleConfigurations.size(); i++) { + timeOffsetsUs[i] = subtitleConfigurations.get(i).timeOffsetUs; } - return timeOffsetSources; + return timeOffsetsUs; } - private static MergingMediaSource createMergingMediaSource( - MediaSource contentMediaSource, TimeOffsetMediaSource[] subtitleSources) { - MediaSource[] mediaSources = new MediaSource[subtitleSources.length + 1]; - mediaSources[0] = contentMediaSource; - System.arraycopy(subtitleSources, 0, mediaSources, 1, subtitleSources.length); - return new MergingMediaSource(mediaSources); - } - - /** - * A {@link MediaSource} that applies a time offset to the timestamps of a wrapped {@link - * MediaSource}, and allows updating the offset during playback. - * - *

A positive offset shifts the samples of the wrapped source to later positions on the - * playback timeline, a negative offset shifts them to earlier positions. The {@link - * androidx.media3.common.Timeline} of the wrapped source is not adjusted, so this source relies - * on being merged with another source that defines the timeline. - */ - private static final class TimeOffsetMediaSource extends WrappingMediaSource { - - private final ArrayList activeMediaPeriods; - - private long timeOffsetUs; - - /** - * Creates the time offset source. - * - * @param mediaSource The wrapped {@link MediaSource}. - * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in - * microseconds. - */ - public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { - super(mediaSource); - this.timeOffsetUs = timeOffsetUs; - this.activeMediaPeriods = new ArrayList<>(); - } - - /** - * Updates the offset that is applied to all timestamps coming from the wrapped source. - * - *

Must be called on the playback thread. - * - *

The new offset is applied to all future interactions with this source and its active - * {@linkplain MediaPeriod media periods}. Data already read from the sample streams of active - * periods is unaffected, see {@link TimeOffsetMediaPeriod#updateTimeOffsetUs(long)}. - * - * @param timeOffsetUs The offset to apply to all timestamps coming from the wrapped source, in - * microseconds. - */ - public void setTimeOffsetUs(long timeOffsetUs) { - this.timeOffsetUs = timeOffsetUs; - for (int i = 0; i < activeMediaPeriods.size(); i++) { - activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); - } - } - - @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { - TimeOffsetMediaPeriod mediaPeriod = - new TimeOffsetMediaPeriod( - mediaSource.createPeriod(id, allocator, startPositionUs - timeOffsetUs), - timeOffsetUs); - activeMediaPeriods.add(mediaPeriod); - return mediaPeriod; + @SuppressWarnings("unchecked") + private static ArrayList[] createActiveMediaPeriodsArray(int length) { + ArrayList[] array = new ArrayList[length]; + for (int i = 0; i < length; i++) { + array[i] = new ArrayList<>(); } + return array; + } - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; - activeMediaPeriods.remove(timeOffsetMediaPeriod); - mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + private static MergingMediaSource createMergingMediaSource( + MediaSource contentMediaSource, + MediaSource[] subtitleMediaSources, + long[] timeOffsetsUs, + ArrayList[] activeMediaPeriods) { + checkArgument(subtitleMediaSources.length == timeOffsetsUs.length); + MediaSource[] mediaSources = new MediaSource[subtitleMediaSources.length + 1]; + mediaSources[0] = contentMediaSource; + for (int i = 0; i < subtitleMediaSources.length; i++) { + int subtitleIndex = i; + mediaSources[i + 1] = + new WrappingMediaSource(subtitleMediaSources[i]) { + @Override + public MediaPeriod createPeriod( + MediaPeriodId id, Allocator allocator, long startPositionUs) { + TimeOffsetMediaPeriod mediaPeriod = + new TimeOffsetMediaPeriod( + mediaSource.createPeriod( + id, allocator, startPositionUs - timeOffsetsUs[subtitleIndex]), + timeOffsetsUs[subtitleIndex]); + activeMediaPeriods[subtitleIndex].add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; + activeMediaPeriods[subtitleIndex].remove(timeOffsetMediaPeriod); + mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + } + }; } + return new MergingMediaSource(mediaSources); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java index d38f99c4e9f..8b680da99d5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimeOffsetMediaPeriod.java @@ -211,11 +211,11 @@ private final class TimeOffsetSampleStream implements SampleStream { private final SampleStream sampleStream; - public TimeOffsetSampleStream(SampleStream sampleStream) { + private TimeOffsetSampleStream(SampleStream sampleStream) { this.sampleStream = sampleStream; } - public SampleStream getChildStream() { + private SampleStream getChildStream() { return sampleStream; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java index 345ca6777f6..57718ec33bf 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java @@ -64,7 +64,6 @@ public class SubtitlePlaybackTest { public ShadowMediaCodecConfig mediaCodecConfig = ShadowMediaCodecConfig.withAllDefaultSupportedCodecs(); - // https://github.com/androidx/media/issues/1976 @Test public void sideloadedSubtitle_withPositiveTimeOffset_cuesShiftedLater() throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); @@ -109,7 +108,6 @@ public void sideloadedSubtitle_withPositiveTimeOffset_cuesShiftedLater() throws .inOrder(); } - // https://github.com/androidx/media/issues/1976 @Test public void sideloadedSubtitle_withNegativeTimeOffset_cuesShiftedEarlier() throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); @@ -153,10 +151,10 @@ public void sideloadedSubtitle_withNegativeTimeOffset_cuesShiftedEarlier() throw assertThat(cueTexts).containsExactly("This is the second subtitle."); } - // https://github.com/androidx/media/issues/1976 @Test - public void sideloadedSubtitle_timeOffsetUpdatedDuringPlayback_playbackContinuesWithShiftedCues() - throws Exception { + public void + sideloadedSubtitle_timeOffsetUpdatedDuringPlaybackWithTextTrackReenabled_playbackContinuesWithShiftedCues() + throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true); CapturingRenderersFactory capturingRenderersFactory = @@ -186,7 +184,7 @@ public void sideloadedSubtitle_timeOffsetUpdatedDuringPlayback_playbackContinues player.prepare(); advance(player).untilState(Player.STATE_READY); advance(player).untilFullyBuffered(); - advance(player).untilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 2000); + advance(player).untilPositionAtLeast(/* positionMs= */ 2000); List playbackStatesAfterUpdate = new ArrayList<>(); player.addListener( new Player.Listener() { @@ -241,6 +239,79 @@ public void onPlaybackStateChanged(@Player.State int playbackState) { assertThat(playbackStatesAfterUpdate).containsExactly(Player.STATE_ENDED); } + @Test + public void + sideloadedSubtitle_timeOffsetUpdatedDuringPlaybackWithoutTextTrackReenabled_appliesToFutureCuesOnly() + throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + FakeClock clock = new FakeClock(/* isAutoAdvancing= */ true); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext, clock); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, capturingRenderersFactory) + .setClock(clock) + .build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + List cueChangeTimesUs = new ArrayList<>(); + List cueTexts = new ArrayList<>(); + player.addListener(createNonEmptyCueGroupCollectingListener(cueChangeTimesUs, cueTexts)); + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse("asset:///media/webvtt/typical")) + .setMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("asset:///media/mp4/preroll-5s.mp4") + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + + player.setMediaItem(mediaItem); + player.prepare(); + advance(player).untilState(Player.STATE_READY); + advance(player).untilFullyBuffered(); + advance(player).untilPositionAtLeast(/* positionMs= */ 1000); + List playbackStatesAfterUpdate = new ArrayList<>(); + player.addListener( + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + playbackStatesAfterUpdate.add(playbackState); + } + }); + // Shift the subtitles one second later. Without re-enabling the text track, the new offset + // only applies to future cues that haven't been read by the renderer yet. + player.replaceMediaItem( + /* index= */ 0, + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of( + subtitleConfiguration.buildUpon().setTimeOffsetUs(1_000_000).build())) + .build()); + player.play(); + advance(player).untilState(Player.STATE_ENDED); + long updatedTimeOffsetUs = + checkNotNull(player.getCurrentMediaItem().localConfiguration) + .subtitleConfigurations + .get(0) + .timeOffsetUs; + player.release(); + surface.release(); + + assertThat(updatedTimeOffsetUs).isEqualTo(1_000_000); + // The first cue is shown with the initial zero offset, and the second cue is shown with the + // new shifted offset. + assertThat(cueChangeTimesUs).containsExactly(0L, 3_345_000L).inOrder(); + assertThat(cueTexts) + .containsExactly("This is the first subtitle.", "This is the second subtitle.") + .inOrder(); + // The media item update must not interrupt playback with a re-preparation. + assertThat(playbackStatesAfterUpdate).containsExactly(Player.STATE_ENDED); + } + private static Player.Listener createNonEmptyCueGroupCollectingListener( List cueChangeTimesUs, List cueTexts) { return new Player.Listener() { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java index 0beae68604a..4d648fb4a2a 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactoryTest.java @@ -100,62 +100,6 @@ public void createMediaSource_withSubtitle_isSideloadedSubtitlesMediaSource() { assertThat(mediaSource).isInstanceOf(SideloadedSubtitlesMediaSource.class); } - @Test - public void canUpdateMediaItem_withChangedSubtitleTimeOffset_returnsTrue() { - DefaultMediaSourceFactory defaultMediaSourceFactory = - new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); - MediaItem.SubtitleConfiguration subtitleConfiguration = - new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) - .setMimeType(MimeTypes.APPLICATION_TTML) - .setLanguage("en") - .build(); - MediaItem mediaItem = - new MediaItem.Builder() - .setUri(URI_MEDIA) - .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) - .build(); - MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - - MediaItem updatedMediaItem = - mediaItem - .buildUpon() - .setSubtitleConfigurations( - ImmutableList.of( - subtitleConfiguration.buildUpon().setTimeOffsetUs(1_000_000).build())) - .build(); - - assertThat(mediaSource.canUpdateMediaItem(updatedMediaItem)).isTrue(); - } - - @Test - public void canUpdateMediaItem_withStructurallyChangedSubtitleConfigurations_returnsFalse() { - DefaultMediaSourceFactory defaultMediaSourceFactory = - new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); - MediaItem.SubtitleConfiguration subtitleConfiguration = - new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) - .setMimeType(MimeTypes.APPLICATION_TTML) - .setLanguage("en") - .build(); - MediaItem mediaItem = - new MediaItem.Builder() - .setUri(URI_MEDIA) - .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) - .build(); - MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - - MediaItem mediaItemWithChangedLanguage = - mediaItem - .buildUpon() - .setSubtitleConfigurations( - ImmutableList.of(subtitleConfiguration.buildUpon().setLanguage("de").build())) - .build(); - MediaItem mediaItemWithoutSubtitles = - mediaItem.buildUpon().setSubtitleConfigurations(ImmutableList.of()).build(); - - assertThat(mediaSource.canUpdateMediaItem(mediaItemWithChangedLanguage)).isFalse(); - assertThat(mediaSource.canUpdateMediaItem(mediaItemWithoutSubtitles)).isFalse(); - } - @Test public void createMediaSource_withStartPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSourceTest.java new file mode 100644 index 00000000000..fe83233f25a --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSourceTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.test.utils.FakeMediaSource; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link SideloadedSubtitlesMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public final class SideloadedSubtitlesMediaSourceTest { + + private static final String URI_MEDIA = "http://example.com/media.mp4"; + private static final String URI_TEXT = "http://example.com/subtitles.vtt"; + + @Test + public void canUpdateMediaItem_withChangedSubtitleTimeOffset_returnsTrue() { + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage("en") + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(URI_MEDIA) + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + FakeMediaSource contentMediaSource = new FakeMediaSource(); + contentMediaSource.setCanUpdateMediaItems(true); + contentMediaSource.updateMediaItem(mediaItem); + MediaSource sideloadedSubtitlesMediaSource = + new SideloadedSubtitlesMediaSource( + contentMediaSource, + ImmutableList.of(subtitleConfiguration), + new MediaSource[] {new FakeMediaSource()}); + + MediaItem updatedMediaItem = + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of( + subtitleConfiguration.buildUpon().setTimeOffsetUs(1_000_000).build())) + .build(); + + assertThat(sideloadedSubtitlesMediaSource.canUpdateMediaItem(updatedMediaItem)).isTrue(); + } + + @Test + public void canUpdateMediaItem_withStructurallyChangedSubtitleConfigurations_returnsFalse() { + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse(URI_TEXT)) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage("en") + .build(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(URI_MEDIA) + .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) + .build(); + FakeMediaSource contentMediaSource = new FakeMediaSource(); + contentMediaSource.setCanUpdateMediaItems(true); + contentMediaSource.updateMediaItem(mediaItem); + MediaSource sideloadedSubtitlesMediaSource = + new SideloadedSubtitlesMediaSource( + contentMediaSource, + ImmutableList.of(subtitleConfiguration), + new MediaSource[] {new FakeMediaSource()}); + + MediaItem mediaItemWithChangedLanguage = + mediaItem + .buildUpon() + .setSubtitleConfigurations( + ImmutableList.of(subtitleConfiguration.buildUpon().setLanguage("de").build())) + .build(); + MediaItem mediaItemWithoutSubtitles = + mediaItem.buildUpon().setSubtitleConfigurations(ImmutableList.of()).build(); + + assertThat(sideloadedSubtitlesMediaSource.canUpdateMediaItem(mediaItemWithChangedLanguage)) + .isFalse(); + assertThat(sideloadedSubtitlesMediaSource.canUpdateMediaItem(mediaItemWithoutSubtitles)) + .isFalse(); + } +} From d0c83386191423c5b27b67d6c8188ff6c249f887 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 18:05:08 +0100 Subject: [PATCH 07/11] Clarify ExoPlayer specific notes in MediaItem Javadoc --- .../androidx/media3/common/MediaItem.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java index 1ba5e573638..961d2d68be6 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java @@ -1645,17 +1645,20 @@ public Builder setId(@Nullable String id) { *

A positive value shifts the cues to be displayed later relative to the media, a negative * value shifts them to be displayed earlier. * - *

The offset can be changed during playback by passing an updated {@link MediaItem} to - * {@code Player.replaceMediaItem(int, MediaItem)}. If only the time offsets of the {@link - * SubtitleConfiguration} instances are changed, playback continues uninterrupted and the new - * offsets apply to cues that have not been read by the renderer yet. To also apply the new - * offset to the cues currently on screen, disable and re-enable the text track, for example - * with {@code TrackSelectionParameters.Builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, - * boolean)}. + *

ExoPlayer specific notes: * - *

The offset only takes effect if the subtitles are parsed during extraction (the default - * behavior of {@code DefaultMediaSourceFactory}), and is ignored by the deprecated legacy - * subtitle decoding path. + *

* *

The default value is 0. */ From 8be73266b6e0637c0c68a02e286b1e8ba779d3da Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 18:26:47 +0100 Subject: [PATCH 08/11] Refactor SideloadedSubtitlesMediaSource to extend CompositeMediaSource directly --- .../SideloadedSubtitlesMediaSource.java | 155 +++++++++--------- 1 file changed, 78 insertions(+), 77 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index a65cd61eb9c..46293031a73 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -20,7 +20,10 @@ import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.upstream.Allocator; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; @@ -33,10 +36,16 @@ * playback. Updates that change the subtitle configurations in any other way are rejected from * {@link #canUpdateMediaItem}, so that the player falls back to re-preparing the item. */ -/* package */ final class SideloadedSubtitlesMediaSource extends WrappingMediaSource { +/* package */ final class SideloadedSubtitlesMediaSource extends CompositeMediaSource { + private static final int CONTENT_MEDIA_SOURCE_INDEX = 0; + private static final int FIRST_SUBTITLE_MEDIA_SOURCE_INDEX = 1; + + private final MediaSource contentMediaSource; + private final MediaSource[] subtitleMediaSources; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final long[] timeOffsetsUs; - private final ArrayList[] activeMediaPeriods; + private final List> activeMediaPeriods; /** * Creates the media source. @@ -51,46 +60,91 @@ public SideloadedSubtitlesMediaSource( MediaSource contentMediaSource, List subtitleConfigurations, MediaSource[] subtitleMediaSources) { - this( - contentMediaSource, - subtitleConfigurations, - subtitleMediaSources, - createTimeOffsetsUs(subtitleConfigurations), - createActiveMediaPeriodsArray(subtitleMediaSources.length)); + checkArgument(subtitleConfigurations.size() == subtitleMediaSources.length); + this.contentMediaSource = contentMediaSource; + this.subtitleMediaSources = subtitleMediaSources; + this.compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); + this.timeOffsetsUs = new long[subtitleConfigurations.size()]; + this.activeMediaPeriods = new ArrayList<>(subtitleMediaSources.length); + for (int i = 0; i < subtitleConfigurations.size(); i++) { + this.timeOffsetsUs[i] = subtitleConfigurations.get(i).timeOffsetUs; + this.activeMediaPeriods.add(new ArrayList<>()); + } } - private SideloadedSubtitlesMediaSource( - MediaSource contentMediaSource, - List subtitleConfigurations, - MediaSource[] subtitleMediaSources, - long[] timeOffsetsUs, - ArrayList[] activeMediaPeriods) { - super( - createMergingMediaSource( - contentMediaSource, subtitleMediaSources, timeOffsetsUs, activeMediaPeriods)); - this.timeOffsetsUs = timeOffsetsUs; - this.activeMediaPeriods = activeMediaPeriods; + @Override + public MediaItem getMediaItem() { + return contentMediaSource.getMediaItem(); } @Override public boolean canUpdateMediaItem(MediaItem mediaItem) { - return super.canUpdateMediaItem(mediaItem) && canUpdateSubtitleConfigurations(mediaItem); + return contentMediaSource.canUpdateMediaItem(mediaItem) + && canUpdateSubtitleConfigurations(mediaItem); } @Override public void updateMediaItem(MediaItem mediaItem) { - super.updateMediaItem(mediaItem); - List newSubtitleConfigurations = + contentMediaSource.updateMediaItem(mediaItem); + ImmutableList newSubtitleConfigurations = checkNotNull(mediaItem.localConfiguration).subtitleConfigurations; for (int i = 0; i < timeOffsetsUs.length; i++) { long newTimeOffsetUs = newSubtitleConfigurations.get(i).timeOffsetUs; timeOffsetsUs[i] = newTimeOffsetUs; - for (int j = 0; j < activeMediaPeriods[i].size(); j++) { - activeMediaPeriods[i].get(j).updateTimeOffsetUs(newTimeOffsetUs); + for (int j = 0; j < activeMediaPeriods.get(i).size(); j++) { + activeMediaPeriods.get(i).get(j).updateTimeOffsetUs(newTimeOffsetUs); } } } + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + for (int i = 0; i < subtitleMediaSources.length; i++) { + prepareChildSource(FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i, subtitleMediaSources[i]); + } + prepareChildSource(CONTENT_MEDIA_SOURCE_INDEX, contentMediaSource); + } + + @Override + protected void onChildSourceInfoRefreshed( + Integer childSourceId, MediaSource mediaSource, Timeline newTimeline) { + if (childSourceId == CONTENT_MEDIA_SOURCE_INDEX) { + refreshSourceInfo(newTimeline); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + MediaPeriod[] periods = new MediaPeriod[subtitleMediaSources.length + 1]; + periods[CONTENT_MEDIA_SOURCE_INDEX] = + contentMediaSource.createPeriod(id, allocator, startPositionUs); + for (int i = 0; i < subtitleMediaSources.length; i++) { + TimeOffsetMediaPeriod period = + new TimeOffsetMediaPeriod( + subtitleMediaSources[i].createPeriod( + id, allocator, startPositionUs - timeOffsetsUs[i]), + timeOffsetsUs[i]); + periods[FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i] = period; + activeMediaPeriods.get(i).add(period); + } + return new MergingMediaPeriod( + compositeSequenceableLoaderFactory, new long[periods.length], periods); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MergingMediaPeriod mergingMediaPeriod = (MergingMediaPeriod) mediaPeriod; + contentMediaSource.releasePeriod(mergingMediaPeriod.getChildPeriod(CONTENT_MEDIA_SOURCE_INDEX)); + for (int i = 0; i < subtitleMediaSources.length; i++) { + TimeOffsetMediaPeriod timeOffsetMediaPeriod = + (TimeOffsetMediaPeriod) + mergingMediaPeriod.getChildPeriod(FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i); + activeMediaPeriods.get(i).remove(timeOffsetMediaPeriod); + subtitleMediaSources[i].releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + } + } + private boolean canUpdateSubtitleConfigurations(MediaItem mediaItem) { @Nullable MediaItem.LocalConfiguration localConfiguration = mediaItem.localConfiguration; @Nullable @@ -120,57 +174,4 @@ private static boolean equalsIgnoringTimeOffset( .build() .equals(other); } - - private static long[] createTimeOffsetsUs( - List subtitleConfigurations) { - long[] timeOffsetsUs = new long[subtitleConfigurations.size()]; - for (int i = 0; i < subtitleConfigurations.size(); i++) { - timeOffsetsUs[i] = subtitleConfigurations.get(i).timeOffsetUs; - } - return timeOffsetsUs; - } - - @SuppressWarnings("unchecked") - private static ArrayList[] createActiveMediaPeriodsArray(int length) { - ArrayList[] array = new ArrayList[length]; - for (int i = 0; i < length; i++) { - array[i] = new ArrayList<>(); - } - return array; - } - - private static MergingMediaSource createMergingMediaSource( - MediaSource contentMediaSource, - MediaSource[] subtitleMediaSources, - long[] timeOffsetsUs, - ArrayList[] activeMediaPeriods) { - checkArgument(subtitleMediaSources.length == timeOffsetsUs.length); - MediaSource[] mediaSources = new MediaSource[subtitleMediaSources.length + 1]; - mediaSources[0] = contentMediaSource; - for (int i = 0; i < subtitleMediaSources.length; i++) { - int subtitleIndex = i; - mediaSources[i + 1] = - new WrappingMediaSource(subtitleMediaSources[i]) { - @Override - public MediaPeriod createPeriod( - MediaPeriodId id, Allocator allocator, long startPositionUs) { - TimeOffsetMediaPeriod mediaPeriod = - new TimeOffsetMediaPeriod( - mediaSource.createPeriod( - id, allocator, startPositionUs - timeOffsetsUs[subtitleIndex]), - timeOffsetsUs[subtitleIndex]); - activeMediaPeriods[subtitleIndex].add(mediaPeriod); - return mediaPeriod; - } - - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; - activeMediaPeriods[subtitleIndex].remove(timeOffsetMediaPeriod); - mediaSource.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); - } - }; - } - return new MergingMediaSource(mediaSources); - } } From 2bc313fd9a21b31176bf946633644f5a0fdae8a1 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 18:34:43 +0100 Subject: [PATCH 09/11] Restore TimeOffsetMediaSource as an explicit WrappingMediaSource --- .../SideloadedSubtitlesMediaSource.java | 153 +++++++++--------- 1 file changed, 72 insertions(+), 81 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index 46293031a73..d347ff19690 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -20,8 +20,6 @@ import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; -import androidx.media3.common.Timeline; -import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.upstream.Allocator; import com.google.common.collect.ImmutableList; import java.util.ArrayList; @@ -36,16 +34,9 @@ * playback. Updates that change the subtitle configurations in any other way are rejected from * {@link #canUpdateMediaItem}, so that the player falls back to re-preparing the item. */ -/* package */ final class SideloadedSubtitlesMediaSource extends CompositeMediaSource { +/* package */ final class SideloadedSubtitlesMediaSource extends WrappingMediaSource { - private static final int CONTENT_MEDIA_SOURCE_INDEX = 0; - private static final int FIRST_SUBTITLE_MEDIA_SOURCE_INDEX = 1; - - private final MediaSource contentMediaSource; - private final MediaSource[] subtitleMediaSources; - private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; - private final long[] timeOffsetsUs; - private final List> activeMediaPeriods; + private final TimeOffsetMediaSource[] subtitleSources; /** * Creates the media source. @@ -60,88 +51,27 @@ public SideloadedSubtitlesMediaSource( MediaSource contentMediaSource, List subtitleConfigurations, MediaSource[] subtitleMediaSources) { - checkArgument(subtitleConfigurations.size() == subtitleMediaSources.length); - this.contentMediaSource = contentMediaSource; - this.subtitleMediaSources = subtitleMediaSources; - this.compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); - this.timeOffsetsUs = new long[subtitleConfigurations.size()]; - this.activeMediaPeriods = new ArrayList<>(subtitleMediaSources.length); - for (int i = 0; i < subtitleConfigurations.size(); i++) { - this.timeOffsetsUs[i] = subtitleConfigurations.get(i).timeOffsetUs; - this.activeMediaPeriods.add(new ArrayList<>()); - } + this(createTimeOffsetSources(subtitleConfigurations, subtitleMediaSources), contentMediaSource); } - @Override - public MediaItem getMediaItem() { - return contentMediaSource.getMediaItem(); + private SideloadedSubtitlesMediaSource( + TimeOffsetMediaSource[] subtitleSources, MediaSource contentMediaSource) { + super(createMergingMediaSource(contentMediaSource, subtitleSources)); + this.subtitleSources = subtitleSources; } @Override public boolean canUpdateMediaItem(MediaItem mediaItem) { - return contentMediaSource.canUpdateMediaItem(mediaItem) - && canUpdateSubtitleConfigurations(mediaItem); + return super.canUpdateMediaItem(mediaItem) && canUpdateSubtitleConfigurations(mediaItem); } @Override public void updateMediaItem(MediaItem mediaItem) { - contentMediaSource.updateMediaItem(mediaItem); + super.updateMediaItem(mediaItem); ImmutableList newSubtitleConfigurations = checkNotNull(mediaItem.localConfiguration).subtitleConfigurations; - for (int i = 0; i < timeOffsetsUs.length; i++) { - long newTimeOffsetUs = newSubtitleConfigurations.get(i).timeOffsetUs; - timeOffsetsUs[i] = newTimeOffsetUs; - for (int j = 0; j < activeMediaPeriods.get(i).size(); j++) { - activeMediaPeriods.get(i).get(j).updateTimeOffsetUs(newTimeOffsetUs); - } - } - } - - @Override - protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { - super.prepareSourceInternal(mediaTransferListener); - for (int i = 0; i < subtitleMediaSources.length; i++) { - prepareChildSource(FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i, subtitleMediaSources[i]); - } - prepareChildSource(CONTENT_MEDIA_SOURCE_INDEX, contentMediaSource); - } - - @Override - protected void onChildSourceInfoRefreshed( - Integer childSourceId, MediaSource mediaSource, Timeline newTimeline) { - if (childSourceId == CONTENT_MEDIA_SOURCE_INDEX) { - refreshSourceInfo(newTimeline); - } - } - - @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { - MediaPeriod[] periods = new MediaPeriod[subtitleMediaSources.length + 1]; - periods[CONTENT_MEDIA_SOURCE_INDEX] = - contentMediaSource.createPeriod(id, allocator, startPositionUs); - for (int i = 0; i < subtitleMediaSources.length; i++) { - TimeOffsetMediaPeriod period = - new TimeOffsetMediaPeriod( - subtitleMediaSources[i].createPeriod( - id, allocator, startPositionUs - timeOffsetsUs[i]), - timeOffsetsUs[i]); - periods[FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i] = period; - activeMediaPeriods.get(i).add(period); - } - return new MergingMediaPeriod( - compositeSequenceableLoaderFactory, new long[periods.length], periods); - } - - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - MergingMediaPeriod mergingMediaPeriod = (MergingMediaPeriod) mediaPeriod; - contentMediaSource.releasePeriod(mergingMediaPeriod.getChildPeriod(CONTENT_MEDIA_SOURCE_INDEX)); - for (int i = 0; i < subtitleMediaSources.length; i++) { - TimeOffsetMediaPeriod timeOffsetMediaPeriod = - (TimeOffsetMediaPeriod) - mergingMediaPeriod.getChildPeriod(FIRST_SUBTITLE_MEDIA_SOURCE_INDEX + i); - activeMediaPeriods.get(i).remove(timeOffsetMediaPeriod); - subtitleMediaSources[i].releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + for (int i = 0; i < subtitleSources.length; i++) { + subtitleSources[i].setTimeOffsetUs(newSubtitleConfigurations.get(i).timeOffsetUs); } } @@ -174,4 +104,65 @@ private static boolean equalsIgnoringTimeOffset( .build() .equals(other); } + + private static TimeOffsetMediaSource[] createTimeOffsetSources( + List subtitleConfigurations, + MediaSource[] subtitleMediaSources) { + checkArgument(subtitleConfigurations.size() == subtitleMediaSources.length); + TimeOffsetMediaSource[] timeOffsetSources = + new TimeOffsetMediaSource[subtitleMediaSources.length]; + for (int i = 0; i < subtitleMediaSources.length; i++) { + timeOffsetSources[i] = + new TimeOffsetMediaSource( + subtitleMediaSources[i], subtitleConfigurations.get(i).timeOffsetUs); + } + return timeOffsetSources; + } + + private static MergingMediaSource createMergingMediaSource( + MediaSource contentMediaSource, TimeOffsetMediaSource[] subtitleSources) { + MediaSource[] mediaSources = new MediaSource[subtitleSources.length + 1]; + mediaSources[0] = contentMediaSource; + System.arraycopy(subtitleSources, 0, mediaSources, 1, subtitleSources.length); + return new MergingMediaSource(mediaSources); + } + + /** + * A {@link WrappingMediaSource} that applies a time offset to the media periods of the wrapped + * source. + */ + private static final class TimeOffsetMediaSource extends WrappingMediaSource { + + private final List activeMediaPeriods; + private long timeOffsetUs; + + public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { + super(mediaSource); + this.timeOffsetUs = timeOffsetUs; + this.activeMediaPeriods = new ArrayList<>(); + } + + public void setTimeOffsetUs(long timeOffsetUs) { + this.timeOffsetUs = timeOffsetUs; + for (int i = 0; i < activeMediaPeriods.size(); i++) { + activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + TimeOffsetMediaPeriod mediaPeriod = + new TimeOffsetMediaPeriod( + super.createPeriod(id, allocator, startPositionUs - timeOffsetUs), timeOffsetUs); + activeMediaPeriods.add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + TimeOffsetMediaPeriod timeOffsetMediaPeriod = (TimeOffsetMediaPeriod) mediaPeriod; + activeMediaPeriods.remove(timeOffsetMediaPeriod); + super.releasePeriod(timeOffsetMediaPeriod.getWrappedMediaPeriod()); + } + } } From 863e171f79b6a0d652a62baf32b98dcc9b7bdc57 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 25 Aug 2026 18:39:25 +0100 Subject: [PATCH 10/11] Make TimeOffsetMediaSource constructor and setTimeOffsetUs private --- .../exoplayer/source/SideloadedSubtitlesMediaSource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java index d347ff19690..6389f7a4d56 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SideloadedSubtitlesMediaSource.java @@ -136,13 +136,13 @@ private static final class TimeOffsetMediaSource extends WrappingMediaSource { private final List activeMediaPeriods; private long timeOffsetUs; - public TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { + private TimeOffsetMediaSource(MediaSource mediaSource, long timeOffsetUs) { super(mediaSource); this.timeOffsetUs = timeOffsetUs; this.activeMediaPeriods = new ArrayList<>(); } - public void setTimeOffsetUs(long timeOffsetUs) { + private void setTimeOffsetUs(long timeOffsetUs) { this.timeOffsetUs = timeOffsetUs; for (int i = 0; i < activeMediaPeriods.size(); i++) { activeMediaPeriods.get(i).updateTimeOffsetUs(timeOffsetUs); From b3ba33498a38e9fc26836a8b7161e9f1a4d2455f Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 27 Aug 2026 09:55:39 +0100 Subject: [PATCH 11/11] Wrap default value 0 in {@code 0} in MediaItem.java --- .../common/src/main/java/androidx/media3/common/MediaItem.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java index 961d2d68be6..cd046dd1142 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java @@ -1660,7 +1660,7 @@ public Builder setId(@Nullable String id) { * deprecated legacy subtitle decoding path. * * - *

The default value is 0. + *

The default value is {@code 0}. */ @CanIgnoreReturnValue @UnstableApi