diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index d8457dd5b8..b70b562bb0 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -33,7 +33,6 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; @@ -47,8 +46,6 @@ import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MergingMediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; @@ -412,43 +409,24 @@ public class PlayerActivity extends AppCompatActivity : new UriSample[] {(UriSample) intentAsSample}; boolean seenAdsTagUri = false; + List mediaSources = new ArrayList<>(); for (UriSample sample : samples) { seenAdsTagUri |= sample.adTagUri != null; if (!Util.checkCleartextTrafficPermitted(sample.uri)) { showToast(R.string.error_cleartext_not_permitted); return Collections.emptyList(); } - if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, sample.uri)) { + if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, sample.uri) + || (sample.subtitleInfo != null + && Util.maybeRequestReadExternalStoragePermission( + /* activity= */ this, sample.subtitleInfo.uri))) { // The player will be reinitialized if the permission is granted. return Collections.emptyList(); } - } - - List mediaSources = new ArrayList<>(); - for (UriSample sample : samples) { MediaSource mediaSource = createLeafMediaSource(sample); - if (mediaSource == null) { - continue; + if (mediaSource != null) { + mediaSources.add(mediaSource); } - Sample.SubtitleInfo subtitleInfo = sample.subtitleInfo; - if (subtitleInfo != null) { - if (Util.maybeRequestReadExternalStoragePermission( - /* activity= */ this, subtitleInfo.uri)) { - // The player will be reinitialized if the permission is granted. - return Collections.emptyList(); - } - Format subtitleFormat = - new Format.Builder() - .setSampleMimeType(subtitleInfo.mimeType) - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .setLanguage(subtitleInfo.language) - .build(); - MediaSource subtitleMediaSource = - new SingleSampleMediaSource.Factory(dataSourceFactory) - .createMediaSource(subtitleInfo.uri, subtitleFormat, C.TIME_UNSET); - mediaSource = new MergingMediaSource(mediaSource, subtitleMediaSource); - } - mediaSources.add(mediaSource); } if (seenAdsTagUri && mediaSources.size() == 1) { Uri adTagUri = samples[0].adTagUri; @@ -497,6 +475,15 @@ public class PlayerActivity extends AppCompatActivity drmSessionForClearTypes = parameters.drmInfo.drmSessionForClearTypes; drmDataSourceFactory = ((DemoApplication) getApplication()).buildHttpDataSourceFactory(); } + if (parameters.subtitleInfo != null) { + builder.setSubtitles( + Collections.singletonList( + new MediaItem.Subtitle( + parameters.subtitleInfo.uri, + parameters.subtitleInfo.mimeType, + parameters.subtitleInfo.language, + C.SELECTION_FLAG_DEFAULT))); + } DownloadRequest downloadRequest = ((DemoApplication) getApplication()) @@ -531,7 +518,7 @@ public class PlayerActivity extends AppCompatActivity debugViewHelper = null; player.release(); player = null; - mediaSources = null; + mediaSources = Collections.emptyList(); trackSelector = null; } if (adsLoader != null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java index 1d624c24f5..0d3b785715 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -61,12 +61,14 @@ public final class MediaItem { @Nullable private UUID drmUuid; private boolean drmMultiSession; private List streamKeys; + private List subtitles; @Nullable private Object tag; @Nullable private MediaMetadata mediaMetadata; /** Creates a builder. */ public Builder() { streamKeys = Collections.emptyList(); + subtitles = Collections.emptyList(); drmLicenseRequestHeaders = Collections.emptyMap(); } @@ -138,6 +140,8 @@ public final class MediaItem { /** * Sets the optional request headers attached to the drm license request. * + *

{@code null} or an empty {@link Map} can be used for a reset. + * *

If no valid drm configuration is specified, the drm license request headers are ignored. */ public Builder setDrmLicenseRequestHeaders( @@ -176,6 +180,8 @@ public final class MediaItem { * Sets the optional stream keys by which the manifest is filtered (only used for adaptive * streams). * + *

{@code null} or an empty {@link List} can be used for a reset. + * *

If a {@link PlaybackProperties#sourceUri} is set, the stream keys are used to create a * {@link PlaybackProperties} object. Otherwise it will be ignored. */ @@ -187,6 +193,22 @@ public final class MediaItem { return this; } + /** + * Sets the optional subtitles. + * + *

{@code null} or an empty {@link List} can be used for a reset. + * + *

If a {@link PlaybackProperties#sourceUri} is set, the subtitles are used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. + */ + public Builder setSubtitles(@Nullable List subtitles) { + this.subtitles = + subtitles != null && !subtitles.isEmpty() + ? Collections.unmodifiableList(new ArrayList<>(subtitles)) + : Collections.emptyList(); + return this; + } + /** * Sets the optional tag for custom attributes. The tag for the media source which will be * published in the {@link com.google.android.exoplayer2.Timeline} of the source as {@link @@ -222,6 +244,7 @@ public final class MediaItem { drmUuid, drmLicenseUri, drmLicenseRequestHeaders, drmMultiSession) : null, streamKeys, + subtitles, tag); mediaId = mediaId != null ? mediaId : sourceUri.toString(); } @@ -266,7 +289,7 @@ public final class MediaItem { if (this == obj) { return true; } - if (obj == null || getClass() != obj.getClass()) { + if (!(obj instanceof DrmConfiguration)) { return false; } @@ -307,6 +330,9 @@ public final class MediaItem { /** Optional stream keys by which the manifest is filtered. */ public final List streamKeys; + /** Optional subtitles to be sideloaded. */ + public final List subtitles; + /** * Optional tag for custom attributes. The tag for the media source which will be published in * the {@link com.google.android.exoplayer2.Timeline} of the source as {@link @@ -319,11 +345,13 @@ public final class MediaItem { @Nullable String mimeType, @Nullable DrmConfiguration drmConfiguration, List streamKeys, + List subtitles, @Nullable Object tag) { this.sourceUri = sourceUri; this.mimeType = mimeType; this.drmConfiguration = drmConfiguration; this.streamKeys = streamKeys; + this.subtitles = subtitles; this.tag = tag; } @@ -332,7 +360,7 @@ public final class MediaItem { if (this == obj) { return true; } - if (obj == null || getClass() != obj.getClass()) { + if (!(obj instanceof PlaybackProperties)) { return false; } PlaybackProperties other = (PlaybackProperties) obj; @@ -340,7 +368,8 @@ public final class MediaItem { return sourceUri.equals(other.sourceUri) && Util.areEqual(mimeType, other.mimeType) && Util.areEqual(drmConfiguration, other.drmConfiguration) - && Util.areEqual(streamKeys, other.streamKeys) + && streamKeys.equals(other.streamKeys) + && subtitles.equals(other.subtitles) && Util.areEqual(tag, other.tag); } @@ -350,11 +379,78 @@ public final class MediaItem { result = 31 * result + (mimeType == null ? 0 : mimeType.hashCode()); result = 31 * result + (drmConfiguration == null ? 0 : drmConfiguration.hashCode()); result = 31 * result + streamKeys.hashCode(); + result = 31 * result + subtitles.hashCode(); result = 31 * result + (tag == null ? 0 : tag.hashCode()); return result; } } + /** Properties for a text track. */ + public static final class Subtitle { + + /** The {@link Uri} to the subtitle file. */ + public final Uri uri; + /** The MIME type. */ + public final String mimeType; + /** The language. */ + @Nullable public final String language; + /** The selection flags. */ + @C.SelectionFlags public final int selectionFlags; + + /** + * Creates an instance. + * + * @param uri The {@link Uri uri} to the subtitle file. + * @param mimeType The mime type. + * @param language The optional language. + */ + public Subtitle(Uri uri, String mimeType, @Nullable String language) { + this(uri, mimeType, language, /* selectionFlags= */ 0); + } + + /** + * Creates an instance with the given selection flags. + * + * @param uri The {@link Uri uri} to the subtitle file. + * @param mimeType The mime type. + * @param language The optional language. + * @param selectionFlags The selection flags. + */ + public Subtitle( + Uri uri, String mimeType, @Nullable String language, @C.SelectionFlags int selectionFlags) { + this.uri = uri; + this.mimeType = mimeType; + this.language = language; + this.selectionFlags = selectionFlags; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Subtitle)) { + return false; + } + + Subtitle other = (Subtitle) obj; + + return uri.equals(other.uri) + && mimeType.equals(other.mimeType) + && Util.areEqual(language, other.language) + && selectionFlags == other.selectionFlags; + } + + @Override + public int hashCode() { + int result = uri.hashCode(); + result = 31 * result + mimeType.hashCode(); + result = 31 * result + (language == null ? 0 : language.hashCode()); + result = 31 * result + selectionFlags; + return result; + } + } + /** Identifies the media item. */ public final String mediaId; @@ -374,15 +470,15 @@ public final class MediaItem { } @Override - public boolean equals(@Nullable Object o) { - if (this == o) { + public boolean equals(@Nullable Object obj) { + if (this == obj) { return true; } - if (o == null || getClass() != o.getClass()) { + if (!(obj instanceof MediaItem)) { return false; } - MediaItem other = (MediaItem) o; + MediaItem other = (MediaItem) obj; return Util.areEqual(mediaId, other.mediaId) && Util.areEqual(playbackProperties, other.playbackProperties) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index 16f7d5edc9..0351f2f791 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -21,6 +21,7 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; @@ -122,6 +123,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { return new DefaultMediaSourceFactory(context, dataSourceFactory); } + private final DataSource.Factory dataSourceFactory; private final SparseArray mediaSourceFactories; @C.ContentType private final int[] supportedTypes; private final String userAgent; @@ -133,6 +135,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { @Nullable private List streamKeys; private DefaultMediaSourceFactory(Context context, DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); userAgent = Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY); drmHttpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); @@ -244,7 +247,30 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { !mediaItem.playbackProperties.streamKeys.isEmpty() ? mediaItem.playbackProperties.streamKeys : streamKeys); - return mediaSourceFactory.createMediaSource(mediaItem); + + MediaSource leafMediaSource = mediaSourceFactory.createMediaSource(mediaItem); + + if (mediaItem.playbackProperties.subtitles.isEmpty()) { + return leafMediaSource; + } + + List subtitles = mediaItem.playbackProperties.subtitles; + MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; + mediaSources[0] = leafMediaSource; + SingleSampleMediaSource.Factory singleSampleSourceFactory = + new SingleSampleMediaSource.Factory(dataSourceFactory); + for (int i = 0; i < subtitles.size(); i++) { + MediaItem.Subtitle subtitle = subtitles.get(i); + Format subtitleFormat = + new Format.Builder() + .setSampleMimeType(subtitle.mimeType) + .setLanguage(subtitle.language) + .setSelectionFlags(subtitle.selectionFlags) + .build(); + mediaSources[i + 1] = + singleSampleSourceFactory.createMediaSource(subtitle.uri, subtitleFormat, C.TIME_UNSET); + } + return new MergingMediaSource(mediaSources); } // internal methods diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index 94c4945689..289e56d422 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -23,6 +23,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -130,6 +131,24 @@ public class MediaItemTest { assertThat(mediaItem.playbackProperties.streamKeys).isEqualTo(streamKeys); } + @Test + public void builderSetSubtitles_setsSubtitles() { + List subtitles = + Arrays.asList( + new MediaItem.Subtitle( + Uri.parse(URI_STRING + "/en"), MimeTypes.APPLICATION_TTML, /* language= */ "en"), + new MediaItem.Subtitle( + Uri.parse(URI_STRING + "/de"), + MimeTypes.APPLICATION_TTML, + /* language= */ null, + C.SELECTION_FLAG_DEFAULT)); + + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_STRING).setSubtitles(subtitles).build(); + + assertThat(mediaItem.playbackProperties.subtitles).isEqualTo(subtitles); + } + @Test public void builderSetTag_isNullByDefault() { MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java index 0f0178a43a..2bb4ca16cf 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -17,10 +17,15 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -29,6 +34,7 @@ import org.junit.runner.RunWith; public final class DefaultMediaSourceFactoryTest { private static final String URI_MEDIA = "http://exoplayer.dev/video"; + private static final String URI_TEXT = "http://exoplayer.dev/text"; @Test public void createMediaSource_withoutMimeType_progressiveSource() { @@ -80,6 +86,42 @@ public final class DefaultMediaSourceFactoryTest { assertThat(mediaSource).isNotNull(); } + @Test + public void createMediaSource_withSubtitle_isMergingMediaSource() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + List subtitles = + Arrays.asList( + new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"), + new MediaItem.Subtitle( + Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "de", C.SELECTION_FLAG_DEFAULT)); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setSubtitles(subtitles).build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(MergingMediaSource.class); + } + + @Test + public void createMediaSource_withSubtitle_hasTag() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder() + .setTag(tag) + .setSourceUri(URI_MEDIA) + .setSubtitles( + Collections.singletonList( + new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"))) + .build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource.getTag()).isEqualTo(tag); + } + @Test public void getSupportedTypes_coreModule_onlyOther() { int[] supportedTypes =