diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 38ed1ba728..005d4e2e68 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -17,6 +17,7 @@ body: label: Media3 Version description: What version of Media3 are you using? options: + - 1.0.0-rc01 - 1.0.0-beta03 - 1.0.0-beta02 - 1.0.0-beta01 diff --git a/README.md b/README.md index 9f50f679ba..d0b375b92d 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,13 @@ Android, including local playback (via ExoPlayer) and media sessions. ## Current status -AndroidX Media is currently in beta and we welcome your feedback via the -[issue tracker][]. Please consult the [release notes][] for more details about -the beta release. +AndroidX Media is currently in release candidate and we welcome your feedback +via the [issue tracker][]. Please consult the [release notes][] for more details +about the current release. ExoPlayer's new home will be in AndroidX Media, but for now we are publishing it -both in AndroidX Media and via the existing [ExoPlayer project][]. While -AndroidX Media is in beta we recommend that production apps using ExoPlayer -continue to depend on the existing ExoPlayer project. We are still handling -ExoPlayer issues on the [ExoPlayer issue tracker][]. +both in AndroidX Media and via the existing [ExoPlayer project][] and we are +still handling ExoPlayer issues on the [ExoPlayer issue tracker][]. You'll find some [Media3 documentation on developer.android.com][], including a [migration guide for existing ExoPlayer and MediaSession users][]. diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1eb87be715..38c631388e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,4 +1,101 @@ -Release notes +# Release notes + +### 1.0.0-rc01 (2023-02-16) + +This release corresponds to the +[ExoPlayer 2.18.3 release](https://github.com/google/ExoPlayer/releases/tag/r2.18.3). + +* Core library: + * Tweak the renderer's decoder ordering logic to uphold the + `MediaCodecSelector`'s preferences, even if a decoder reports it may not + be able to play the media performantly. For example with default + selector, hardware decoder with only functional support will be + preferred over software decoder that fully supports the format + ([#10604](https://github.com/google/ExoPlayer/issues/10604)). + * Add `ExoPlayer.Builder.setPlaybackLooper` that sets a pre-existing + playback thread for a new ExoPlayer instance. + * Allow download manager helpers to be cleared + ([#10776](https://github.com/google/ExoPlayer/issues/10776)). + * Add parameter to `BasePlayer.seekTo` to also indicate the command used + for seeking. + * Use theme when loading drawables on API 21+ + ([#220](https://github.com/androidx/media/issues/220)). + * Add `ConcatenatingMediaSource2` that allows combining multiple media + items into a single window + ([#247](https://github.com/androidx/media/issues/247)). +* Extractors: + * Throw a `ParserException` instead of a `NullPointerException` if the + sample table (stbl) is missing a required sample description (stsd) when + parsing trak atoms. + * Correctly skip samples when seeking directly to a sync frame in fMP4 + ([#10941](https://github.com/google/ExoPlayer/issues/10941)). +* Audio: + * Use the compressed audio format bitrate to calculate the min buffer size + for `AudioTrack` in direct playbacks (passthrough). +* Text: + * Fix `TextRenderer` passing an invalid (negative) index to + `Subtitle.getEventTime` if a subtitle file contains no cues. + * SubRip: Add support for UTF-16 files if they start with a byte order + mark. +* Metadata: + * Parse multiple null-separated values from ID3 frames, as permitted by + ID3 v2.4. + * Add `MediaMetadata.mediaType` to denote the type of content or the type + of folder described by the metadata. + * Add `MediaMetadata.isBrowsable` as a replacement for + `MediaMetadata.folderType`. The folder type will be deprecated in the + next release. +* DASH: + * Add full parsing for image adaptation sets, including tile counts + ([#3752](https://github.com/google/ExoPlayer/issues/3752)). +* UI: + * Fix the deprecated + `PlayerView.setControllerVisibilityListener(PlayerControlView.VisibilityListener)` + to ensure visibility changes are passed to the registered listener + ([#229](https://github.com/androidx/media/issues/229)). + * Fix the ordering of the center player controls in `PlayerView` when + using a right-to-left (RTL) layout + ([#227](https://github.com/androidx/media/issues/227)). +* Session: + * Add abstract `SimpleBasePlayer` to help implement the `Player` interface + for custom players. + * Add helper method to convert platform session token to Media3 + `SessionToken` ([#171](https://github.com/androidx/media/issues/171)). + * Use `onMediaMetadataChanged` to trigger updates of the platform media + session ([#219](https://github.com/androidx/media/issues/219)). + * Add the media session as an argument of `getMediaButtons()` of the + `DefaultMediaNotificationProvider` and use immutable lists for clarity + ([#216](https://github.com/androidx/media/issues/216)). + * Add `onSetMediaItems` callback listener to provide means to modify/set + `MediaItem` list, starting index and position by session before setting + onto Player ([#156](https://github.com/androidx/media/issues/156)). + * Avoid double tap detection for non-Bluetooth media button events + ([#233](https://github.com/androidx/media/issues/233)). + * Make `QueueTimeline` more robust in case of a shady legacy session state + ([#241](https://github.com/androidx/media/issues/241)). +* Metadata: + * Parse multiple null-separated values from ID3 frames, as permitted by + ID3 v2.4. + * Add `MediaMetadata.mediaType` to denote the type of content or the type + of folder described by the metadata. + * Add `MediaMetadata.isBrowsable` as a replacement for + `MediaMetadata.folderType`. The folder type will be deprecated in the + next release. +* Cast extension: + * Bump Cast SDK version to 21.2.0. +* IMA extension: + * Remove player listener of the `ImaServerSideAdInsertionMediaSource` on + the application thread to avoid threading issues. + * Add a property `focusSkipButtonWhenAvailable` to the + `ImaServerSideAdInsertionMediaSource.AdsLoader.Builder` to request + focusing the skip button on TV devices and set it to true by default. + * Add a method `focusSkipButton()` to the + `ImaServerSideAdInsertionMediaSource.AdsLoader` to programmatically + request to focus the skip button. + * Bump IMA SDK version to 3.29.0. +* Demo app: + * Request notification permission for download notifications at runtime + ([#10884](https://github.com/google/ExoPlayer/issues/10884)). ### 1.0.0-beta03 (2022-11-22) @@ -259,15 +356,15 @@ This release corresponds to the * Query the platform (API 29+) or assume the audio encoding channel count for audio passthrough when the format audio channel count is unset, which occurs with HLS chunkless preparation - ([10204](https://github.com/google/ExoPlayer/issues/10204)). + ([#10204](https://github.com/google/ExoPlayer/issues/10204)). * Configure `AudioTrack` with channel mask `AudioFormat.CHANNEL_OUT_7POINT1POINT4` if the decoder outputs 12 channel PCM audio - ([#10322](#https://github.com/google/ExoPlayer/pull/10322). + ([#10322](#https://github.com/google/ExoPlayer/pull/10322)). * DRM * Ensure the DRM session is always correctly updated when seeking immediately after a format change - ([10274](https://github.com/google/ExoPlayer/issues/10274)). + ([#10274](https://github.com/google/ExoPlayer/issues/10274)). * Text: * Change `Player.getCurrentCues()` to return `CueGroup` instead of `List`. diff --git a/constants.gradle b/constants.gradle index ac9b80f1d6..39884d57c7 100644 --- a/constants.gradle +++ b/constants.gradle @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. project.ext { - releaseVersion = '1.0.0-beta03' - releaseVersionCode = 1_000_000_1_03 + releaseVersion = '1.0.0-rc01' + releaseVersionCode = 1_000_000_2_01 minSdkVersion = 16 appTargetSdkVersion = 33 // API version before restricting local file access. diff --git a/demos/main/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml index 76fc35d287..21d07e4ee5 100644 --- a/demos/main/src/main/AndroidManifest.xml +++ b/demos/main/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + @@ -35,6 +36,7 @@ android:largeHeap="true" android:allowBackup="false" android:requestLegacyExternalStorage="true" + android:supportsRtl="true" android:name="androidx.multidex.MultiDexApplication" tools:targetApi="29"> diff --git a/demos/main/src/main/java/androidx/media3/demo/main/SampleChooserActivity.java b/demos/main/src/main/java/androidx/media3/demo/main/SampleChooserActivity.java index fc7144dc91..ef01b148ca 100644 --- a/demos/main/src/main/java/androidx/media3/demo/main/SampleChooserActivity.java +++ b/demos/main/src/main/java/androidx/media3/demo/main/SampleChooserActivity.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; +import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -41,8 +42,10 @@ import android.widget.ExpandableListView.OnChildClickListener; import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.DoNotInline; import androidx.annotation.Nullable; import androidx.annotation.OptIn; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem.ClippingConfiguration; @@ -76,6 +79,7 @@ public class SampleChooserActivity extends AppCompatActivity private static final String TAG = "SampleChooserActivity"; private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position"; private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position"; + private static final int POST_NOTIFICATION_PERMISSION_REQUEST_CODE = 100; private String[] uris; private boolean useExtensionRenderers; @@ -83,6 +87,8 @@ public class SampleChooserActivity extends AppCompatActivity private SampleAdapter sampleAdapter; private MenuItem preferExtensionDecodersMenuItem; private ExpandableListView sampleListView; + @Nullable private MediaItem downloadMediaItemWaitingForNotificationPermission; + private boolean notificationPermissionToastShown; @Override public void onCreate(Bundle savedInstanceState) { @@ -172,12 +178,34 @@ public class SampleChooserActivity extends AppCompatActivity public void onRequestPermissionsResult( int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == POST_NOTIFICATION_PERMISSION_REQUEST_CODE) { + handlePostNotificationPermissionGrantResults(grantResults); + } else { + handleExternalStoragePermissionGrantResults(grantResults); + } + } + + private void handlePostNotificationPermissionGrantResults(int[] grantResults) { + if (!notificationPermissionToastShown + && (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED)) { + Toast.makeText( + getApplicationContext(), R.string.post_notification_not_granted, Toast.LENGTH_LONG) + .show(); + notificationPermissionToastShown = true; + } + if (downloadMediaItemWaitingForNotificationPermission != null) { + // Download with or without permission to post notifications. + toggleDownload(downloadMediaItemWaitingForNotificationPermission); + downloadMediaItemWaitingForNotificationPermission = null; + } + } + + private void handleExternalStoragePermissionGrantResults(int[] grantResults) { if (grantResults.length == 0) { // Empty results are triggered if a permission is requested while another request was already // pending and can be safely ignored in this case. return; - } - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + } else if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { loadSample(); } else { Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) @@ -244,15 +272,26 @@ public class SampleChooserActivity extends AppCompatActivity if (downloadUnsupportedStringId != 0) { Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG) .show(); + } else if (!notificationPermissionToastShown + && Util.SDK_INT >= 33 + && checkSelfPermission(Api33.getPostNotificationPermissionString()) + != PackageManager.PERMISSION_GRANTED) { + downloadMediaItemWaitingForNotificationPermission = playlistHolder.mediaItems.get(0); + requestPermissions( + new String[] {Api33.getPostNotificationPermissionString()}, + /* requestCode= */ POST_NOTIFICATION_PERMISSION_REQUEST_CODE); } else { - RenderersFactory renderersFactory = - DemoUtil.buildRenderersFactory( - /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); - downloadTracker.toggleDownload( - getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory); + toggleDownload(playlistHolder.mediaItems.get(0)); } } + private void toggleDownload(MediaItem mediaItem) { + RenderersFactory renderersFactory = + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); + downloadTracker.toggleDownload(getSupportFragmentManager(), mediaItem, renderersFactory); + } + private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) { if (playlistHolder.mediaItems.size() > 1) { return R.string.download_playlist_unsupported; @@ -630,4 +669,13 @@ public class SampleChooserActivity extends AppCompatActivity this.playlists = new ArrayList<>(); } } + + @RequiresApi(33) + private static class Api33 { + + @DoNotInline + public static String getPostNotificationPermissionString() { + return Manifest.permission.POST_NOTIFICATIONS; + } + } } diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index 49441ef7da..ce9c90d0c2 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -45,6 +45,8 @@ One or more sample lists failed to load + Notifications suppressed. Grant permission to see download notifications. + Failed to start download Failed to obtain offline license diff --git a/demos/session/build.gradle b/demos/session/build.gradle index b3b61dac55..376c69534d 100644 --- a/demos/session/build.gradle +++ b/demos/session/build.gradle @@ -31,7 +31,7 @@ android { versionName project.ext.releaseVersion versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion + targetSdkVersion project.ext.appTargetSdkVersion multiDexEnabled true } diff --git a/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt b/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt index 0e7694dc03..810a6ac9b7 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt @@ -38,7 +38,7 @@ import com.google.common.util.concurrent.ListenableFuture class MainActivity : AppCompatActivity() { private lateinit var browserFuture: ListenableFuture private val browser: MediaBrowser? - get() = if (browserFuture.isDone) browserFuture.get() else null + get() = if (browserFuture.isDone && !browserFuture.isCancelled) browserFuture.get() else null private lateinit var mediaListAdapter: FolderMediaItemArrayAdapter private lateinit var mediaListView: ListView diff --git a/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt b/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt index d1ece8ba12..a1a6c6c187 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt @@ -20,11 +20,6 @@ import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem.SubtitleConfiguration import androidx.media3.common.MediaMetadata -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_GENRES -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED -import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE import androidx.media3.common.util.Util import com.google.common.collect.ImmutableList import org.json.JSONObject @@ -67,7 +62,8 @@ object MediaItemTree { title: String, mediaId: String, isPlayable: Boolean, - @MediaMetadata.FolderType folderType: Int, + isBrowsable: Boolean, + mediaType: @MediaMetadata.MediaType Int, subtitleConfigurations: List = mutableListOf(), album: String? = null, artist: String? = null, @@ -81,9 +77,10 @@ object MediaItemTree { .setTitle(title) .setArtist(artist) .setGenre(genre) - .setFolderType(folderType) + .setIsBrowsable(isBrowsable) .setIsPlayable(isPlayable) .setArtworkUri(imageUri) + .setMediaType(mediaType) .build() return MediaItem.Builder() @@ -109,7 +106,8 @@ object MediaItemTree { title = "Root Folder", mediaId = ROOT_ID, isPlayable = false, - folderType = FOLDER_TYPE_MIXED + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED ) ) treeNodes[ALBUM_ID] = @@ -118,7 +116,8 @@ object MediaItemTree { title = "Album Folder", mediaId = ALBUM_ID, isPlayable = false, - folderType = FOLDER_TYPE_MIXED + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS ) ) treeNodes[ARTIST_ID] = @@ -127,7 +126,8 @@ object MediaItemTree { title = "Artist Folder", mediaId = ARTIST_ID, isPlayable = false, - folderType = FOLDER_TYPE_MIXED + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS ) ) treeNodes[GENRE_ID] = @@ -136,7 +136,8 @@ object MediaItemTree { title = "Genre Folder", mediaId = GENRE_ID, isPlayable = false, - folderType = FOLDER_TYPE_MIXED + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_GENRES ) ) treeNodes[ROOT_ID]!!.addChild(ALBUM_ID) @@ -188,7 +189,8 @@ object MediaItemTree { title = title, mediaId = idInTree, isPlayable = true, - folderType = FOLDER_TYPE_NONE, + isBrowsable = false, + mediaType = MediaMetadata.MEDIA_TYPE_MUSIC, subtitleConfigurations, album = album, artist = artist, @@ -207,7 +209,8 @@ object MediaItemTree { title = album, mediaId = albumFolderIdInTree, isPlayable = true, - folderType = FOLDER_TYPE_ALBUMS, + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_ALBUM, subtitleConfigurations ) ) @@ -223,7 +226,8 @@ object MediaItemTree { title = artist, mediaId = artistFolderIdInTree, isPlayable = true, - folderType = FOLDER_TYPE_ARTISTS, + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_ARTIST, subtitleConfigurations ) ) @@ -239,7 +243,8 @@ object MediaItemTree { title = genre, mediaId = genreFolderIdInTree, isPlayable = true, - folderType = FOLDER_TYPE_GENRES, + isBrowsable = true, + mediaType = MediaMetadata.MEDIA_TYPE_GENRE, subtitleConfigurations ) ) @@ -262,7 +267,7 @@ object MediaItemTree { fun getRandomItem(): MediaItem { var curRoot = getRootItem() - while (curRoot.mediaMetadata.folderType != FOLDER_TYPE_NONE) { + while (curRoot.mediaMetadata.isBrowsable == true) { val children = getChildren(curRoot.mediaId)!! curRoot = children.random() } diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt index cc8291c27d..192499d4e1 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt @@ -15,22 +15,21 @@ */ package androidx.media3.demo.session -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent.* import android.app.TaskStackBuilder import android.content.Intent import android.os.Build import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.MediaItem +import androidx.media3.common.util.Util import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.CommandButton -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession +import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @@ -51,6 +50,8 @@ class PlaybackService : MediaLibraryService() { "android.media3.session.demo.SHUFFLE_ON" private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF" + private const val NOTIFICATION_ID = 123 + private const val CHANNEL_ID = "demo_session_notification_channel_id" } override fun onCreate() { @@ -66,15 +67,23 @@ class PlaybackService : MediaLibraryService() { ) customLayout = ImmutableList.of(customCommands[0]) initializeSessionAndPlayer() + setListener(MediaSessionServiceListener()) } override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession { return mediaLibrarySession } + override fun onTaskRemoved(rootIntent: Intent?) { + if (!player.playWhenReady) { + stopSelf() + } + } + override fun onDestroy() { player.release() mediaLibrarySession.release() + clearListener() super.onDestroy() } @@ -253,4 +262,49 @@ class PlaybackService : MediaLibraryService() { private fun ignoreFuture(customLayout: ListenableFuture) { /* Do nothing. */ } + + private inner class MediaSessionServiceListener : Listener { + + /** + * This method is only required to be implemented on Android 12 or above when an attempt is made + * by a media controller to resume playback when the {@link MediaSessionService} is in the + * background. + */ + override fun onForegroundServiceStartNotAllowedException() { + val notificationManagerCompat = NotificationManagerCompat.from(this@PlaybackService) + ensureNotificationChannel(notificationManagerCompat) + val pendingIntent = + TaskStackBuilder.create(this@PlaybackService).run { + addNextIntent(Intent(this@PlaybackService, MainActivity::class.java)) + + val immutableFlag = if (Build.VERSION.SDK_INT >= 23) FLAG_IMMUTABLE else 0 + getPendingIntent(0, immutableFlag or FLAG_UPDATE_CURRENT) + } + val builder = + NotificationCompat.Builder(this@PlaybackService, CHANNEL_ID) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.media3_notification_small_icon) + .setContentTitle(getString(R.string.notification_content_title)) + .setStyle( + NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_content_text)) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + notificationManagerCompat.notify(NOTIFICATION_ID, builder.build()) + } + } + + private fun ensureNotificationChannel(notificationManagerCompat: NotificationManagerCompat) { + if (Util.SDK_INT < 26 || notificationManagerCompat.getNotificationChannel(CHANNEL_ID) != null) { + return + } + + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + notificationManagerCompat.createNotificationChannel(channel) + } } diff --git a/demos/session/src/main/res/values/strings.xml b/demos/session/src/main/res/values/strings.xml index 727772e190..0add882c72 100644 --- a/demos/session/src/main/res/values/strings.xml +++ b/demos/session/src/main/res/values/strings.xml @@ -24,4 +24,9 @@ "! No media in the play list !\nPlease try to add more from browser" + Playback cannot be resumed + Press on the play button on the media notification if it + is still present, otherwise please open the app to start the playback and re-connect the session + to the controller + Playback cannot be resumed diff --git a/github/media3-migration.sh b/github/media3-migration.sh deleted file mode 100644 index f80ac4dfa3..0000000000 --- a/github/media3-migration.sh +++ /dev/null @@ -1,386 +0,0 @@ -#!/bin/bash -# Copyright (C) 2022 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. -## -shopt -s extglob - -PACKAGE_MAPPINGS='com.google.android.exoplayer2 androidx.media3.exoplayer -com.google.android.exoplayer2.analytics androidx.media3.exoplayer.analytics -com.google.android.exoplayer2.audio androidx.media3.exoplayer.audio -com.google.android.exoplayer2.castdemo androidx.media3.demo.cast -com.google.android.exoplayer2.database androidx.media3.database -com.google.android.exoplayer2.decoder androidx.media3.decoder -com.google.android.exoplayer2.demo androidx.media3.demo.main -com.google.android.exoplayer2.drm androidx.media3.exoplayer.drm -com.google.android.exoplayer2.ext.av1 androidx.media3.decoder.av1 -com.google.android.exoplayer2.ext.cast androidx.media3.cast -com.google.android.exoplayer2.ext.cronet androidx.media3.datasource.cronet -com.google.android.exoplayer2.ext.ffmpeg androidx.media3.decoder.ffmpeg -com.google.android.exoplayer2.ext.flac androidx.media3.decoder.flac -com.google.android.exoplayer2.ext.ima androidx.media3.exoplayer.ima -com.google.android.exoplayer2.ext.leanback androidx.media3.ui.leanback -com.google.android.exoplayer2.ext.okhttp androidx.media3.datasource.okhttp -com.google.android.exoplayer2.ext.opus androidx.media3.decoder.opus -com.google.android.exoplayer2.ext.rtmp androidx.media3.datasource.rtmp -com.google.android.exoplayer2.ext.vp9 androidx.media3.decoder.vp9 -com.google.android.exoplayer2.ext.workmanager androidx.media3.exoplayer.workmanager -com.google.android.exoplayer2.extractor androidx.media3.extractor -com.google.android.exoplayer2.gldemo androidx.media3.demo.gl -com.google.android.exoplayer2.mediacodec androidx.media3.exoplayer.mediacodec -com.google.android.exoplayer2.metadata androidx.media3.extractor.metadata -com.google.android.exoplayer2.offline androidx.media3.exoplayer.offline -com.google.android.exoplayer2.playbacktests androidx.media3.test.exoplayer.playback -com.google.android.exoplayer2.robolectric androidx.media3.test.utils.robolectric -com.google.android.exoplayer2.scheduler androidx.media3.exoplayer.scheduler -com.google.android.exoplayer2.source androidx.media3.exoplayer.source -com.google.android.exoplayer2.source.dash androidx.media3.exoplayer.dash -com.google.android.exoplayer2.source.hls androidx.media3.exoplayer.hls -com.google.android.exoplayer2.source.rtsp androidx.media3.exoplayer.rtsp -com.google.android.exoplayer2.source.smoothstreaming androidx.media3.exoplayer.smoothstreaming -com.google.android.exoplayer2.surfacedemo androidx.media3.demo.surface -com.google.android.exoplayer2.testdata androidx.media3.test.data -com.google.android.exoplayer2.testutil androidx.media3.test.utils -com.google.android.exoplayer2.text androidx.media3.extractor.text -com.google.android.exoplayer2.trackselection androidx.media3.exoplayer.trackselection -com.google.android.exoplayer2.transformer androidx.media3.transformer -com.google.android.exoplayer2.transformerdemo androidx.media3.demo.transformer -com.google.android.exoplayer2.ui androidx.media3.ui -com.google.android.exoplayer2.upstream androidx.media3.datasource -com.google.android.exoplayer2.upstream.cache androidx.media3.datasource.cache -com.google.android.exoplayer2.upstream.crypto androidx.media3.exoplayer.upstream.crypto -com.google.android.exoplayer2.util androidx.media3.common.util -com.google.android.exoplayer2.util androidx.media3.exoplayer.util -com.google.android.exoplayer2.video androidx.media3.exoplayer.video' - - -CLASS_RENAMINGS='com.google.android.exoplayer2.ui.StyledPlayerView androidx.media3.ui.PlayerView -StyledPlayerView PlayerView -com.google.android.exoplayer2.ui.StyledPlayerControlView androidx.media3.ui.PlayerControlView -StyledPlayerControlView PlayerControlView -com.google.android.exoplayer2.ExoPlayerLibraryInfo androidx.media3.common.MediaLibraryInfo -ExoPlayerLibraryInfo MediaLibraryInfo -com.google.android.exoplayer2.SimpleExoPlayer androidx.media3.exoplayer.ExoPlayer -SimpleExoPlayer ExoPlayer' - -CLASS_MAPPINGS='com.google.android.exoplayer2.text.span androidx.media3.common.text HorizontalTextInVerticalContextSpan LanguageFeatureSpan RubySpan SpanUtil TextAnnotation TextEmphasisSpan -com.google.android.exoplayer2.text androidx.media3.common.text CueGroup Cue -com.google.android.exoplayer2.text androidx.media3.exoplayer.text ExoplayerCuesDecoder SubtitleDecoderFactory TextOutput TextRenderer -com.google.android.exoplayer2.upstream.crypto androidx.media3.datasource AesCipherDataSource AesCipherDataSink AesFlushingCipher -com.google.android.exoplayer2.util androidx.media3.common.util AtomicFile Assertions BundleableUtil BundleUtil Clock ClosedSource CodecSpecificDataUtil ColorParser ConditionVariable Consumer CopyOnWriteMultiset EGLSurfaceTexture GlProgram GlUtil HandlerWrapper LibraryLoader ListenerSet Log LongArray MediaFormatUtil NetworkTypeObserver NonNullApi NotificationUtil ParsableBitArray ParsableByteArray RepeatModeUtil RunnableFutureTask SystemClock SystemHandlerWrapper TimedValueQueue TimestampAdjuster TraceUtil UnknownNull UnstableApi UriUtil Util XmlPullParserUtil -com.google.android.exoplayer2.util androidx.media3.common ErrorMessageProvider FlagSet FileTypes MimeTypes PriorityTaskManager -com.google.android.exoplayer2.metadata androidx.media3.common Metadata -com.google.android.exoplayer2.metadata androidx.media3.exoplayer.metadata MetadataDecoderFactory MetadataOutput MetadataRenderer -com.google.android.exoplayer2.audio androidx.media3.common AudioAttributes AuxEffectInfo -com.google.android.exoplayer2.ui androidx.media3.common AdOverlayInfo AdViewProvider -com.google.android.exoplayer2.source.ads androidx.media3.common AdPlaybackState -com.google.android.exoplayer2.source androidx.media3.common MediaPeriodId TrackGroup -com.google.android.exoplayer2.offline androidx.media3.common StreamKey -com.google.android.exoplayer2.ui androidx.media3.exoplayer.offline DownloadNotificationHelper -com.google.android.exoplayer2.trackselection androidx.media3.common TrackSelectionParameters TrackSelectionOverride -com.google.android.exoplayer2.video androidx.media3.common ColorInfo VideoSize -com.google.android.exoplayer2.upstream androidx.media3.common DataReader -com.google.android.exoplayer2.upstream androidx.media3.exoplayer.upstream Allocation Allocator BandwidthMeter CachedRegionTracker DefaultAllocator DefaultBandwidthMeter DefaultLoadErrorHandlingPolicy Loader LoaderErrorThrower ParsingLoadable SlidingPercentile TimeToFirstByteEstimator -com.google.android.exoplayer2.audio androidx.media3.extractor AacUtil Ac3Util Ac4Util DtsUtil MpegAudioUtil OpusUtil WavUtil -com.google.android.exoplayer2.util androidx.media3.extractor NalUnitUtil ParsableNalUnitBitArray -com.google.android.exoplayer2.video androidx.media3.extractor AvcConfig DolbyVisionConfig HevcConfig -com.google.android.exoplayer2.decoder androidx.media3.exoplayer DecoderCounters DecoderReuseEvaluation -com.google.android.exoplayer2.util androidx.media3.exoplayer MediaClock StandaloneMediaClock -com.google.android.exoplayer2 androidx.media3.exoplayer FormatHolder PlayerMessage -com.google.android.exoplayer2 androidx.media3.common BasePlayer BundleListRetriever Bundleable ControlDispatcher C DefaultControlDispatcher DeviceInfo ErrorMessageProvider ExoPlayerLibraryInfo Format ForwardingPlayer HeartRating IllegalSeekPositionException MediaItem MediaMetadata ParserException PercentageRating PlaybackException PlaybackParameters Player PositionInfo Rating StarRating ThumbRating Timeline Tracks -com.google.android.exoplayer2.drm androidx.media3.common DrmInitData' - -DEPENDENCY_MAPPINGS='exoplayer media3-exoplayer -exoplayer-common media3-common -exoplayer-core media3-exoplayer -exoplayer-dash media3-exoplayer-dash -exoplayer-database media3-database -exoplayer-datasource media-datasource -exoplayer-decoder media3-decoder -exoplayer-extractor media3-extractor -exoplayer-hls media3-exoplayer-hls -exoplayer-robolectricutils media3-test-utils-robolectric -exoplayer-rtsp media3-exoplayer-rtsp -exoplayer-smoothstreaming media3-exoplayer-smoothstreaming -exoplayer-testutils media3-test-utils -exoplayer-transformer media3-transformer -exoplayer-ui media3-ui -extension-cast media3-cast -extension-cronet media3-datasource-cronet -extension-ima media3-exoplayer-ima -extension-leanback media3-ui-leanback -extension-okhttp media3-datasource-okhttp -extension-rtmp media3-datasource-rtmp -extension-workmanager media3-exoplayer-workmanager' - -# Rewrites classes, packages and dependencies from the legacy ExoPlayer package structure -# to androidx.media3 structure. - -MEDIA3_VERSION="1.0.0-beta02" -LEGACY_PEER_VERSION="2.18.1" - -function usage() { - echo "usage: $0 [-p|-c|-d|-v]|[-m|-l [-x ] [-f] PROJECT_ROOT]" - echo " PROJECT_ROOT: path to your project root (location of 'gradlew')" - echo " -p: list package mappings and then exit" - echo " -c: list class mappings (precedence over package mappings) and then exit" - echo " -d: list dependency mappings and then exit" - echo " -m: migrate packages, classes and dependencies to AndroidX Media3" - echo " -l: list files that will be considered for rewrite and then exit" - echo " -x: exclude the path from the list of file to be changed: 'app/src/test'" - echo " -f: force the action even when validation fails" - echo " -v: print the exoplayer2/media3 version strings of this script and exit" - echo " --noclean : Do not call './gradlew clean' in project directory." - echo " -h, --help: show this help text" -} - -function print_pairs { - while read -r line; - do - IFS=' ' read -ra PAIR <<< "$line" - printf "%-55s %-30s\n" "${PAIR[0]}" "${PAIR[1]}" - done <<< "$(echo "$@")" -} - -function print_class_mappings { - while read -r mapping; - do - old=$(echo "$mapping" | cut -d ' ' -f1) - new=$(echo "$mapping" | cut -d ' ' -f2) - classes=$(echo "$mapping" | cut -d ' ' -f3-) - for clazz in $classes; - do - printf "%-80s %-30s\n" "$old.$clazz" "$new.$clazz" - done - done <<< "$(echo "$CLASS_MAPPINGS" | sort)" -} - -ERROR_COUNTER=0 -VALIDATION_ERRORS='' - -function add_validation_error { - let ERROR_COUNTER++ - VALIDATION_ERRORS+="\033[31m[$ERROR_COUNTER] ->\033[0m ${1}" -} - -function validate_exoplayer_version() { - has_exoplayer_dependency='' - while read -r file; - do - local version - version=$(grep -m 1 "com\.google\.android\.exoplayer:" "$file" | cut -d ":" -f3 | tr -d \" | tr -d \') - if [[ ! -z $version ]] && [[ ! "$version" =~ $LEGACY_PEER_VERSION ]]; - then - add_validation_error "The version does not match '$LEGACY_PEER_VERSION'. \ -Update to '$LEGACY_PEER_VERSION' or use the migration script matching your \ -current version. Current version '$version' found in\n $file\n" - fi - done <<< "$(find . -type f -name "build.gradle")" -} - -function validate_string_not_contained { - local pattern=$1 # regex - local failure_message=$2 - while read -r file; - do - if grep -q -e "$pattern" "$file"; - then - add_validation_error "$failure_message:\n $file\n" - fi - done <<< "$files" -} - -function validate_string_patterns { - validate_string_not_contained \ - 'com\.google\.android\.exoplayer2\..*\*' \ - 'Replace wildcard import statements with fully qualified import statements'; - validate_string_not_contained \ - 'com\.google\.android\.exoplayer2\.ui\.PlayerView' \ - 'Migrate PlayerView to StyledPlayerView before migrating'; - validate_string_not_contained \ - 'LegacyPlayerView' \ - 'Migrate LegacyPlayerView to StyledPlayerView before migrating'; - validate_string_not_contained \ - 'com\.google\.android\.exoplayer2\.ext\.mediasession' \ - 'The MediaSessionConnector is integrated in androidx.media3.session.MediaSession' -} - -SED_CMD_INPLACE='sed -i ' -if [[ "$OSTYPE" == "darwin"* ]]; then - SED_CMD_INPLACE="sed -i '' " -fi - -MIGRATE_FILES='1' -LIST_FILES_ONLY='1' -PRINT_CLASS_MAPPINGS='1' -PRINT_PACKAGE_MAPPINGS='1' -PRINT_DEPENDENCY_MAPPINGS='1' -PRINT_VERSION='1' -NO_CLEAN='1' -FORCE='1' -IGNORE_VERSION='1' -EXCLUDED_PATHS='' - -while [[ $1 =~ ^-.* ]]; -do - case "$1" in - -m ) MIGRATE_FILES='';; - -l ) LIST_FILES_ONLY='';; - -c ) PRINT_CLASS_MAPPINGS='';; - -p ) PRINT_PACKAGE_MAPPINGS='';; - -d ) PRINT_DEPENDENCY_MAPPINGS='';; - -v ) PRINT_VERSION='';; - -f ) FORCE='';; - -x ) shift; EXCLUDED_PATHS="$(printf "%s\n%s" $EXCLUDED_PATHS $1)";; - --noclean ) NO_CLEAN='';; - * ) usage && exit 1;; - esac - shift -done - -if [[ -z $PRINT_DEPENDENCY_MAPPINGS ]]; -then - print_pairs "$DEPENDENCY_MAPPINGS" - exit 0 -elif [[ -z $PRINT_PACKAGE_MAPPINGS ]]; -then - print_pairs "$PACKAGE_MAPPINGS" - exit 0 -elif [[ -z $PRINT_CLASS_MAPPINGS ]]; -then - print_class_mappings - exit 0 -elif [[ -z $PRINT_VERSION ]]; -then - echo "$LEGACY_PEER_VERSION -> $MEDIA3_VERSION. This script is written to migrate from ExoPlayer $LEGACY_PEER_VERSION to AndroidX Media3 $MEDIA3_VERSION" - exit 0 -elif [[ -z $1 ]]; -then - usage - exit 1 -fi - -if [[ ! -f $1/gradlew ]]; -then - echo "directory seems not to exist or is not a gradle project (missing 'gradlew')" - usage - exit 1 -fi - -PROJECT_ROOT=$1 -cd "$PROJECT_ROOT" - -# Create the set of files to transform -exclusion="/build/|/.idea/|/res/drawable|/res/color|/res/mipmap|/res/values|" -if [[ ! -z $EXCLUDED_PATHS ]]; -then - while read -r path; - do - exclusion="$exclusion./$path|" - done <<< "$EXCLUDED_PATHS" -fi -files=$(find . -name '*\.java' -o -name '*\.kt' -o -name '*\.xml' | grep -Ev "'$exclusion'") - -# Validate project and exit in case of validation errors -validate_string_patterns -validate_exoplayer_version "$PROJECT_ROOT" -if [[ ! -z $FORCE && ! -z "$VALIDATION_ERRORS" ]]; -then - echo "=============================================" - echo "Validation errors (use -f to force execution)" - echo "---------------------------------------------" - echo -e "$VALIDATION_ERRORS" - exit 1 -fi - -if [[ -z $LIST_FILES_ONLY ]]; -then - echo "$files" | cut -c 3- - find . -type f -name 'build\.gradle' | cut -c 3- - exit 0 -fi - -# start migration after successful validation or when forced to disregard validation -# errors - -if [[ ! -z "$MIGRATE_FILES" ]]; -then - echo "nothing to do" - usage - exit 0 -fi - -PWD=$(pwd) -if [[ ! -z $NO_CLEAN ]]; -then - cd "$PROJECT_ROOT" - ./gradlew clean - cd "$PWD" -fi - -# create expressions for class renamings -renaming_expressions='' -while read -r renaming; -do - src=$(echo "$renaming" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') - dest=$(echo "$renaming" | cut -d ' ' -f2) - renaming_expressions+="-e s/$src/$dest/g " -done <<< "$CLASS_RENAMINGS" - -# create expressions for class mappings -classes_expressions='' -while read -r mapping; -do - src=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') - dest=$(echo "$mapping" | cut -d ' ' -f2) - classes=$(echo "$mapping" | cut -d ' ' -f3-) - for clazz in $classes; - do - classes_expressions+="-e s/$src\.$clazz/$dest.$clazz/g " - done -done <<< "$CLASS_MAPPINGS" - -# create expressions for package mappings -packages_expressions='' -while read -r mapping; -do - src=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') - dest=$(echo "$mapping" | cut -d ' ' -f2) - packages_expressions+="-e s/$src/$dest/g " -done <<< "$PACKAGE_MAPPINGS" - -# do search and replace with expressions in each selected file -while read -r file; -do - echo "migrating $file" - expr="$renaming_expressions $classes_expressions $packages_expressions" - $SED_CMD_INPLACE $expr $file -done <<< "$files" - -# create expressions for dependencies in gradle files -EXOPLAYER_GROUP="com\.google\.android\.exoplayer" -MEDIA3_GROUP="androidx.media3" -dependency_expressions="" -while read -r mapping -do - OLD=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') - NEW=$(echo "$mapping" | cut -d ' ' -f2) - dependency_expressions="$dependency_expressions -e s/$EXOPLAYER_GROUP:$OLD:.*\"/$MEDIA3_GROUP:$NEW:$MEDIA3_VERSION\"/g -e s/$EXOPLAYER_GROUP:$OLD:.*'/$MEDIA3_GROUP:$NEW:$MEDIA3_VERSION'/" -done <<< "$DEPENDENCY_MAPPINGS" - -## do search and replace for dependencies in gradle files -while read -r build_file; -do - echo "migrating build file $build_file" - $SED_CMD_INPLACE $dependency_expressions $build_file -done <<< "$(find . -type f -name 'build\.gradle')" diff --git a/libraries/cast/README.md b/libraries/cast/README.md index ccd919b9b7..d8b25289b7 100644 --- a/libraries/cast/README.md +++ b/libraries/cast/README.md @@ -27,3 +27,9 @@ Create a `CastPlayer` and use it to control a Cast receiver app. Since `CastPlayer` implements the `Player` interface, it can be passed to all media components that accept a `Player`, including the UI components provided by the UI module. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/cast/build.gradle b/libraries/cast/build.gradle index 32dbee1e1e..87f7e6f20c 100644 --- a/libraries/cast/build.gradle +++ b/libraries/cast/build.gradle @@ -14,7 +14,7 @@ apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle" dependencies { - api 'com.google.android.gms:play-services-cast-framework:21.0.1' + api 'com.google.android.gms:play-services-cast-framework:21.2.0' implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'lib-common') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java index 57b81b3fa5..8d2a0cbde1 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -15,6 +15,7 @@ */ package androidx.media3.cast; +import static androidx.annotation.VisibleForTesting.PROTECTED; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Util.castNonNull; import static java.lang.Math.min; @@ -43,7 +44,6 @@ import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Clock; import androidx.media3.common.util.ListenerSet; import androidx.media3.common.util.Log; @@ -295,7 +295,7 @@ public final class CastPlayer extends BasePlayer { @Override public void addMediaItems(int index, List mediaItems) { - Assertions.checkArgument(index >= 0); + checkArgument(index >= 0); int uid = MediaQueueItem.INVALID_ITEM_ID; if (index < currentTimeline.getWindowCount()) { uid = (int) currentTimeline.getWindow(/* windowIndex= */ index, window).uid; @@ -305,14 +305,11 @@ public final class CastPlayer extends BasePlayer { @Override public void moveMediaItems(int fromIndex, int toIndex, int newIndex) { - Assertions.checkArgument( - fromIndex >= 0 - && fromIndex <= toIndex - && toIndex <= currentTimeline.getWindowCount() - && newIndex >= 0 - && newIndex < currentTimeline.getWindowCount()); - newIndex = min(newIndex, currentTimeline.getWindowCount() - (toIndex - fromIndex)); - if (fromIndex == toIndex || fromIndex == newIndex) { + checkArgument(fromIndex >= 0 && fromIndex <= toIndex && newIndex >= 0); + int playlistSize = currentTimeline.getWindowCount(); + toIndex = min(toIndex, playlistSize); + newIndex = min(newIndex, playlistSize - (toIndex - fromIndex)); + if (fromIndex >= playlistSize || fromIndex == toIndex || fromIndex == newIndex) { // Do nothing. return; } @@ -325,9 +322,10 @@ public final class CastPlayer extends BasePlayer { @Override public void removeMediaItems(int fromIndex, int toIndex) { - Assertions.checkArgument(fromIndex >= 0 && toIndex >= fromIndex); - toIndex = min(toIndex, currentTimeline.getWindowCount()); - if (fromIndex == toIndex) { + checkArgument(fromIndex >= 0 && toIndex >= fromIndex); + int playlistSize = currentTimeline.getWindowCount(); + toIndex = min(toIndex, playlistSize); + if (fromIndex >= playlistSize || fromIndex == toIndex) { // Do nothing. return; } @@ -399,7 +397,16 @@ public final class CastPlayer extends BasePlayer { // don't implement onPositionDiscontinuity(). @SuppressWarnings("deprecation") @Override - public void seekTo(int mediaItemIndex, long positionMs) { + @VisibleForTesting(otherwise = PROTECTED) + public void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem) { + checkArgument(mediaItemIndex >= 0); + if (!currentTimeline.isEmpty() && mediaItemIndex >= currentTimeline.getWindowCount()) { + return; + } MediaStatus mediaStatus = getMediaStatus(); // We assume the default position is 0. There is no support for seeking to the default position // in RemoteMediaClient. diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastUtils.java b/libraries/cast/src/main/java/androidx/media3/cast/CastUtils.java index a7a2481843..5e0b045cd8 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastUtils.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastUtils.java @@ -26,10 +26,6 @@ import com.google.android.gms.cast.MediaTrack; /** Utility methods for Cast integration. */ /* package */ final class CastUtils { - /** The duration returned by {@link MediaInfo#getStreamDuration()} for live streams. */ - // TODO: Remove once [Internal ref: b/171657375] is fixed. - private static final long LIVE_STREAM_DURATION = -1000; - /** * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if * unknown or not applicable. @@ -42,9 +38,7 @@ import com.google.android.gms.cast.MediaTrack; return C.TIME_UNSET; } long durationMs = mediaInfo.getStreamDuration(); - return durationMs != MediaInfo.UNKNOWN_DURATION && durationMs != LIVE_STREAM_DURATION - ? Util.msToUs(durationMs) - : C.TIME_UNSET; + return durationMs != MediaInfo.UNKNOWN_DURATION ? Util.msToUs(durationMs) : C.TIME_UNSET; } /** diff --git a/libraries/common/README.md b/libraries/common/README.md index 216342644a..f2ef17bac6 100644 --- a/libraries/common/README.md +++ b/libraries/common/README.md @@ -2,3 +2,9 @@ Provides common code and utilities used by other media modules. Application code will not normally need to depend on this module directly. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java index 8efa8f218d..5bc8f9d0a9 100644 --- a/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java +++ b/libraries/common/src/main/java/androidx/media3/common/AdPlaybackState.java @@ -459,44 +459,29 @@ public final class AdPlaybackState implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TIME_US, - FIELD_COUNT, - FIELD_URIS, - FIELD_STATES, - FIELD_DURATIONS_US, - FIELD_CONTENT_RESUME_OFFSET_US, - FIELD_IS_SERVER_SIDE_INSERTED, - FIELD_ORIGINAL_COUNT - }) - private @interface FieldNumber {} - - private static final int FIELD_TIME_US = 0; - private static final int FIELD_COUNT = 1; - private static final int FIELD_URIS = 2; - private static final int FIELD_STATES = 3; - private static final int FIELD_DURATIONS_US = 4; - private static final int FIELD_CONTENT_RESUME_OFFSET_US = 5; - private static final int FIELD_IS_SERVER_SIDE_INSERTED = 6; - private static final int FIELD_ORIGINAL_COUNT = 7; + private static final String FIELD_TIME_US = Util.intToStringMaxRadix(0); + private static final String FIELD_COUNT = Util.intToStringMaxRadix(1); + private static final String FIELD_URIS = Util.intToStringMaxRadix(2); + private static final String FIELD_STATES = Util.intToStringMaxRadix(3); + private static final String FIELD_DURATIONS_US = Util.intToStringMaxRadix(4); + private static final String FIELD_CONTENT_RESUME_OFFSET_US = Util.intToStringMaxRadix(5); + private static final String FIELD_IS_SERVER_SIDE_INSERTED = Util.intToStringMaxRadix(6); + private static final String FIELD_ORIGINAL_COUNT = Util.intToStringMaxRadix(7); // putParcelableArrayList actually supports null elements. @SuppressWarnings("nullness:argument") @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putLong(keyForField(FIELD_TIME_US), timeUs); - bundle.putInt(keyForField(FIELD_COUNT), count); - bundle.putInt(keyForField(FIELD_ORIGINAL_COUNT), originalCount); + bundle.putLong(FIELD_TIME_US, timeUs); + bundle.putInt(FIELD_COUNT, count); + bundle.putInt(FIELD_ORIGINAL_COUNT, originalCount); bundle.putParcelableArrayList( - keyForField(FIELD_URIS), new ArrayList<@NullableType Uri>(Arrays.asList(uris))); - bundle.putIntArray(keyForField(FIELD_STATES), states); - bundle.putLongArray(keyForField(FIELD_DURATIONS_US), durationsUs); - bundle.putLong(keyForField(FIELD_CONTENT_RESUME_OFFSET_US), contentResumeOffsetUs); - bundle.putBoolean(keyForField(FIELD_IS_SERVER_SIDE_INSERTED), isServerSideInserted); + FIELD_URIS, new ArrayList<@NullableType Uri>(Arrays.asList(uris))); + bundle.putIntArray(FIELD_STATES, states); + bundle.putLongArray(FIELD_DURATIONS_US, durationsUs); + bundle.putLong(FIELD_CONTENT_RESUME_OFFSET_US, contentResumeOffsetUs); + bundle.putBoolean(FIELD_IS_SERVER_SIDE_INSERTED, isServerSideInserted); return bundle; } @@ -506,18 +491,16 @@ public final class AdPlaybackState implements Bundleable { // getParcelableArrayList may have null elements. @SuppressWarnings("nullness:type.argument") private static AdGroup fromBundle(Bundle bundle) { - long timeUs = bundle.getLong(keyForField(FIELD_TIME_US)); - int count = bundle.getInt(keyForField(FIELD_COUNT), /* defaultValue= */ C.LENGTH_UNSET); - int originalCount = - bundle.getInt(keyForField(FIELD_ORIGINAL_COUNT), /* defaultValue= */ C.LENGTH_UNSET); - @Nullable - ArrayList<@NullableType Uri> uriList = bundle.getParcelableArrayList(keyForField(FIELD_URIS)); + long timeUs = bundle.getLong(FIELD_TIME_US); + int count = bundle.getInt(FIELD_COUNT); + int originalCount = bundle.getInt(FIELD_ORIGINAL_COUNT); + @Nullable ArrayList<@NullableType Uri> uriList = bundle.getParcelableArrayList(FIELD_URIS); @Nullable @AdState - int[] states = bundle.getIntArray(keyForField(FIELD_STATES)); - @Nullable long[] durationsUs = bundle.getLongArray(keyForField(FIELD_DURATIONS_US)); - long contentResumeOffsetUs = bundle.getLong(keyForField(FIELD_CONTENT_RESUME_OFFSET_US)); - boolean isServerSideInserted = bundle.getBoolean(keyForField(FIELD_IS_SERVER_SIDE_INSERTED)); + int[] states = bundle.getIntArray(FIELD_STATES); + @Nullable long[] durationsUs = bundle.getLongArray(FIELD_DURATIONS_US); + long contentResumeOffsetUs = bundle.getLong(FIELD_CONTENT_RESUME_OFFSET_US); + boolean isServerSideInserted = bundle.getBoolean(FIELD_IS_SERVER_SIDE_INSERTED); return new AdGroup( timeUs, count, @@ -528,10 +511,6 @@ public final class AdPlaybackState implements Bundleable { contentResumeOffsetUs, isServerSideInserted); } - - private static String keyForField(@AdGroup.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** @@ -1122,21 +1101,10 @@ public final class AdPlaybackState implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_AD_GROUPS, - FIELD_AD_RESUME_POSITION_US, - FIELD_CONTENT_DURATION_US, - FIELD_REMOVED_AD_GROUP_COUNT - }) - private @interface FieldNumber {} - - private static final int FIELD_AD_GROUPS = 1; - private static final int FIELD_AD_RESUME_POSITION_US = 2; - private static final int FIELD_CONTENT_DURATION_US = 3; - private static final int FIELD_REMOVED_AD_GROUP_COUNT = 4; + private static final String FIELD_AD_GROUPS = Util.intToStringMaxRadix(1); + private static final String FIELD_AD_RESUME_POSITION_US = Util.intToStringMaxRadix(2); + private static final String FIELD_CONTENT_DURATION_US = Util.intToStringMaxRadix(3); + private static final String FIELD_REMOVED_AD_GROUP_COUNT = Util.intToStringMaxRadix(4); /** * {@inheritDoc} @@ -1152,10 +1120,18 @@ public final class AdPlaybackState implements Bundleable { for (AdGroup adGroup : adGroups) { adGroupBundleList.add(adGroup.toBundle()); } - bundle.putParcelableArrayList(keyForField(FIELD_AD_GROUPS), adGroupBundleList); - bundle.putLong(keyForField(FIELD_AD_RESUME_POSITION_US), adResumePositionUs); - bundle.putLong(keyForField(FIELD_CONTENT_DURATION_US), contentDurationUs); - bundle.putInt(keyForField(FIELD_REMOVED_AD_GROUP_COUNT), removedAdGroupCount); + if (!adGroupBundleList.isEmpty()) { + bundle.putParcelableArrayList(FIELD_AD_GROUPS, adGroupBundleList); + } + if (adResumePositionUs != NONE.adResumePositionUs) { + bundle.putLong(FIELD_AD_RESUME_POSITION_US, adResumePositionUs); + } + if (contentDurationUs != NONE.contentDurationUs) { + bundle.putLong(FIELD_CONTENT_DURATION_US, contentDurationUs); + } + if (removedAdGroupCount != NONE.removedAdGroupCount) { + bundle.putInt(FIELD_REMOVED_AD_GROUP_COUNT, removedAdGroupCount); + } return bundle; } @@ -1167,9 +1143,7 @@ public final class AdPlaybackState implements Bundleable { public static final Bundleable.Creator CREATOR = AdPlaybackState::fromBundle; private static AdPlaybackState fromBundle(Bundle bundle) { - @Nullable - ArrayList adGroupBundleList = - bundle.getParcelableArrayList(keyForField(FIELD_AD_GROUPS)); + @Nullable ArrayList adGroupBundleList = bundle.getParcelableArrayList(FIELD_AD_GROUPS); @Nullable AdGroup[] adGroups; if (adGroupBundleList == null) { adGroups = new AdGroup[0]; @@ -1180,18 +1154,15 @@ public final class AdPlaybackState implements Bundleable { } } long adResumePositionUs = - bundle.getLong(keyForField(FIELD_AD_RESUME_POSITION_US), /* defaultValue= */ 0); + bundle.getLong(FIELD_AD_RESUME_POSITION_US, /* defaultValue= */ NONE.adResumePositionUs); long contentDurationUs = - bundle.getLong(keyForField(FIELD_CONTENT_DURATION_US), /* defaultValue= */ C.TIME_UNSET); - int removedAdGroupCount = bundle.getInt(keyForField(FIELD_REMOVED_AD_GROUP_COUNT)); + bundle.getLong(FIELD_CONTENT_DURATION_US, /* defaultValue= */ NONE.contentDurationUs); + int removedAdGroupCount = + bundle.getInt(FIELD_REMOVED_AD_GROUP_COUNT, /* defaultValue= */ NONE.removedAdGroupCount); return new AdPlaybackState( /* adsId= */ null, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); } - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - private static AdGroup[] createEmptyAdGroups(long[] adGroupTimesUs) { AdGroup[] adGroups = new AdGroup[adGroupTimesUs.length]; for (int i = 0; i < adGroups.length; i++) { diff --git a/libraries/common/src/main/java/androidx/media3/common/AudioAttributes.java b/libraries/common/src/main/java/androidx/media3/common/AudioAttributes.java index 11a8ef15bd..6406baf87a 100644 --- a/libraries/common/src/main/java/androidx/media3/common/AudioAttributes.java +++ b/libraries/common/src/main/java/androidx/media3/common/AudioAttributes.java @@ -15,20 +15,13 @@ */ package androidx.media3.common; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; import androidx.annotation.DoNotInline; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** * Attributes for audio playback, which configure the underlying platform {@link @@ -205,33 +198,21 @@ public final class AudioAttributes implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_CONTENT_TYPE, - FIELD_FLAGS, - FIELD_USAGE, - FIELD_ALLOWED_CAPTURE_POLICY, - FIELD_SPATIALIZATION_BEHAVIOR - }) - private @interface FieldNumber {} - - private static final int FIELD_CONTENT_TYPE = 0; - private static final int FIELD_FLAGS = 1; - private static final int FIELD_USAGE = 2; - private static final int FIELD_ALLOWED_CAPTURE_POLICY = 3; - private static final int FIELD_SPATIALIZATION_BEHAVIOR = 4; + private static final String FIELD_CONTENT_TYPE = Util.intToStringMaxRadix(0); + private static final String FIELD_FLAGS = Util.intToStringMaxRadix(1); + private static final String FIELD_USAGE = Util.intToStringMaxRadix(2); + private static final String FIELD_ALLOWED_CAPTURE_POLICY = Util.intToStringMaxRadix(3); + private static final String FIELD_SPATIALIZATION_BEHAVIOR = Util.intToStringMaxRadix(4); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_CONTENT_TYPE), contentType); - bundle.putInt(keyForField(FIELD_FLAGS), flags); - bundle.putInt(keyForField(FIELD_USAGE), usage); - bundle.putInt(keyForField(FIELD_ALLOWED_CAPTURE_POLICY), allowedCapturePolicy); - bundle.putInt(keyForField(FIELD_SPATIALIZATION_BEHAVIOR), spatializationBehavior); + bundle.putInt(FIELD_CONTENT_TYPE, contentType); + bundle.putInt(FIELD_FLAGS, flags); + bundle.putInt(FIELD_USAGE, usage); + bundle.putInt(FIELD_ALLOWED_CAPTURE_POLICY, allowedCapturePolicy); + bundle.putInt(FIELD_SPATIALIZATION_BEHAVIOR, spatializationBehavior); return bundle; } @@ -240,29 +221,24 @@ public final class AudioAttributes implements Bundleable { public static final Creator CREATOR = bundle -> { Builder builder = new Builder(); - if (bundle.containsKey(keyForField(FIELD_CONTENT_TYPE))) { - builder.setContentType(bundle.getInt(keyForField(FIELD_CONTENT_TYPE))); + if (bundle.containsKey(FIELD_CONTENT_TYPE)) { + builder.setContentType(bundle.getInt(FIELD_CONTENT_TYPE)); } - if (bundle.containsKey(keyForField(FIELD_FLAGS))) { - builder.setFlags(bundle.getInt(keyForField(FIELD_FLAGS))); + if (bundle.containsKey(FIELD_FLAGS)) { + builder.setFlags(bundle.getInt(FIELD_FLAGS)); } - if (bundle.containsKey(keyForField(FIELD_USAGE))) { - builder.setUsage(bundle.getInt(keyForField(FIELD_USAGE))); + if (bundle.containsKey(FIELD_USAGE)) { + builder.setUsage(bundle.getInt(FIELD_USAGE)); } - if (bundle.containsKey(keyForField(FIELD_ALLOWED_CAPTURE_POLICY))) { - builder.setAllowedCapturePolicy(bundle.getInt(keyForField(FIELD_ALLOWED_CAPTURE_POLICY))); + if (bundle.containsKey(FIELD_ALLOWED_CAPTURE_POLICY)) { + builder.setAllowedCapturePolicy(bundle.getInt(FIELD_ALLOWED_CAPTURE_POLICY)); } - if (bundle.containsKey(keyForField(FIELD_SPATIALIZATION_BEHAVIOR))) { - builder.setSpatializationBehavior( - bundle.getInt(keyForField(FIELD_SPATIALIZATION_BEHAVIOR))); + if (bundle.containsKey(FIELD_SPATIALIZATION_BEHAVIOR)) { + builder.setSpatializationBehavior(bundle.getInt(FIELD_SPATIALIZATION_BEHAVIOR)); } return builder.build(); }; - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - @RequiresApi(29) private static final class Api29 { @DoNotInline diff --git a/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java index 74b144baa3..b0f31e5d21 100644 --- a/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java @@ -15,14 +15,15 @@ */ package androidx.media3.common; +import static androidx.annotation.VisibleForTesting.PROTECTED; import static java.lang.Math.max; import static java.lang.Math.min; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; -import com.google.errorprone.annotations.ForOverride; import java.util.List; /** Abstract base {@link Player} which implements common implementation independent methods. */ @@ -121,27 +122,23 @@ public abstract class BasePlayer implements Player { @Override public final void seekToDefaultPosition() { - seekToDefaultPosition(getCurrentMediaItemIndex()); + seekToDefaultPositionInternal( + getCurrentMediaItemIndex(), Player.COMMAND_SEEK_TO_DEFAULT_POSITION); } @Override public final void seekToDefaultPosition(int mediaItemIndex) { - seekTo(mediaItemIndex, /* positionMs= */ C.TIME_UNSET); - } - - @Override - public final void seekTo(long positionMs) { - seekTo(getCurrentMediaItemIndex(), positionMs); + seekToDefaultPositionInternal(mediaItemIndex, Player.COMMAND_SEEK_TO_MEDIA_ITEM); } @Override public final void seekBack() { - seekToOffset(-getSeekBackIncrement()); + seekToOffset(-getSeekBackIncrement(), Player.COMMAND_SEEK_BACK); } @Override public final void seekForward() { - seekToOffset(getSeekForwardIncrement()); + seekToOffset(getSeekForwardIncrement(), Player.COMMAND_SEEK_FORWARD); } /** @@ -187,15 +184,7 @@ public abstract class BasePlayer implements Player { @Override public final void seekToPreviousMediaItem() { - int previousMediaItemIndex = getPreviousMediaItemIndex(); - if (previousMediaItemIndex == C.INDEX_UNSET) { - return; - } - if (previousMediaItemIndex == getCurrentMediaItemIndex()) { - repeatCurrentMediaItem(); - } else { - seekToDefaultPosition(previousMediaItemIndex); - } + seekToPreviousMediaItemInternal(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM); } @Override @@ -207,12 +196,12 @@ public abstract class BasePlayer implements Player { boolean hasPreviousMediaItem = hasPreviousMediaItem(); if (isCurrentMediaItemLive() && !isCurrentMediaItemSeekable()) { if (hasPreviousMediaItem) { - seekToPreviousMediaItem(); + seekToPreviousMediaItemInternal(Player.COMMAND_SEEK_TO_PREVIOUS); } } else if (hasPreviousMediaItem && getCurrentPosition() <= getMaxSeekToPreviousPosition()) { - seekToPreviousMediaItem(); + seekToPreviousMediaItemInternal(Player.COMMAND_SEEK_TO_PREVIOUS); } else { - seekTo(/* positionMs= */ 0); + seekToCurrentItem(/* positionMs= */ 0, Player.COMMAND_SEEK_TO_PREVIOUS); } } @@ -259,15 +248,7 @@ public abstract class BasePlayer implements Player { @Override public final void seekToNextMediaItem() { - int nextMediaItemIndex = getNextMediaItemIndex(); - if (nextMediaItemIndex == C.INDEX_UNSET) { - return; - } - if (nextMediaItemIndex == getCurrentMediaItemIndex()) { - repeatCurrentMediaItem(); - } else { - seekToDefaultPosition(nextMediaItemIndex); - } + seekToNextMediaItemInternal(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM); } @Override @@ -277,12 +258,42 @@ public abstract class BasePlayer implements Player { return; } if (hasNextMediaItem()) { - seekToNextMediaItem(); + seekToNextMediaItemInternal(Player.COMMAND_SEEK_TO_NEXT); } else if (isCurrentMediaItemLive() && isCurrentMediaItemDynamic()) { - seekToDefaultPosition(); + seekToDefaultPositionInternal(getCurrentMediaItemIndex(), Player.COMMAND_SEEK_TO_NEXT); } } + @Override + public final void seekTo(long positionMs) { + seekToCurrentItem(positionMs, Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM); + } + + @Override + public final void seekTo(int mediaItemIndex, long positionMs) { + seekTo( + mediaItemIndex, + positionMs, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + /** + * Seeks to a position in the specified {@link MediaItem}. + * + * @param mediaItemIndex The index of the {@link MediaItem}. + * @param positionMs The seek position in the specified {@link MediaItem} in milliseconds, or + * {@link C#TIME_UNSET} to seek to the media item's default position. + * @param seekCommand The {@link Player.Command} used to trigger the seek. + * @param isRepeatingCurrentItem Whether this seeks repeats the current item. + */ + @VisibleForTesting(otherwise = PROTECTED) + public abstract void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem); + @Override public final void setPlaybackSpeed(float speed) { setPlaybackParameters(getPlaybackParameters().withSpeed(speed)); @@ -437,29 +448,63 @@ public abstract class BasePlayer implements Player { : timeline.getWindow(getCurrentMediaItemIndex(), window).getDurationMs(); } - /** - * Repeat the current media item. - * - *

The default implementation seeks to the default position in the current item, which can be - * overridden for additional handling. - */ - @ForOverride - protected void repeatCurrentMediaItem() { - seekToDefaultPosition(); - } - private @RepeatMode int getRepeatModeForNavigation() { @RepeatMode int repeatMode = getRepeatMode(); return repeatMode == REPEAT_MODE_ONE ? REPEAT_MODE_OFF : repeatMode; } - private void seekToOffset(long offsetMs) { + private void seekToCurrentItem(long positionMs, @Player.Command int seekCommand) { + seekTo( + getCurrentMediaItemIndex(), positionMs, seekCommand, /* isRepeatingCurrentItem= */ false); + } + + private void seekToOffset(long offsetMs, @Player.Command int seekCommand) { long positionMs = getCurrentPosition() + offsetMs; long durationMs = getDuration(); if (durationMs != C.TIME_UNSET) { positionMs = min(positionMs, durationMs); } positionMs = max(positionMs, 0); - seekTo(positionMs); + seekToCurrentItem(positionMs, seekCommand); + } + + private void seekToDefaultPositionInternal(int mediaItemIndex, @Player.Command int seekCommand) { + seekTo( + mediaItemIndex, + /* positionMs= */ C.TIME_UNSET, + seekCommand, + /* isRepeatingCurrentItem= */ false); + } + + private void seekToNextMediaItemInternal(@Player.Command int seekCommand) { + int nextMediaItemIndex = getNextMediaItemIndex(); + if (nextMediaItemIndex == C.INDEX_UNSET) { + return; + } + if (nextMediaItemIndex == getCurrentMediaItemIndex()) { + repeatCurrentMediaItem(seekCommand); + } else { + seekToDefaultPositionInternal(nextMediaItemIndex, seekCommand); + } + } + + private void seekToPreviousMediaItemInternal(@Player.Command int seekCommand) { + int previousMediaItemIndex = getPreviousMediaItemIndex(); + if (previousMediaItemIndex == C.INDEX_UNSET) { + return; + } + if (previousMediaItemIndex == getCurrentMediaItemIndex()) { + repeatCurrentMediaItem(seekCommand); + } else { + seekToDefaultPositionInternal(previousMediaItemIndex, seekCommand); + } + } + + private void repeatCurrentMediaItem(@Player.Command int seekCommand) { + seekTo( + getCurrentMediaItemIndex(), + /* positionMs= */ C.TIME_UNSET, + seekCommand, + /* isRepeatingCurrentItem= */ true); } } diff --git a/libraries/common/src/main/java/androidx/media3/common/C.java b/libraries/common/src/main/java/androidx/media3/common/C.java index 968253e4b3..ac44ebb603 100644 --- a/libraries/common/src/main/java/androidx/media3/common/C.java +++ b/libraries/common/src/main/java/androidx/media3/common/C.java @@ -196,7 +196,7 @@ public final class C { * #ENCODING_PCM_16BIT_BIG_ENDIAN}, {@link #ENCODING_PCM_24BIT}, {@link #ENCODING_PCM_32BIT}, * {@link #ENCODING_PCM_FLOAT}, {@link #ENCODING_MP3}, {@link #ENCODING_AC3}, {@link * #ENCODING_E_AC3}, {@link #ENCODING_E_AC3_JOC}, {@link #ENCODING_AC4}, {@link #ENCODING_DTS}, - * {@link #ENCODING_DTS_HD} or {@link #ENCODING_DOLBY_TRUEHD}. + * {@link #ENCODING_DTS_HD}, {@link #ENCODING_DOLBY_TRUEHD} or {@link #ENCODING_OPUS}. */ @UnstableApi @Documented @@ -224,7 +224,8 @@ public final class C { ENCODING_AC4, ENCODING_DTS, ENCODING_DTS_HD, - ENCODING_DOLBY_TRUEHD + ENCODING_DOLBY_TRUEHD, + ENCODING_OPUS, }) public @interface Encoding {} @@ -325,6 +326,10 @@ public final class C { * @see AudioFormat#ENCODING_DOLBY_TRUEHD */ @UnstableApi public static final int ENCODING_DOLBY_TRUEHD = AudioFormat.ENCODING_DOLBY_TRUEHD; + /** + * @see AudioFormat#ENCODING_OPUS + */ + @UnstableApi public static final int ENCODING_OPUS = AudioFormat.ENCODING_OPUS; /** Represents the behavior affecting whether spatialization will be used. */ @Documented diff --git a/libraries/common/src/main/java/androidx/media3/common/ColorInfo.java b/libraries/common/src/main/java/androidx/media3/common/ColorInfo.java index aae29250d1..034ada4fe8 100644 --- a/libraries/common/src/main/java/androidx/media3/common/ColorInfo.java +++ b/libraries/common/src/main/java/androidx/media3/common/ColorInfo.java @@ -15,16 +15,10 @@ */ package androidx.media3.common; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import androidx.media3.common.util.Util; import java.util.Arrays; import org.checkerframework.dataflow.qual.Pure; @@ -183,41 +177,26 @@ public final class ColorInfo implements Bundleable { // Bundleable implementation - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_COLOR_SPACE, - FIELD_COLOR_RANGE, - FIELD_COLOR_TRANSFER, - FIELD_HDR_STATIC_INFO, - }) - private @interface FieldNumber {} - - private static final int FIELD_COLOR_SPACE = 0; - private static final int FIELD_COLOR_RANGE = 1; - private static final int FIELD_COLOR_TRANSFER = 2; - private static final int FIELD_HDR_STATIC_INFO = 3; + private static final String FIELD_COLOR_SPACE = Util.intToStringMaxRadix(0); + private static final String FIELD_COLOR_RANGE = Util.intToStringMaxRadix(1); + private static final String FIELD_COLOR_TRANSFER = Util.intToStringMaxRadix(2); + private static final String FIELD_HDR_STATIC_INFO = Util.intToStringMaxRadix(3); @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_COLOR_SPACE), colorSpace); - bundle.putInt(keyForField(FIELD_COLOR_RANGE), colorRange); - bundle.putInt(keyForField(FIELD_COLOR_TRANSFER), colorTransfer); - bundle.putByteArray(keyForField(FIELD_HDR_STATIC_INFO), hdrStaticInfo); + bundle.putInt(FIELD_COLOR_SPACE, colorSpace); + bundle.putInt(FIELD_COLOR_RANGE, colorRange); + bundle.putInt(FIELD_COLOR_TRANSFER, colorTransfer); + bundle.putByteArray(FIELD_HDR_STATIC_INFO, hdrStaticInfo); return bundle; } public static final Creator CREATOR = bundle -> new ColorInfo( - bundle.getInt(keyForField(FIELD_COLOR_SPACE), Format.NO_VALUE), - bundle.getInt(keyForField(FIELD_COLOR_RANGE), Format.NO_VALUE), - bundle.getInt(keyForField(FIELD_COLOR_TRANSFER), Format.NO_VALUE), - bundle.getByteArray(keyForField(FIELD_HDR_STATIC_INFO))); - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } + bundle.getInt(FIELD_COLOR_SPACE, Format.NO_VALUE), + bundle.getInt(FIELD_COLOR_RANGE, Format.NO_VALUE), + bundle.getInt(FIELD_COLOR_TRANSFER, Format.NO_VALUE), + bundle.getByteArray(FIELD_HDR_STATIC_INFO)); } diff --git a/libraries/common/src/main/java/androidx/media3/common/DeviceInfo.java b/libraries/common/src/main/java/androidx/media3/common/DeviceInfo.java index 2daeb92ef2..c75fcb7cc9 100644 --- a/libraries/common/src/main/java/androidx/media3/common/DeviceInfo.java +++ b/libraries/common/src/main/java/androidx/media3/common/DeviceInfo.java @@ -21,6 +21,7 @@ import android.os.Bundle; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -87,23 +88,17 @@ public final class DeviceInfo implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_PLAYBACK_TYPE, FIELD_MIN_VOLUME, FIELD_MAX_VOLUME}) - private @interface FieldNumber {} - - private static final int FIELD_PLAYBACK_TYPE = 0; - private static final int FIELD_MIN_VOLUME = 1; - private static final int FIELD_MAX_VOLUME = 2; + private static final String FIELD_PLAYBACK_TYPE = Util.intToStringMaxRadix(0); + private static final String FIELD_MIN_VOLUME = Util.intToStringMaxRadix(1); + private static final String FIELD_MAX_VOLUME = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_PLAYBACK_TYPE), playbackType); - bundle.putInt(keyForField(FIELD_MIN_VOLUME), minVolume); - bundle.putInt(keyForField(FIELD_MAX_VOLUME), maxVolume); + bundle.putInt(FIELD_PLAYBACK_TYPE, playbackType); + bundle.putInt(FIELD_MIN_VOLUME, minVolume); + bundle.putInt(FIELD_MAX_VOLUME, maxVolume); return bundle; } @@ -112,14 +107,9 @@ public final class DeviceInfo implements Bundleable { public static final Creator CREATOR = bundle -> { int playbackType = - bundle.getInt( - keyForField(FIELD_PLAYBACK_TYPE), /* defaultValue= */ PLAYBACK_TYPE_LOCAL); - int minVolume = bundle.getInt(keyForField(FIELD_MIN_VOLUME), /* defaultValue= */ 0); - int maxVolume = bundle.getInt(keyForField(FIELD_MAX_VOLUME), /* defaultValue= */ 0); + bundle.getInt(FIELD_PLAYBACK_TYPE, /* defaultValue= */ PLAYBACK_TYPE_LOCAL); + int minVolume = bundle.getInt(FIELD_MIN_VOLUME, /* defaultValue= */ 0); + int maxVolume = bundle.getInt(FIELD_MAX_VOLUME, /* defaultValue= */ 0); return new DeviceInfo(playbackType, minVolume, maxVolume); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/Format.java b/libraries/common/src/main/java/androidx/media3/common/Format.java index bb712e2472..b34bead66e 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Format.java +++ b/libraries/common/src/main/java/androidx/media3/common/Format.java @@ -15,20 +15,13 @@ */ package androidx.media3.common; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Joiner; import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -112,6 +105,13 @@ import java.util.UUID; *

    *
  • {@link #accessibilityChannel} *
+ * + *

Fields relevant to image formats

+ * + *
    + *
  • {@link #tileCountHorizontal} + *
  • {@link #tileCountVertical} + *
*/ public final class Format implements Bundleable { @@ -172,6 +172,11 @@ public final class Format implements Bundleable { private int accessibilityChannel; + // Image specific + + private int tileCountHorizontal; + private int tileCountVertical; + // Provided by the source. private @C.CryptoType int cryptoType; @@ -195,6 +200,9 @@ public final class Format implements Bundleable { pcmEncoding = NO_VALUE; // Text specific. accessibilityChannel = NO_VALUE; + // Image specific. + tileCountHorizontal = NO_VALUE; + tileCountVertical = NO_VALUE; // Provided by the source. cryptoType = C.CRYPTO_TYPE_NONE; } @@ -239,6 +247,9 @@ public final class Format implements Bundleable { this.encoderPadding = format.encoderPadding; // Text specific. this.accessibilityChannel = format.accessibilityChannel; + // Image specific. + this.tileCountHorizontal = format.tileCountHorizontal; + this.tileCountVertical = format.tileCountVertical; // Provided by the source. this.cryptoType = format.cryptoType; } @@ -614,6 +625,32 @@ public final class Format implements Bundleable { return this; } + // Image specific. + + /** + * Sets {@link Format#tileCountHorizontal}. The default value is {@link #NO_VALUE}. + * + * @param tileCountHorizontal The {@link Format#accessibilityChannel}. + * @return The builder. + */ + @CanIgnoreReturnValue + public Builder setTileCountHorizontal(int tileCountHorizontal) { + this.tileCountHorizontal = tileCountHorizontal; + return this; + } + + /** + * Sets {@link Format#tileCountVertical}. The default value is {@link #NO_VALUE}. + * + * @param tileCountVertical The {@link Format#accessibilityChannel}. + * @return The builder. + */ + @CanIgnoreReturnValue + public Builder setTileCountVertical(int tileCountVertical) { + this.tileCountVertical = tileCountVertical; + return this; + } + // Provided by source. /** @@ -786,6 +823,15 @@ public final class Format implements Bundleable { /** The Accessibility channel, or {@link #NO_VALUE} if not known or applicable. */ @UnstableApi public final int accessibilityChannel; + // Image specific. + + /** + * The number of horizontal tiles in an image, or {@link #NO_VALUE} if not known or applicable. + */ + @UnstableApi public final int tileCountHorizontal; + /** The number of vertical tiles in an image, or {@link #NO_VALUE} if not known or applicable. */ + @UnstableApi public final int tileCountVertical; + // Provided by source. /** @@ -1015,6 +1061,9 @@ public final class Format implements Bundleable { encoderPadding = builder.encoderPadding == NO_VALUE ? 0 : builder.encoderPadding; // Text specific. accessibilityChannel = builder.accessibilityChannel; + // Image specific. + tileCountHorizontal = builder.tileCountHorizontal; + tileCountVertical = builder.tileCountVertical; // Provided by source. if (builder.cryptoType == C.CRYPTO_TYPE_NONE && drmInitData != null) { // Encrypted content cannot use CRYPTO_TYPE_NONE. @@ -1275,6 +1324,9 @@ public final class Format implements Bundleable { result = 31 * result + encoderPadding; // Text specific. result = 31 * result + accessibilityChannel; + // Image specific. + result = 31 * result + tileCountHorizontal; + result = 31 * result + tileCountVertical; // Provided by the source. result = 31 * result + cryptoType; hashCode = result; @@ -1311,6 +1363,8 @@ public final class Format implements Bundleable { && encoderDelay == other.encoderDelay && encoderPadding == other.encoderPadding && accessibilityChannel == other.accessibilityChannel + && tileCountHorizontal == other.tileCountHorizontal + && tileCountVertical == other.tileCountVertical && cryptoType == other.cryptoType && Float.compare(frameRate, other.frameRate) == 0 && Float.compare(pixelWidthHeightRatio, other.pixelWidthHeightRatio) == 0 @@ -1476,73 +1530,39 @@ public final class Format implements Bundleable { } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_ID, - FIELD_LABEL, - FIELD_LANGUAGE, - FIELD_SELECTION_FLAGS, - FIELD_ROLE_FLAGS, - FIELD_AVERAGE_BITRATE, - FIELD_PEAK_BITRATE, - FIELD_CODECS, - FIELD_METADATA, - FIELD_CONTAINER_MIME_TYPE, - FIELD_SAMPLE_MIME_TYPE, - FIELD_MAX_INPUT_SIZE, - FIELD_INITIALIZATION_DATA, - FIELD_DRM_INIT_DATA, - FIELD_SUBSAMPLE_OFFSET_US, - FIELD_WIDTH, - FIELD_HEIGHT, - FIELD_FRAME_RATE, - FIELD_ROTATION_DEGREES, - FIELD_PIXEL_WIDTH_HEIGHT_RATIO, - FIELD_PROJECTION_DATA, - FIELD_STEREO_MODE, - FIELD_COLOR_INFO, - FIELD_CHANNEL_COUNT, - FIELD_SAMPLE_RATE, - FIELD_PCM_ENCODING, - FIELD_ENCODER_DELAY, - FIELD_ENCODER_PADDING, - FIELD_ACCESSIBILITY_CHANNEL, - FIELD_CRYPTO_TYPE, - }) - private @interface FieldNumber {} - private static final int FIELD_ID = 0; - private static final int FIELD_LABEL = 1; - private static final int FIELD_LANGUAGE = 2; - private static final int FIELD_SELECTION_FLAGS = 3; - private static final int FIELD_ROLE_FLAGS = 4; - private static final int FIELD_AVERAGE_BITRATE = 5; - private static final int FIELD_PEAK_BITRATE = 6; - private static final int FIELD_CODECS = 7; - private static final int FIELD_METADATA = 8; - private static final int FIELD_CONTAINER_MIME_TYPE = 9; - private static final int FIELD_SAMPLE_MIME_TYPE = 10; - private static final int FIELD_MAX_INPUT_SIZE = 11; - private static final int FIELD_INITIALIZATION_DATA = 12; - private static final int FIELD_DRM_INIT_DATA = 13; - private static final int FIELD_SUBSAMPLE_OFFSET_US = 14; - private static final int FIELD_WIDTH = 15; - private static final int FIELD_HEIGHT = 16; - private static final int FIELD_FRAME_RATE = 17; - private static final int FIELD_ROTATION_DEGREES = 18; - private static final int FIELD_PIXEL_WIDTH_HEIGHT_RATIO = 19; - private static final int FIELD_PROJECTION_DATA = 20; - private static final int FIELD_STEREO_MODE = 21; - private static final int FIELD_COLOR_INFO = 22; - private static final int FIELD_CHANNEL_COUNT = 23; - private static final int FIELD_SAMPLE_RATE = 24; - private static final int FIELD_PCM_ENCODING = 25; - private static final int FIELD_ENCODER_DELAY = 26; - private static final int FIELD_ENCODER_PADDING = 27; - private static final int FIELD_ACCESSIBILITY_CHANNEL = 28; - private static final int FIELD_CRYPTO_TYPE = 29; + private static final String FIELD_ID = Util.intToStringMaxRadix(0); + private static final String FIELD_LABEL = Util.intToStringMaxRadix(1); + private static final String FIELD_LANGUAGE = Util.intToStringMaxRadix(2); + private static final String FIELD_SELECTION_FLAGS = Util.intToStringMaxRadix(3); + private static final String FIELD_ROLE_FLAGS = Util.intToStringMaxRadix(4); + private static final String FIELD_AVERAGE_BITRATE = Util.intToStringMaxRadix(5); + private static final String FIELD_PEAK_BITRATE = Util.intToStringMaxRadix(6); + private static final String FIELD_CODECS = Util.intToStringMaxRadix(7); + private static final String FIELD_METADATA = Util.intToStringMaxRadix(8); + private static final String FIELD_CONTAINER_MIME_TYPE = Util.intToStringMaxRadix(9); + private static final String FIELD_SAMPLE_MIME_TYPE = Util.intToStringMaxRadix(10); + private static final String FIELD_MAX_INPUT_SIZE = Util.intToStringMaxRadix(11); + private static final String FIELD_INITIALIZATION_DATA = Util.intToStringMaxRadix(12); + private static final String FIELD_DRM_INIT_DATA = Util.intToStringMaxRadix(13); + private static final String FIELD_SUBSAMPLE_OFFSET_US = Util.intToStringMaxRadix(14); + private static final String FIELD_WIDTH = Util.intToStringMaxRadix(15); + private static final String FIELD_HEIGHT = Util.intToStringMaxRadix(16); + private static final String FIELD_FRAME_RATE = Util.intToStringMaxRadix(17); + private static final String FIELD_ROTATION_DEGREES = Util.intToStringMaxRadix(18); + private static final String FIELD_PIXEL_WIDTH_HEIGHT_RATIO = Util.intToStringMaxRadix(19); + private static final String FIELD_PROJECTION_DATA = Util.intToStringMaxRadix(20); + private static final String FIELD_STEREO_MODE = Util.intToStringMaxRadix(21); + private static final String FIELD_COLOR_INFO = Util.intToStringMaxRadix(22); + private static final String FIELD_CHANNEL_COUNT = Util.intToStringMaxRadix(23); + private static final String FIELD_SAMPLE_RATE = Util.intToStringMaxRadix(24); + private static final String FIELD_PCM_ENCODING = Util.intToStringMaxRadix(25); + private static final String FIELD_ENCODER_DELAY = Util.intToStringMaxRadix(26); + private static final String FIELD_ENCODER_PADDING = Util.intToStringMaxRadix(27); + private static final String FIELD_ACCESSIBILITY_CHANNEL = Util.intToStringMaxRadix(28); + private static final String FIELD_CRYPTO_TYPE = Util.intToStringMaxRadix(29); + private static final String FIELD_TILE_COUNT_HORIZONTAL = Util.intToStringMaxRadix(30); + private static final String FIELD_TILE_COUNT_VERTICAL = Util.intToStringMaxRadix(31); @UnstableApi @Override @@ -1557,51 +1577,54 @@ public final class Format implements Bundleable { @UnstableApi public Bundle toBundle(boolean excludeMetadata) { Bundle bundle = new Bundle(); - bundle.putString(keyForField(FIELD_ID), id); - bundle.putString(keyForField(FIELD_LABEL), label); - bundle.putString(keyForField(FIELD_LANGUAGE), language); - bundle.putInt(keyForField(FIELD_SELECTION_FLAGS), selectionFlags); - bundle.putInt(keyForField(FIELD_ROLE_FLAGS), roleFlags); - bundle.putInt(keyForField(FIELD_AVERAGE_BITRATE), averageBitrate); - bundle.putInt(keyForField(FIELD_PEAK_BITRATE), peakBitrate); - bundle.putString(keyForField(FIELD_CODECS), codecs); + bundle.putString(FIELD_ID, id); + bundle.putString(FIELD_LABEL, label); + bundle.putString(FIELD_LANGUAGE, language); + bundle.putInt(FIELD_SELECTION_FLAGS, selectionFlags); + bundle.putInt(FIELD_ROLE_FLAGS, roleFlags); + bundle.putInt(FIELD_AVERAGE_BITRATE, averageBitrate); + bundle.putInt(FIELD_PEAK_BITRATE, peakBitrate); + bundle.putString(FIELD_CODECS, codecs); if (!excludeMetadata) { // TODO (internal ref: b/239701618) - bundle.putParcelable(keyForField(FIELD_METADATA), metadata); + bundle.putParcelable(FIELD_METADATA, metadata); } // Container specific. - bundle.putString(keyForField(FIELD_CONTAINER_MIME_TYPE), containerMimeType); + bundle.putString(FIELD_CONTAINER_MIME_TYPE, containerMimeType); // Sample specific. - bundle.putString(keyForField(FIELD_SAMPLE_MIME_TYPE), sampleMimeType); - bundle.putInt(keyForField(FIELD_MAX_INPUT_SIZE), maxInputSize); + bundle.putString(FIELD_SAMPLE_MIME_TYPE, sampleMimeType); + bundle.putInt(FIELD_MAX_INPUT_SIZE, maxInputSize); for (int i = 0; i < initializationData.size(); i++) { bundle.putByteArray(keyForInitializationData(i), initializationData.get(i)); } // DrmInitData doesn't need to be Bundleable as it's only used in the playing process to // initialize the decoder. - bundle.putParcelable(keyForField(FIELD_DRM_INIT_DATA), drmInitData); - bundle.putLong(keyForField(FIELD_SUBSAMPLE_OFFSET_US), subsampleOffsetUs); + bundle.putParcelable(FIELD_DRM_INIT_DATA, drmInitData); + bundle.putLong(FIELD_SUBSAMPLE_OFFSET_US, subsampleOffsetUs); // Video specific. - bundle.putInt(keyForField(FIELD_WIDTH), width); - bundle.putInt(keyForField(FIELD_HEIGHT), height); - bundle.putFloat(keyForField(FIELD_FRAME_RATE), frameRate); - bundle.putInt(keyForField(FIELD_ROTATION_DEGREES), rotationDegrees); - bundle.putFloat(keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), pixelWidthHeightRatio); - bundle.putByteArray(keyForField(FIELD_PROJECTION_DATA), projectionData); - bundle.putInt(keyForField(FIELD_STEREO_MODE), stereoMode); + bundle.putInt(FIELD_WIDTH, width); + bundle.putInt(FIELD_HEIGHT, height); + bundle.putFloat(FIELD_FRAME_RATE, frameRate); + bundle.putInt(FIELD_ROTATION_DEGREES, rotationDegrees); + bundle.putFloat(FIELD_PIXEL_WIDTH_HEIGHT_RATIO, pixelWidthHeightRatio); + bundle.putByteArray(FIELD_PROJECTION_DATA, projectionData); + bundle.putInt(FIELD_STEREO_MODE, stereoMode); if (colorInfo != null) { - bundle.putBundle(keyForField(FIELD_COLOR_INFO), colorInfo.toBundle()); + bundle.putBundle(FIELD_COLOR_INFO, colorInfo.toBundle()); } // Audio specific. - bundle.putInt(keyForField(FIELD_CHANNEL_COUNT), channelCount); - bundle.putInt(keyForField(FIELD_SAMPLE_RATE), sampleRate); - bundle.putInt(keyForField(FIELD_PCM_ENCODING), pcmEncoding); - bundle.putInt(keyForField(FIELD_ENCODER_DELAY), encoderDelay); - bundle.putInt(keyForField(FIELD_ENCODER_PADDING), encoderPadding); + bundle.putInt(FIELD_CHANNEL_COUNT, channelCount); + bundle.putInt(FIELD_SAMPLE_RATE, sampleRate); + bundle.putInt(FIELD_PCM_ENCODING, pcmEncoding); + bundle.putInt(FIELD_ENCODER_DELAY, encoderDelay); + bundle.putInt(FIELD_ENCODER_PADDING, encoderPadding); // Text specific. - bundle.putInt(keyForField(FIELD_ACCESSIBILITY_CHANNEL), accessibilityChannel); + bundle.putInt(FIELD_ACCESSIBILITY_CHANNEL, accessibilityChannel); + // Image specific. + bundle.putInt(FIELD_TILE_COUNT_HORIZONTAL, tileCountHorizontal); + bundle.putInt(FIELD_TILE_COUNT_VERTICAL, tileCountVertical); // Source specific. - bundle.putInt(keyForField(FIELD_CRYPTO_TYPE), cryptoType); + bundle.putInt(FIELD_CRYPTO_TYPE, cryptoType); return bundle; } @@ -1612,28 +1635,22 @@ public final class Format implements Bundleable { Builder builder = new Builder(); BundleableUtil.ensureClassLoader(bundle); builder - .setId(defaultIfNull(bundle.getString(keyForField(FIELD_ID)), DEFAULT.id)) - .setLabel(defaultIfNull(bundle.getString(keyForField(FIELD_LABEL)), DEFAULT.label)) - .setLanguage(defaultIfNull(bundle.getString(keyForField(FIELD_LANGUAGE)), DEFAULT.language)) - .setSelectionFlags( - bundle.getInt(keyForField(FIELD_SELECTION_FLAGS), DEFAULT.selectionFlags)) - .setRoleFlags(bundle.getInt(keyForField(FIELD_ROLE_FLAGS), DEFAULT.roleFlags)) - .setAverageBitrate( - bundle.getInt(keyForField(FIELD_AVERAGE_BITRATE), DEFAULT.averageBitrate)) - .setPeakBitrate(bundle.getInt(keyForField(FIELD_PEAK_BITRATE), DEFAULT.peakBitrate)) - .setCodecs(defaultIfNull(bundle.getString(keyForField(FIELD_CODECS)), DEFAULT.codecs)) - .setMetadata( - defaultIfNull(bundle.getParcelable(keyForField(FIELD_METADATA)), DEFAULT.metadata)) + .setId(defaultIfNull(bundle.getString(FIELD_ID), DEFAULT.id)) + .setLabel(defaultIfNull(bundle.getString(FIELD_LABEL), DEFAULT.label)) + .setLanguage(defaultIfNull(bundle.getString(FIELD_LANGUAGE), DEFAULT.language)) + .setSelectionFlags(bundle.getInt(FIELD_SELECTION_FLAGS, DEFAULT.selectionFlags)) + .setRoleFlags(bundle.getInt(FIELD_ROLE_FLAGS, DEFAULT.roleFlags)) + .setAverageBitrate(bundle.getInt(FIELD_AVERAGE_BITRATE, DEFAULT.averageBitrate)) + .setPeakBitrate(bundle.getInt(FIELD_PEAK_BITRATE, DEFAULT.peakBitrate)) + .setCodecs(defaultIfNull(bundle.getString(FIELD_CODECS), DEFAULT.codecs)) + .setMetadata(defaultIfNull(bundle.getParcelable(FIELD_METADATA), DEFAULT.metadata)) // Container specific. .setContainerMimeType( - defaultIfNull( - bundle.getString(keyForField(FIELD_CONTAINER_MIME_TYPE)), - DEFAULT.containerMimeType)) + defaultIfNull(bundle.getString(FIELD_CONTAINER_MIME_TYPE), DEFAULT.containerMimeType)) // Sample specific. .setSampleMimeType( - defaultIfNull( - bundle.getString(keyForField(FIELD_SAMPLE_MIME_TYPE)), DEFAULT.sampleMimeType)) - .setMaxInputSize(bundle.getInt(keyForField(FIELD_MAX_INPUT_SIZE), DEFAULT.maxInputSize)); + defaultIfNull(bundle.getString(FIELD_SAMPLE_MIME_TYPE), DEFAULT.sampleMimeType)) + .setMaxInputSize(bundle.getInt(FIELD_MAX_INPUT_SIZE, DEFAULT.maxInputSize)); List initializationData = new ArrayList<>(); for (int i = 0; ; i++) { @@ -1645,51 +1662,55 @@ public final class Format implements Bundleable { } builder .setInitializationData(initializationData) - .setDrmInitData(bundle.getParcelable(keyForField(FIELD_DRM_INIT_DATA))) - .setSubsampleOffsetUs( - bundle.getLong(keyForField(FIELD_SUBSAMPLE_OFFSET_US), DEFAULT.subsampleOffsetUs)) + .setDrmInitData(bundle.getParcelable(FIELD_DRM_INIT_DATA)) + .setSubsampleOffsetUs(bundle.getLong(FIELD_SUBSAMPLE_OFFSET_US, DEFAULT.subsampleOffsetUs)) // Video specific. - .setWidth(bundle.getInt(keyForField(FIELD_WIDTH), DEFAULT.width)) - .setHeight(bundle.getInt(keyForField(FIELD_HEIGHT), DEFAULT.height)) - .setFrameRate(bundle.getFloat(keyForField(FIELD_FRAME_RATE), DEFAULT.frameRate)) - .setRotationDegrees( - bundle.getInt(keyForField(FIELD_ROTATION_DEGREES), DEFAULT.rotationDegrees)) + .setWidth(bundle.getInt(FIELD_WIDTH, DEFAULT.width)) + .setHeight(bundle.getInt(FIELD_HEIGHT, DEFAULT.height)) + .setFrameRate(bundle.getFloat(FIELD_FRAME_RATE, DEFAULT.frameRate)) + .setRotationDegrees(bundle.getInt(FIELD_ROTATION_DEGREES, DEFAULT.rotationDegrees)) .setPixelWidthHeightRatio( - bundle.getFloat( - keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), DEFAULT.pixelWidthHeightRatio)) - .setProjectionData(bundle.getByteArray(keyForField(FIELD_PROJECTION_DATA))) - .setStereoMode(bundle.getInt(keyForField(FIELD_STEREO_MODE), DEFAULT.stereoMode)); - Bundle colorInfoBundle = bundle.getBundle(keyForField(FIELD_COLOR_INFO)); + bundle.getFloat(FIELD_PIXEL_WIDTH_HEIGHT_RATIO, DEFAULT.pixelWidthHeightRatio)) + .setProjectionData(bundle.getByteArray(FIELD_PROJECTION_DATA)) + .setStereoMode(bundle.getInt(FIELD_STEREO_MODE, DEFAULT.stereoMode)); + Bundle colorInfoBundle = bundle.getBundle(FIELD_COLOR_INFO); if (colorInfoBundle != null) { builder.setColorInfo(ColorInfo.CREATOR.fromBundle(colorInfoBundle)); } // Audio specific. builder - .setChannelCount(bundle.getInt(keyForField(FIELD_CHANNEL_COUNT), DEFAULT.channelCount)) - .setSampleRate(bundle.getInt(keyForField(FIELD_SAMPLE_RATE), DEFAULT.sampleRate)) - .setPcmEncoding(bundle.getInt(keyForField(FIELD_PCM_ENCODING), DEFAULT.pcmEncoding)) - .setEncoderDelay(bundle.getInt(keyForField(FIELD_ENCODER_DELAY), DEFAULT.encoderDelay)) - .setEncoderPadding( - bundle.getInt(keyForField(FIELD_ENCODER_PADDING), DEFAULT.encoderPadding)) + .setChannelCount(bundle.getInt(FIELD_CHANNEL_COUNT, DEFAULT.channelCount)) + .setSampleRate(bundle.getInt(FIELD_SAMPLE_RATE, DEFAULT.sampleRate)) + .setPcmEncoding(bundle.getInt(FIELD_PCM_ENCODING, DEFAULT.pcmEncoding)) + .setEncoderDelay(bundle.getInt(FIELD_ENCODER_DELAY, DEFAULT.encoderDelay)) + .setEncoderPadding(bundle.getInt(FIELD_ENCODER_PADDING, DEFAULT.encoderPadding)) // Text specific. .setAccessibilityChannel( - bundle.getInt(keyForField(FIELD_ACCESSIBILITY_CHANNEL), DEFAULT.accessibilityChannel)) + bundle.getInt(FIELD_ACCESSIBILITY_CHANNEL, DEFAULT.accessibilityChannel)) + // Image specific. + .setTileCountHorizontal( + bundle.getInt(FIELD_TILE_COUNT_HORIZONTAL, DEFAULT.tileCountHorizontal)) + .setTileCountVertical(bundle.getInt(FIELD_TILE_COUNT_VERTICAL, DEFAULT.tileCountVertical)) // Source specific. - .setCryptoType(bundle.getInt(keyForField(FIELD_CRYPTO_TYPE), DEFAULT.cryptoType)); + .setCryptoType(bundle.getInt(FIELD_CRYPTO_TYPE, DEFAULT.cryptoType)); return builder.build(); } - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - private static String keyForInitializationData(int initialisationDataIndex) { - return keyForField(FIELD_INITIALIZATION_DATA) + return FIELD_INITIALIZATION_DATA + "_" + Integer.toString(initialisationDataIndex, Character.MAX_RADIX); } + /** + * Utility method to get {@code defaultValue} if {@code value} is {@code null}. {@code + * defaultValue} can be {@code null}. + * + *

Note: Current implementations of getters in {@link Bundle}, for example {@link + * Bundle#getString(String, String)} does not allow the defaultValue to be {@code null}, hence the + * need for this method. + */ @Nullable private static T defaultIfNull(@Nullable T value, @Nullable T defaultValue) { return value != null ? value : defaultValue; diff --git a/libraries/common/src/main/java/androidx/media3/common/HeartRating.java b/libraries/common/src/main/java/androidx/media3/common/HeartRating.java index 08a6b405f3..22ca4bec1b 100644 --- a/libraries/common/src/main/java/androidx/media3/common/HeartRating.java +++ b/libraries/common/src/main/java/androidx/media3/common/HeartRating.java @@ -16,17 +16,12 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.Objects; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** * A rating expressed as "heart" or "no heart". It can be used to indicate whether the content is a @@ -81,22 +76,16 @@ public final class HeartRating extends Rating { private static final @RatingType int TYPE = RATING_TYPE_HEART; - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_RATING_TYPE, FIELD_RATED, FIELD_IS_HEART}) - private @interface FieldNumber {} - - private static final int FIELD_RATED = 1; - private static final int FIELD_IS_HEART = 2; + private static final String FIELD_RATED = Util.intToStringMaxRadix(1); + private static final String FIELD_IS_HEART = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_RATING_TYPE), TYPE); - bundle.putBoolean(keyForField(FIELD_RATED), rated); - bundle.putBoolean(keyForField(FIELD_IS_HEART), isHeart); + bundle.putInt(FIELD_RATING_TYPE, TYPE); + bundle.putBoolean(FIELD_RATED, rated); + bundle.putBoolean(FIELD_IS_HEART, isHeart); return bundle; } @@ -104,16 +93,10 @@ public final class HeartRating extends Rating { @UnstableApi public static final Creator CREATOR = HeartRating::fromBundle; private static HeartRating fromBundle(Bundle bundle) { - checkArgument( - bundle.getInt(keyForField(FIELD_RATING_TYPE), /* defaultValue= */ RATING_TYPE_UNSET) - == TYPE); - boolean isRated = bundle.getBoolean(keyForField(FIELD_RATED), /* defaultValue= */ false); + checkArgument(bundle.getInt(FIELD_RATING_TYPE, /* defaultValue= */ RATING_TYPE_UNSET) == TYPE); + boolean isRated = bundle.getBoolean(FIELD_RATED, /* defaultValue= */ false); return isRated - ? new HeartRating(bundle.getBoolean(keyForField(FIELD_IS_HEART), /* defaultValue= */ false)) + ? new HeartRating(bundle.getBoolean(FIELD_IS_HEART, /* defaultValue= */ false)) : new HeartRating(); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } 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 7770a8c276..4db28ca61f 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaItem.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaItem.java @@ -17,11 +17,9 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; -import static java.lang.annotation.ElementType.TYPE_USE; import android.net.Uri; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.media3.common.util.Assertions; @@ -31,10 +29,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.InlineMe; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -1122,7 +1116,7 @@ public final class MediaItem implements Bundleable { private float minPlaybackSpeed; private float maxPlaybackSpeed; - /** Constructs an instance. */ + /** Creates a new instance with default values. */ public Builder() { this.targetOffsetMs = C.TIME_UNSET; this.minOffsetMs = C.TIME_UNSET; @@ -1304,33 +1298,31 @@ public final class MediaItem implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TARGET_OFFSET_MS, - FIELD_MIN_OFFSET_MS, - FIELD_MAX_OFFSET_MS, - FIELD_MIN_PLAYBACK_SPEED, - FIELD_MAX_PLAYBACK_SPEED - }) - private @interface FieldNumber {} - - private static final int FIELD_TARGET_OFFSET_MS = 0; - private static final int FIELD_MIN_OFFSET_MS = 1; - private static final int FIELD_MAX_OFFSET_MS = 2; - private static final int FIELD_MIN_PLAYBACK_SPEED = 3; - private static final int FIELD_MAX_PLAYBACK_SPEED = 4; + private static final String FIELD_TARGET_OFFSET_MS = Util.intToStringMaxRadix(0); + private static final String FIELD_MIN_OFFSET_MS = Util.intToStringMaxRadix(1); + private static final String FIELD_MAX_OFFSET_MS = Util.intToStringMaxRadix(2); + private static final String FIELD_MIN_PLAYBACK_SPEED = Util.intToStringMaxRadix(3); + private static final String FIELD_MAX_PLAYBACK_SPEED = Util.intToStringMaxRadix(4); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putLong(keyForField(FIELD_TARGET_OFFSET_MS), targetOffsetMs); - bundle.putLong(keyForField(FIELD_MIN_OFFSET_MS), minOffsetMs); - bundle.putLong(keyForField(FIELD_MAX_OFFSET_MS), maxOffsetMs); - bundle.putFloat(keyForField(FIELD_MIN_PLAYBACK_SPEED), minPlaybackSpeed); - bundle.putFloat(keyForField(FIELD_MAX_PLAYBACK_SPEED), maxPlaybackSpeed); + if (targetOffsetMs != UNSET.targetOffsetMs) { + bundle.putLong(FIELD_TARGET_OFFSET_MS, targetOffsetMs); + } + if (minOffsetMs != UNSET.minOffsetMs) { + bundle.putLong(FIELD_MIN_OFFSET_MS, minOffsetMs); + } + if (maxOffsetMs != UNSET.maxOffsetMs) { + bundle.putLong(FIELD_MAX_OFFSET_MS, maxOffsetMs); + } + if (minPlaybackSpeed != UNSET.minPlaybackSpeed) { + bundle.putFloat(FIELD_MIN_PLAYBACK_SPEED, minPlaybackSpeed); + } + if (maxPlaybackSpeed != UNSET.maxPlaybackSpeed) { + bundle.putFloat(FIELD_MAX_PLAYBACK_SPEED, maxPlaybackSpeed); + } return bundle; } @@ -1339,18 +1331,13 @@ public final class MediaItem implements Bundleable { public static final Creator CREATOR = bundle -> new LiveConfiguration( - bundle.getLong( - keyForField(FIELD_TARGET_OFFSET_MS), /* defaultValue= */ C.TIME_UNSET), - bundle.getLong(keyForField(FIELD_MIN_OFFSET_MS), /* defaultValue= */ C.TIME_UNSET), - bundle.getLong(keyForField(FIELD_MAX_OFFSET_MS), /* defaultValue= */ C.TIME_UNSET), + bundle.getLong(FIELD_TARGET_OFFSET_MS, /* defaultValue= */ UNSET.targetOffsetMs), + bundle.getLong(FIELD_MIN_OFFSET_MS, /* defaultValue= */ UNSET.minOffsetMs), + bundle.getLong(FIELD_MAX_OFFSET_MS, /* defaultValue= */ UNSET.maxOffsetMs), bundle.getFloat( - keyForField(FIELD_MIN_PLAYBACK_SPEED), /* defaultValue= */ C.RATE_UNSET), + FIELD_MIN_PLAYBACK_SPEED, /* defaultValue= */ UNSET.minPlaybackSpeed), bundle.getFloat( - keyForField(FIELD_MAX_PLAYBACK_SPEED), /* defaultValue= */ C.RATE_UNSET)); - - private static String keyForField(@LiveConfiguration.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } + FIELD_MAX_PLAYBACK_SPEED, /* defaultValue= */ UNSET.maxPlaybackSpeed)); } /** Properties for a text track. */ @@ -1589,7 +1576,7 @@ public final class MediaItem implements Bundleable { private boolean relativeToDefaultPosition; private boolean startsAtKeyFrame; - /** Constructs an instance. */ + /** Creates a new instance with default values. */ public Builder() { endPositionMs = C.TIME_END_OF_SOURCE; } @@ -1742,33 +1729,31 @@ public final class MediaItem implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_START_POSITION_MS, - FIELD_END_POSITION_MS, - FIELD_RELATIVE_TO_LIVE_WINDOW, - FIELD_RELATIVE_TO_DEFAULT_POSITION, - FIELD_STARTS_AT_KEY_FRAME - }) - private @interface FieldNumber {} - - private static final int FIELD_START_POSITION_MS = 0; - private static final int FIELD_END_POSITION_MS = 1; - private static final int FIELD_RELATIVE_TO_LIVE_WINDOW = 2; - private static final int FIELD_RELATIVE_TO_DEFAULT_POSITION = 3; - private static final int FIELD_STARTS_AT_KEY_FRAME = 4; + private static final String FIELD_START_POSITION_MS = Util.intToStringMaxRadix(0); + private static final String FIELD_END_POSITION_MS = Util.intToStringMaxRadix(1); + private static final String FIELD_RELATIVE_TO_LIVE_WINDOW = Util.intToStringMaxRadix(2); + private static final String FIELD_RELATIVE_TO_DEFAULT_POSITION = Util.intToStringMaxRadix(3); + private static final String FIELD_STARTS_AT_KEY_FRAME = Util.intToStringMaxRadix(4); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putLong(keyForField(FIELD_START_POSITION_MS), startPositionMs); - bundle.putLong(keyForField(FIELD_END_POSITION_MS), endPositionMs); - bundle.putBoolean(keyForField(FIELD_RELATIVE_TO_LIVE_WINDOW), relativeToLiveWindow); - bundle.putBoolean(keyForField(FIELD_RELATIVE_TO_DEFAULT_POSITION), relativeToDefaultPosition); - bundle.putBoolean(keyForField(FIELD_STARTS_AT_KEY_FRAME), startsAtKeyFrame); + if (startPositionMs != UNSET.startPositionMs) { + bundle.putLong(FIELD_START_POSITION_MS, startPositionMs); + } + if (endPositionMs != UNSET.endPositionMs) { + bundle.putLong(FIELD_END_POSITION_MS, endPositionMs); + } + if (relativeToLiveWindow != UNSET.relativeToLiveWindow) { + bundle.putBoolean(FIELD_RELATIVE_TO_LIVE_WINDOW, relativeToLiveWindow); + } + if (relativeToDefaultPosition != UNSET.relativeToDefaultPosition) { + bundle.putBoolean(FIELD_RELATIVE_TO_DEFAULT_POSITION, relativeToDefaultPosition); + } + if (startsAtKeyFrame != UNSET.startsAtKeyFrame) { + bundle.putBoolean(FIELD_STARTS_AT_KEY_FRAME, startsAtKeyFrame); + } return bundle; } @@ -1778,22 +1763,22 @@ public final class MediaItem implements Bundleable { bundle -> new ClippingConfiguration.Builder() .setStartPositionMs( - bundle.getLong(keyForField(FIELD_START_POSITION_MS), /* defaultValue= */ 0)) - .setEndPositionMs( bundle.getLong( - keyForField(FIELD_END_POSITION_MS), - /* defaultValue= */ C.TIME_END_OF_SOURCE)) + FIELD_START_POSITION_MS, /* defaultValue= */ UNSET.startPositionMs)) + .setEndPositionMs( + bundle.getLong(FIELD_END_POSITION_MS, /* defaultValue= */ UNSET.endPositionMs)) .setRelativeToLiveWindow( - bundle.getBoolean(keyForField(FIELD_RELATIVE_TO_LIVE_WINDOW), false)) + bundle.getBoolean( + FIELD_RELATIVE_TO_LIVE_WINDOW, + /* defaultValue= */ UNSET.relativeToLiveWindow)) .setRelativeToDefaultPosition( - bundle.getBoolean(keyForField(FIELD_RELATIVE_TO_DEFAULT_POSITION), false)) + bundle.getBoolean( + FIELD_RELATIVE_TO_DEFAULT_POSITION, + /* defaultValue= */ UNSET.relativeToDefaultPosition)) .setStartsAtKeyFrame( - bundle.getBoolean(keyForField(FIELD_STARTS_AT_KEY_FRAME), false)) + bundle.getBoolean( + FIELD_STARTS_AT_KEY_FRAME, /* defaultValue= */ UNSET.startsAtKeyFrame)) .buildClippingProperties(); - - private static String keyForField(@ClippingConfiguration.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** @@ -1912,28 +1897,22 @@ public final class MediaItem implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_MEDIA_URI, FIELD_SEARCH_QUERY, FIELD_EXTRAS}) - private @interface FieldNumber {} - - private static final int FIELD_MEDIA_URI = 0; - private static final int FIELD_SEARCH_QUERY = 1; - private static final int FIELD_EXTRAS = 2; + private static final String FIELD_MEDIA_URI = Util.intToStringMaxRadix(0); + private static final String FIELD_SEARCH_QUERY = Util.intToStringMaxRadix(1); + private static final String FIELD_EXTRAS = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); if (mediaUri != null) { - bundle.putParcelable(keyForField(FIELD_MEDIA_URI), mediaUri); + bundle.putParcelable(FIELD_MEDIA_URI, mediaUri); } if (searchQuery != null) { - bundle.putString(keyForField(FIELD_SEARCH_QUERY), searchQuery); + bundle.putString(FIELD_SEARCH_QUERY, searchQuery); } if (extras != null) { - bundle.putBundle(keyForField(FIELD_EXTRAS), extras); + bundle.putBundle(FIELD_EXTRAS, extras); } return bundle; } @@ -1943,14 +1922,10 @@ public final class MediaItem implements Bundleable { public static final Creator CREATOR = bundle -> new RequestMetadata.Builder() - .setMediaUri(bundle.getParcelable(keyForField(FIELD_MEDIA_URI))) - .setSearchQuery(bundle.getString(keyForField(FIELD_SEARCH_QUERY))) - .setExtras(bundle.getBundle(keyForField(FIELD_EXTRAS))) + .setMediaUri(bundle.getParcelable(FIELD_MEDIA_URI)) + .setSearchQuery(bundle.getString(FIELD_SEARCH_QUERY)) + .setExtras(bundle.getBundle(FIELD_EXTRAS)) .build(); - - private static String keyForField(@RequestMetadata.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** @@ -2046,24 +2021,11 @@ public final class MediaItem implements Bundleable { } // Bundleable implementation. - - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_MEDIA_ID, - FIELD_LIVE_CONFIGURATION, - FIELD_MEDIA_METADATA, - FIELD_CLIPPING_PROPERTIES, - FIELD_REQUEST_METADATA - }) - private @interface FieldNumber {} - - private static final int FIELD_MEDIA_ID = 0; - private static final int FIELD_LIVE_CONFIGURATION = 1; - private static final int FIELD_MEDIA_METADATA = 2; - private static final int FIELD_CLIPPING_PROPERTIES = 3; - private static final int FIELD_REQUEST_METADATA = 4; + private static final String FIELD_MEDIA_ID = Util.intToStringMaxRadix(0); + private static final String FIELD_LIVE_CONFIGURATION = Util.intToStringMaxRadix(1); + private static final String FIELD_MEDIA_METADATA = Util.intToStringMaxRadix(2); + private static final String FIELD_CLIPPING_PROPERTIES = Util.intToStringMaxRadix(3); + private static final String FIELD_REQUEST_METADATA = Util.intToStringMaxRadix(4); /** * {@inheritDoc} @@ -2075,11 +2037,21 @@ public final class MediaItem implements Bundleable { @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putString(keyForField(FIELD_MEDIA_ID), mediaId); - bundle.putBundle(keyForField(FIELD_LIVE_CONFIGURATION), liveConfiguration.toBundle()); - bundle.putBundle(keyForField(FIELD_MEDIA_METADATA), mediaMetadata.toBundle()); - bundle.putBundle(keyForField(FIELD_CLIPPING_PROPERTIES), clippingConfiguration.toBundle()); - bundle.putBundle(keyForField(FIELD_REQUEST_METADATA), requestMetadata.toBundle()); + if (!mediaId.equals(DEFAULT_MEDIA_ID)) { + bundle.putString(FIELD_MEDIA_ID, mediaId); + } + if (!liveConfiguration.equals(LiveConfiguration.UNSET)) { + bundle.putBundle(FIELD_LIVE_CONFIGURATION, liveConfiguration.toBundle()); + } + if (!mediaMetadata.equals(MediaMetadata.EMPTY)) { + bundle.putBundle(FIELD_MEDIA_METADATA, mediaMetadata.toBundle()); + } + if (!clippingConfiguration.equals(ClippingConfiguration.UNSET)) { + bundle.putBundle(FIELD_CLIPPING_PROPERTIES, clippingConfiguration.toBundle()); + } + if (!requestMetadata.equals(RequestMetadata.EMPTY)) { + bundle.putBundle(FIELD_REQUEST_METADATA, requestMetadata.toBundle()); + } return bundle; } @@ -2092,31 +2064,29 @@ public final class MediaItem implements Bundleable { @SuppressWarnings("deprecation") // Unbundling to ClippingProperties while it still exists. private static MediaItem fromBundle(Bundle bundle) { - String mediaId = checkNotNull(bundle.getString(keyForField(FIELD_MEDIA_ID), DEFAULT_MEDIA_ID)); - @Nullable - Bundle liveConfigurationBundle = bundle.getBundle(keyForField(FIELD_LIVE_CONFIGURATION)); + String mediaId = checkNotNull(bundle.getString(FIELD_MEDIA_ID, DEFAULT_MEDIA_ID)); + @Nullable Bundle liveConfigurationBundle = bundle.getBundle(FIELD_LIVE_CONFIGURATION); LiveConfiguration liveConfiguration; if (liveConfigurationBundle == null) { liveConfiguration = LiveConfiguration.UNSET; } else { liveConfiguration = LiveConfiguration.CREATOR.fromBundle(liveConfigurationBundle); } - @Nullable Bundle mediaMetadataBundle = bundle.getBundle(keyForField(FIELD_MEDIA_METADATA)); + @Nullable Bundle mediaMetadataBundle = bundle.getBundle(FIELD_MEDIA_METADATA); MediaMetadata mediaMetadata; if (mediaMetadataBundle == null) { mediaMetadata = MediaMetadata.EMPTY; } else { mediaMetadata = MediaMetadata.CREATOR.fromBundle(mediaMetadataBundle); } - @Nullable - Bundle clippingConfigurationBundle = bundle.getBundle(keyForField(FIELD_CLIPPING_PROPERTIES)); + @Nullable Bundle clippingConfigurationBundle = bundle.getBundle(FIELD_CLIPPING_PROPERTIES); ClippingProperties clippingConfiguration; if (clippingConfigurationBundle == null) { clippingConfiguration = ClippingProperties.UNSET; } else { clippingConfiguration = ClippingConfiguration.CREATOR.fromBundle(clippingConfigurationBundle); } - @Nullable Bundle requestMetadataBundle = bundle.getBundle(keyForField(FIELD_REQUEST_METADATA)); + @Nullable Bundle requestMetadataBundle = bundle.getBundle(FIELD_REQUEST_METADATA); RequestMetadata requestMetadata; if (requestMetadataBundle == null) { requestMetadata = RequestMetadata.EMPTY; @@ -2131,8 +2101,4 @@ public final class MediaItem implements Bundleable { mediaMetadata, requestMetadata); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java b/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java index 603392d369..ad013f4cd3 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java @@ -29,11 +29,11 @@ public final class MediaLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3" or "1.2.3-beta01". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "1.0.0-beta03"; + public static final String VERSION = "1.0.0-rc01"; /** The version of the library expressed as {@code TAG + "/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-beta03"; + public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-rc01"; /** * The version of the library expressed as an integer, for example 1002003300. @@ -47,7 +47,7 @@ public final class MediaLibraryInfo { * (123-045-006-3-00). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 1_000_000_1_03; + public static final int VERSION_INT = 1_000_000_2_01; /** Whether the library was compiled with {@link Assertions} checks enabled. */ public static final boolean ASSERTIONS_ENABLED = true; diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java b/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java index 05d37b29de..822932377f 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaMetadata.java @@ -61,6 +61,7 @@ public final class MediaMetadata implements Bundleable { @Nullable private Integer trackNumber; @Nullable private Integer totalTrackCount; @Nullable private @FolderType Integer folderType; + @Nullable private Boolean isBrowsable; @Nullable private Boolean isPlayable; @Nullable private Integer recordingYear; @Nullable private Integer recordingMonth; @@ -76,6 +77,7 @@ public final class MediaMetadata implements Bundleable { @Nullable private CharSequence genre; @Nullable private CharSequence compilation; @Nullable private CharSequence station; + @Nullable private @MediaType Integer mediaType; @Nullable private Bundle extras; public Builder() {} @@ -96,6 +98,7 @@ public final class MediaMetadata implements Bundleable { this.trackNumber = mediaMetadata.trackNumber; this.totalTrackCount = mediaMetadata.totalTrackCount; this.folderType = mediaMetadata.folderType; + this.isBrowsable = mediaMetadata.isBrowsable; this.isPlayable = mediaMetadata.isPlayable; this.recordingYear = mediaMetadata.recordingYear; this.recordingMonth = mediaMetadata.recordingMonth; @@ -111,6 +114,7 @@ public final class MediaMetadata implements Bundleable { this.genre = mediaMetadata.genre; this.compilation = mediaMetadata.compilation; this.station = mediaMetadata.station; + this.mediaType = mediaMetadata.mediaType; this.extras = mediaMetadata.extras; } @@ -244,13 +248,26 @@ public final class MediaMetadata implements Bundleable { return this; } - /** Sets the {@link FolderType}. */ + /** + * Sets the {@link FolderType}. + * + *

This method will be deprecated. Use {@link #setIsBrowsable} to indicate if an item is a + * browsable folder and use {@link #setMediaType} to indicate the type of the folder. + */ @CanIgnoreReturnValue public Builder setFolderType(@Nullable @FolderType Integer folderType) { this.folderType = folderType; return this; } + /** Sets whether the media is a browsable folder. */ + @UnstableApi + @CanIgnoreReturnValue + public Builder setIsBrowsable(@Nullable Boolean isBrowsable) { + this.isBrowsable = isBrowsable; + return this; + } + /** Sets whether the media is playable. */ @CanIgnoreReturnValue public Builder setIsPlayable(@Nullable Boolean isPlayable) { @@ -383,6 +400,14 @@ public final class MediaMetadata implements Bundleable { return this; } + /** Sets the {@link MediaType}. */ + @CanIgnoreReturnValue + @UnstableApi + public Builder setMediaType(@Nullable @MediaType Integer mediaType) { + this.mediaType = mediaType; + return this; + } + /** Sets the extras {@link Bundle}. */ @CanIgnoreReturnValue public Builder setExtras(@Nullable Bundle extras) { @@ -481,6 +506,9 @@ public final class MediaMetadata implements Bundleable { if (mediaMetadata.folderType != null) { setFolderType(mediaMetadata.folderType); } + if (mediaMetadata.isBrowsable != null) { + setIsBrowsable(mediaMetadata.isBrowsable); + } if (mediaMetadata.isPlayable != null) { setIsPlayable(mediaMetadata.isPlayable); } @@ -529,6 +557,9 @@ public final class MediaMetadata implements Bundleable { if (mediaMetadata.station != null) { setStation(mediaMetadata.station); } + if (mediaMetadata.mediaType != null) { + setMediaType(mediaMetadata.mediaType); + } if (mediaMetadata.extras != null) { setExtras(mediaMetadata.extras); } @@ -542,12 +573,186 @@ public final class MediaMetadata implements Bundleable { } } + /** + * The type of content described by the media item. + * + *

One of {@link #MEDIA_TYPE_MIXED}, {@link #MEDIA_TYPE_MUSIC}, {@link + * #MEDIA_TYPE_AUDIO_BOOK_CHAPTER}, {@link #MEDIA_TYPE_PODCAST_EPISODE}, {@link + * #MEDIA_TYPE_RADIO_STATION}, {@link #MEDIA_TYPE_NEWS}, {@link #MEDIA_TYPE_VIDEO}, {@link + * #MEDIA_TYPE_TRAILER}, {@link #MEDIA_TYPE_MOVIE}, {@link #MEDIA_TYPE_TV_SHOW}, {@link + * #MEDIA_TYPE_ALBUM}, {@link #MEDIA_TYPE_ARTIST}, {@link #MEDIA_TYPE_GENRE}, {@link + * #MEDIA_TYPE_PLAYLIST}, {@link #MEDIA_TYPE_YEAR}, {@link #MEDIA_TYPE_AUDIO_BOOK}, {@link + * #MEDIA_TYPE_PODCAST}, {@link #MEDIA_TYPE_TV_CHANNEL}, {@link #MEDIA_TYPE_TV_SERIES}, {@link + * #MEDIA_TYPE_TV_SEASON}, {@link #MEDIA_TYPE_FOLDER_MIXED}, {@link #MEDIA_TYPE_FOLDER_ALBUMS}, + * {@link #MEDIA_TYPE_FOLDER_ARTISTS}, {@link #MEDIA_TYPE_FOLDER_GENRES}, {@link + * #MEDIA_TYPE_FOLDER_PLAYLISTS}, {@link #MEDIA_TYPE_FOLDER_YEARS}, {@link + * #MEDIA_TYPE_FOLDER_AUDIO_BOOKS}, {@link #MEDIA_TYPE_FOLDER_PODCASTS}, {@link + * #MEDIA_TYPE_FOLDER_TV_CHANNELS}, {@link #MEDIA_TYPE_FOLDER_TV_SERIES}, {@link + * #MEDIA_TYPE_FOLDER_TV_SHOWS}, {@link #MEDIA_TYPE_FOLDER_RADIO_STATIONS}, {@link + * #MEDIA_TYPE_FOLDER_NEWS}, {@link #MEDIA_TYPE_FOLDER_VIDEOS}, {@link + * #MEDIA_TYPE_FOLDER_TRAILERS} or {@link #MEDIA_TYPE_FOLDER_MOVIES}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @UnstableApi + @IntDef({ + MEDIA_TYPE_MIXED, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_AUDIO_BOOK_CHAPTER, + MEDIA_TYPE_PODCAST_EPISODE, + MEDIA_TYPE_RADIO_STATION, + MEDIA_TYPE_NEWS, + MEDIA_TYPE_VIDEO, + MEDIA_TYPE_TRAILER, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_TV_SHOW, + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_GENRE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_YEAR, + MEDIA_TYPE_AUDIO_BOOK, + MEDIA_TYPE_PODCAST, + MEDIA_TYPE_TV_CHANNEL, + MEDIA_TYPE_TV_SERIES, + MEDIA_TYPE_TV_SEASON, + MEDIA_TYPE_FOLDER_MIXED, + MEDIA_TYPE_FOLDER_ALBUMS, + MEDIA_TYPE_FOLDER_ARTISTS, + MEDIA_TYPE_FOLDER_GENRES, + MEDIA_TYPE_FOLDER_PLAYLISTS, + MEDIA_TYPE_FOLDER_YEARS, + MEDIA_TYPE_FOLDER_AUDIO_BOOKS, + MEDIA_TYPE_FOLDER_PODCASTS, + MEDIA_TYPE_FOLDER_TV_CHANNELS, + MEDIA_TYPE_FOLDER_TV_SERIES, + MEDIA_TYPE_FOLDER_TV_SHOWS, + MEDIA_TYPE_FOLDER_RADIO_STATIONS, + MEDIA_TYPE_FOLDER_NEWS, + MEDIA_TYPE_FOLDER_VIDEOS, + MEDIA_TYPE_FOLDER_TRAILERS, + MEDIA_TYPE_FOLDER_MOVIES, + }) + public @interface MediaType {} + + /** Media of undetermined type or a mix of multiple {@linkplain MediaType media types}. */ + @UnstableApi public static final int MEDIA_TYPE_MIXED = 0; + /** {@link MediaType} for music. */ + @UnstableApi public static final int MEDIA_TYPE_MUSIC = 1; + /** {@link MediaType} for an audio book chapter. */ + @UnstableApi public static final int MEDIA_TYPE_AUDIO_BOOK_CHAPTER = 2; + /** {@link MediaType} for a podcast episode. */ + @UnstableApi public static final int MEDIA_TYPE_PODCAST_EPISODE = 3; + /** {@link MediaType} for a radio station. */ + @UnstableApi public static final int MEDIA_TYPE_RADIO_STATION = 4; + /** {@link MediaType} for news. */ + @UnstableApi public static final int MEDIA_TYPE_NEWS = 5; + /** {@link MediaType} for a video. */ + @UnstableApi public static final int MEDIA_TYPE_VIDEO = 6; + /** {@link MediaType} for a movie trailer. */ + @UnstableApi public static final int MEDIA_TYPE_TRAILER = 7; + /** {@link MediaType} for a movie. */ + @UnstableApi public static final int MEDIA_TYPE_MOVIE = 8; + /** {@link MediaType} for a TV show. */ + @UnstableApi public static final int MEDIA_TYPE_TV_SHOW = 9; + /** + * {@link MediaType} for a group of items (e.g., {@link #MEDIA_TYPE_MUSIC music}) belonging to an + * album. + */ + @UnstableApi public static final int MEDIA_TYPE_ALBUM = 10; + /** + * {@link MediaType} for a group of items (e.g., {@link #MEDIA_TYPE_MUSIC music}) from the same + * artist. + */ + @UnstableApi public static final int MEDIA_TYPE_ARTIST = 11; + /** + * {@link MediaType} for a group of items (e.g., {@link #MEDIA_TYPE_MUSIC music}) of the same + * genre. + */ + @UnstableApi public static final int MEDIA_TYPE_GENRE = 12; + /** + * {@link MediaType} for a group of items (e.g., {@link #MEDIA_TYPE_MUSIC music}) forming a + * playlist. + */ + @UnstableApi public static final int MEDIA_TYPE_PLAYLIST = 13; + /** + * {@link MediaType} for a group of items (e.g., {@link #MEDIA_TYPE_MUSIC music}) from the same + * year. + */ + @UnstableApi public static final int MEDIA_TYPE_YEAR = 14; + /** + * {@link MediaType} for a group of items forming an audio book. Items in this group are typically + * of type {@link #MEDIA_TYPE_AUDIO_BOOK_CHAPTER}. + */ + @UnstableApi public static final int MEDIA_TYPE_AUDIO_BOOK = 15; + /** + * {@link MediaType} for a group of items belonging to a podcast. Items in this group are + * typically of type {@link #MEDIA_TYPE_PODCAST_EPISODE}. + */ + @UnstableApi public static final int MEDIA_TYPE_PODCAST = 16; + /** + * {@link MediaType} for a group of items that are part of a TV channel. Items in this group are + * typically of type {@link #MEDIA_TYPE_TV_SHOW}, {@link #MEDIA_TYPE_TV_SERIES} or {@link + * #MEDIA_TYPE_MOVIE}. + */ + @UnstableApi public static final int MEDIA_TYPE_TV_CHANNEL = 17; + /** + * {@link MediaType} for a group of items that are part of a TV series. Items in this group are + * typically of type {@link #MEDIA_TYPE_TV_SHOW} or {@link #MEDIA_TYPE_TV_SEASON}. + */ + @UnstableApi public static final int MEDIA_TYPE_TV_SERIES = 18; + /** + * {@link MediaType} for a group of items that are part of a TV series. Items in this group are + * typically of type {@link #MEDIA_TYPE_TV_SHOW}. + */ + @UnstableApi public static final int MEDIA_TYPE_TV_SEASON = 19; + /** {@link MediaType} for a folder with mixed or undetermined content. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_MIXED = 20; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_ALBUM albums}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_ALBUMS = 21; + /** {@link MediaType} for a folder containing {@linkplain #FIELD_ARTIST artists}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_ARTISTS = 22; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_GENRE genres}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_GENRES = 23; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_PLAYLIST playlists}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_PLAYLISTS = 24; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_YEAR years}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_YEARS = 25; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_AUDIO_BOOK audio books}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_AUDIO_BOOKS = 26; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_PODCAST podcasts}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_PODCASTS = 27; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_TV_CHANNEL TV channels}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_TV_CHANNELS = 28; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_TV_SERIES TV series}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_TV_SERIES = 29; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_TV_SHOW TV shows}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_TV_SHOWS = 30; + /** + * {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_RADIO_STATION radio + * stations}. + */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_RADIO_STATIONS = 31; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_NEWS news}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_NEWS = 32; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_VIDEO videos}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_VIDEOS = 33; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_TRAILER movie trailers}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_TRAILERS = 34; + /** {@link MediaType} for a folder containing {@linkplain #MEDIA_TYPE_MOVIE movies}. */ + @UnstableApi public static final int MEDIA_TYPE_FOLDER_MOVIES = 35; + /** * The folder type of the media item. * *

This can be used as the type of a browsable bluetooth folder (see section 6.10.2.2 of the Bluetooth * AVRCP 1.6.2). + * + *

One of {@link #FOLDER_TYPE_NONE}, {@link #FOLDER_TYPE_MIXED}, {@link #FOLDER_TYPE_TITLES}, + * {@link #FOLDER_TYPE_ALBUMS}, {@link #FOLDER_TYPE_ARTISTS}, {@link #FOLDER_TYPE_GENRES}, {@link + * #FOLDER_TYPE_PLAYLISTS} or {@link #FOLDER_TYPE_YEARS}. */ // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // with Kotlin usages from before TYPE_USE was added. @@ -588,6 +793,17 @@ public final class MediaMetadata implements Bundleable { * *

Values sourced from the ID3 v2.4 specification (See section 4.14 of * https://id3.org/id3v2.4.0-frames). + * + *

One of {@link #PICTURE_TYPE_OTHER}, {@link #PICTURE_TYPE_FILE_ICON}, {@link + * #PICTURE_TYPE_FILE_ICON_OTHER}, {@link #PICTURE_TYPE_FRONT_COVER}, {@link + * #PICTURE_TYPE_BACK_COVER}, {@link #PICTURE_TYPE_LEAFLET_PAGE}, {@link #PICTURE_TYPE_MEDIA}, + * {@link #PICTURE_TYPE_LEAD_ARTIST_PERFORMER}, {@link #PICTURE_TYPE_ARTIST_PERFORMER}, {@link + * #PICTURE_TYPE_CONDUCTOR}, {@link #PICTURE_TYPE_BAND_ORCHESTRA}, {@link #PICTURE_TYPE_COMPOSER}, + * {@link #PICTURE_TYPE_LYRICIST}, {@link #PICTURE_TYPE_RECORDING_LOCATION}, {@link + * #PICTURE_TYPE_DURING_RECORDING}, {@link #PICTURE_TYPE_DURING_PERFORMANCE}, {@link + * #PICTURE_TYPE_MOVIE_VIDEO_SCREEN_CAPTURE}, {@link #PICTURE_TYPE_A_BRIGHT_COLORED_FISH}, {@link + * #PICTURE_TYPE_ILLUSTRATION}, {@link #PICTURE_TYPE_BAND_ARTIST_LOGO} or {@link + * #PICTURE_TYPE_PUBLISHER_STUDIO_LOGO}. */ // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // with Kotlin usages from before TYPE_USE was added. @@ -676,9 +892,16 @@ public final class MediaMetadata implements Bundleable { @Nullable public final Integer trackNumber; /** Optional total number of tracks. */ @Nullable public final Integer totalTrackCount; - /** Optional {@link FolderType}. */ + /** + * Optional {@link FolderType}. + * + *

This field will be deprecated. Use {@link #isBrowsable} to indicate if an item is a + * browsable folder and use {@link #mediaType} to indicate the type of the folder. + */ @Nullable public final @FolderType Integer folderType; - /** Optional boolean for media playability. */ + /** Optional boolean to indicate that the media is a browsable folder. */ + @UnstableApi @Nullable public final Boolean isBrowsable; + /** Optional boolean to indicate that the media is playable. */ @Nullable public final Boolean isPlayable; /** * @deprecated Use {@link #recordingYear} instead. @@ -729,6 +952,8 @@ public final class MediaMetadata implements Bundleable { @Nullable public final CharSequence compilation; /** Optional name of the station streaming the media. */ @Nullable public final CharSequence station; + /** Optional {@link MediaType}. */ + @UnstableApi @Nullable public final @MediaType Integer mediaType; /** * Optional extras {@link Bundle}. @@ -739,6 +964,22 @@ public final class MediaMetadata implements Bundleable { @Nullable public final Bundle extras; private MediaMetadata(Builder builder) { + // Handle compatibility for deprecated fields. + @Nullable Boolean isBrowsable = builder.isBrowsable; + @Nullable Integer folderType = builder.folderType; + @Nullable Integer mediaType = builder.mediaType; + if (isBrowsable != null) { + if (!isBrowsable) { + folderType = FOLDER_TYPE_NONE; + } else if (folderType == null || folderType == FOLDER_TYPE_NONE) { + folderType = mediaType != null ? getFolderTypeFromMediaType(mediaType) : FOLDER_TYPE_MIXED; + } + } else if (folderType != null) { + isBrowsable = folderType != FOLDER_TYPE_NONE; + if (isBrowsable && mediaType == null) { + mediaType = getMediaTypeFromFolderType(folderType); + } + } this.title = builder.title; this.artist = builder.artist; this.albumTitle = builder.albumTitle; @@ -753,7 +994,8 @@ public final class MediaMetadata implements Bundleable { this.artworkUri = builder.artworkUri; this.trackNumber = builder.trackNumber; this.totalTrackCount = builder.totalTrackCount; - this.folderType = builder.folderType; + this.folderType = folderType; + this.isBrowsable = isBrowsable; this.isPlayable = builder.isPlayable; this.year = builder.recordingYear; this.recordingYear = builder.recordingYear; @@ -770,6 +1012,7 @@ public final class MediaMetadata implements Bundleable { this.genre = builder.genre; this.compilation = builder.compilation; this.station = builder.station; + this.mediaType = mediaType; this.extras = builder.extras; } @@ -802,6 +1045,7 @@ public final class MediaMetadata implements Bundleable { && Util.areEqual(trackNumber, that.trackNumber) && Util.areEqual(totalTrackCount, that.totalTrackCount) && Util.areEqual(folderType, that.folderType) + && Util.areEqual(isBrowsable, that.isBrowsable) && Util.areEqual(isPlayable, that.isPlayable) && Util.areEqual(recordingYear, that.recordingYear) && Util.areEqual(recordingMonth, that.recordingMonth) @@ -816,7 +1060,8 @@ public final class MediaMetadata implements Bundleable { && Util.areEqual(totalDiscCount, that.totalDiscCount) && Util.areEqual(genre, that.genre) && Util.areEqual(compilation, that.compilation) - && Util.areEqual(station, that.station); + && Util.areEqual(station, that.station) + && Util.areEqual(mediaType, that.mediaType); } @Override @@ -837,6 +1082,7 @@ public final class MediaMetadata implements Bundleable { trackNumber, totalTrackCount, folderType, + isBrowsable, isPlayable, recordingYear, recordingMonth, @@ -851,150 +1097,149 @@ public final class MediaMetadata implements Bundleable { totalDiscCount, genre, compilation, - station); + station, + mediaType); } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TITLE, - FIELD_ARTIST, - FIELD_ALBUM_TITLE, - FIELD_ALBUM_ARTIST, - FIELD_DISPLAY_TITLE, - FIELD_SUBTITLE, - FIELD_DESCRIPTION, - FIELD_MEDIA_URI, - FIELD_USER_RATING, - FIELD_OVERALL_RATING, - FIELD_ARTWORK_DATA, - FIELD_ARTWORK_DATA_TYPE, - FIELD_ARTWORK_URI, - FIELD_TRACK_NUMBER, - FIELD_TOTAL_TRACK_COUNT, - FIELD_FOLDER_TYPE, - FIELD_IS_PLAYABLE, - FIELD_RECORDING_YEAR, - FIELD_RECORDING_MONTH, - FIELD_RECORDING_DAY, - FIELD_RELEASE_YEAR, - FIELD_RELEASE_MONTH, - FIELD_RELEASE_DAY, - FIELD_WRITER, - FIELD_COMPOSER, - FIELD_CONDUCTOR, - FIELD_DISC_NUMBER, - FIELD_TOTAL_DISC_COUNT, - FIELD_GENRE, - FIELD_COMPILATION, - FIELD_STATION, - FIELD_EXTRAS - }) - private @interface FieldNumber {} - - private static final int FIELD_TITLE = 0; - private static final int FIELD_ARTIST = 1; - private static final int FIELD_ALBUM_TITLE = 2; - private static final int FIELD_ALBUM_ARTIST = 3; - private static final int FIELD_DISPLAY_TITLE = 4; - private static final int FIELD_SUBTITLE = 5; - private static final int FIELD_DESCRIPTION = 6; - private static final int FIELD_MEDIA_URI = 7; - private static final int FIELD_USER_RATING = 8; - private static final int FIELD_OVERALL_RATING = 9; - private static final int FIELD_ARTWORK_DATA = 10; - private static final int FIELD_ARTWORK_URI = 11; - private static final int FIELD_TRACK_NUMBER = 12; - private static final int FIELD_TOTAL_TRACK_COUNT = 13; - private static final int FIELD_FOLDER_TYPE = 14; - private static final int FIELD_IS_PLAYABLE = 15; - private static final int FIELD_RECORDING_YEAR = 16; - private static final int FIELD_RECORDING_MONTH = 17; - private static final int FIELD_RECORDING_DAY = 18; - private static final int FIELD_RELEASE_YEAR = 19; - private static final int FIELD_RELEASE_MONTH = 20; - private static final int FIELD_RELEASE_DAY = 21; - private static final int FIELD_WRITER = 22; - private static final int FIELD_COMPOSER = 23; - private static final int FIELD_CONDUCTOR = 24; - private static final int FIELD_DISC_NUMBER = 25; - private static final int FIELD_TOTAL_DISC_COUNT = 26; - private static final int FIELD_GENRE = 27; - private static final int FIELD_COMPILATION = 28; - private static final int FIELD_ARTWORK_DATA_TYPE = 29; - private static final int FIELD_STATION = 30; - private static final int FIELD_EXTRAS = 1000; + private static final String FIELD_TITLE = Util.intToStringMaxRadix(0); + private static final String FIELD_ARTIST = Util.intToStringMaxRadix(1); + private static final String FIELD_ALBUM_TITLE = Util.intToStringMaxRadix(2); + private static final String FIELD_ALBUM_ARTIST = Util.intToStringMaxRadix(3); + private static final String FIELD_DISPLAY_TITLE = Util.intToStringMaxRadix(4); + private static final String FIELD_SUBTITLE = Util.intToStringMaxRadix(5); + private static final String FIELD_DESCRIPTION = Util.intToStringMaxRadix(6); + // 7 is reserved to maintain backward compatibility for a previously defined field. + private static final String FIELD_USER_RATING = Util.intToStringMaxRadix(8); + private static final String FIELD_OVERALL_RATING = Util.intToStringMaxRadix(9); + private static final String FIELD_ARTWORK_DATA = Util.intToStringMaxRadix(10); + private static final String FIELD_ARTWORK_URI = Util.intToStringMaxRadix(11); + private static final String FIELD_TRACK_NUMBER = Util.intToStringMaxRadix(12); + private static final String FIELD_TOTAL_TRACK_COUNT = Util.intToStringMaxRadix(13); + private static final String FIELD_FOLDER_TYPE = Util.intToStringMaxRadix(14); + private static final String FIELD_IS_PLAYABLE = Util.intToStringMaxRadix(15); + private static final String FIELD_RECORDING_YEAR = Util.intToStringMaxRadix(16); + private static final String FIELD_RECORDING_MONTH = Util.intToStringMaxRadix(17); + private static final String FIELD_RECORDING_DAY = Util.intToStringMaxRadix(18); + private static final String FIELD_RELEASE_YEAR = Util.intToStringMaxRadix(19); + private static final String FIELD_RELEASE_MONTH = Util.intToStringMaxRadix(20); + private static final String FIELD_RELEASE_DAY = Util.intToStringMaxRadix(21); + private static final String FIELD_WRITER = Util.intToStringMaxRadix(22); + private static final String FIELD_COMPOSER = Util.intToStringMaxRadix(23); + private static final String FIELD_CONDUCTOR = Util.intToStringMaxRadix(24); + private static final String FIELD_DISC_NUMBER = Util.intToStringMaxRadix(25); + private static final String FIELD_TOTAL_DISC_COUNT = Util.intToStringMaxRadix(26); + private static final String FIELD_GENRE = Util.intToStringMaxRadix(27); + private static final String FIELD_COMPILATION = Util.intToStringMaxRadix(28); + private static final String FIELD_ARTWORK_DATA_TYPE = Util.intToStringMaxRadix(29); + private static final String FIELD_STATION = Util.intToStringMaxRadix(30); + private static final String FIELD_MEDIA_TYPE = Util.intToStringMaxRadix(31); + private static final String FIELD_IS_BROWSABLE = Util.intToStringMaxRadix(32); + private static final String FIELD_EXTRAS = Util.intToStringMaxRadix(1000); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putCharSequence(keyForField(FIELD_TITLE), title); - bundle.putCharSequence(keyForField(FIELD_ARTIST), artist); - bundle.putCharSequence(keyForField(FIELD_ALBUM_TITLE), albumTitle); - bundle.putCharSequence(keyForField(FIELD_ALBUM_ARTIST), albumArtist); - bundle.putCharSequence(keyForField(FIELD_DISPLAY_TITLE), displayTitle); - bundle.putCharSequence(keyForField(FIELD_SUBTITLE), subtitle); - bundle.putCharSequence(keyForField(FIELD_DESCRIPTION), description); - bundle.putByteArray(keyForField(FIELD_ARTWORK_DATA), artworkData); - bundle.putParcelable(keyForField(FIELD_ARTWORK_URI), artworkUri); - bundle.putCharSequence(keyForField(FIELD_WRITER), writer); - bundle.putCharSequence(keyForField(FIELD_COMPOSER), composer); - bundle.putCharSequence(keyForField(FIELD_CONDUCTOR), conductor); - bundle.putCharSequence(keyForField(FIELD_GENRE), genre); - bundle.putCharSequence(keyForField(FIELD_COMPILATION), compilation); - bundle.putCharSequence(keyForField(FIELD_STATION), station); - + if (title != null) { + bundle.putCharSequence(FIELD_TITLE, title); + } + if (artist != null) { + bundle.putCharSequence(FIELD_ARTIST, artist); + } + if (albumTitle != null) { + bundle.putCharSequence(FIELD_ALBUM_TITLE, albumTitle); + } + if (albumArtist != null) { + bundle.putCharSequence(FIELD_ALBUM_ARTIST, albumArtist); + } + if (displayTitle != null) { + bundle.putCharSequence(FIELD_DISPLAY_TITLE, displayTitle); + } + if (subtitle != null) { + bundle.putCharSequence(FIELD_SUBTITLE, subtitle); + } + if (description != null) { + bundle.putCharSequence(FIELD_DESCRIPTION, description); + } + if (artworkData != null) { + bundle.putByteArray(FIELD_ARTWORK_DATA, artworkData); + } + if (artworkUri != null) { + bundle.putParcelable(FIELD_ARTWORK_URI, artworkUri); + } + if (writer != null) { + bundle.putCharSequence(FIELD_WRITER, writer); + } + if (composer != null) { + bundle.putCharSequence(FIELD_COMPOSER, composer); + } + if (conductor != null) { + bundle.putCharSequence(FIELD_CONDUCTOR, conductor); + } + if (genre != null) { + bundle.putCharSequence(FIELD_GENRE, genre); + } + if (compilation != null) { + bundle.putCharSequence(FIELD_COMPILATION, compilation); + } + if (station != null) { + bundle.putCharSequence(FIELD_STATION, station); + } if (userRating != null) { - bundle.putBundle(keyForField(FIELD_USER_RATING), userRating.toBundle()); + bundle.putBundle(FIELD_USER_RATING, userRating.toBundle()); } if (overallRating != null) { - bundle.putBundle(keyForField(FIELD_OVERALL_RATING), overallRating.toBundle()); + bundle.putBundle(FIELD_OVERALL_RATING, overallRating.toBundle()); } if (trackNumber != null) { - bundle.putInt(keyForField(FIELD_TRACK_NUMBER), trackNumber); + bundle.putInt(FIELD_TRACK_NUMBER, trackNumber); } if (totalTrackCount != null) { - bundle.putInt(keyForField(FIELD_TOTAL_TRACK_COUNT), totalTrackCount); + bundle.putInt(FIELD_TOTAL_TRACK_COUNT, totalTrackCount); } if (folderType != null) { - bundle.putInt(keyForField(FIELD_FOLDER_TYPE), folderType); + bundle.putInt(FIELD_FOLDER_TYPE, folderType); + } + if (isBrowsable != null) { + bundle.putBoolean(FIELD_IS_BROWSABLE, isBrowsable); } if (isPlayable != null) { - bundle.putBoolean(keyForField(FIELD_IS_PLAYABLE), isPlayable); + bundle.putBoolean(FIELD_IS_PLAYABLE, isPlayable); } if (recordingYear != null) { - bundle.putInt(keyForField(FIELD_RECORDING_YEAR), recordingYear); + bundle.putInt(FIELD_RECORDING_YEAR, recordingYear); } if (recordingMonth != null) { - bundle.putInt(keyForField(FIELD_RECORDING_MONTH), recordingMonth); + bundle.putInt(FIELD_RECORDING_MONTH, recordingMonth); } if (recordingDay != null) { - bundle.putInt(keyForField(FIELD_RECORDING_DAY), recordingDay); + bundle.putInt(FIELD_RECORDING_DAY, recordingDay); } if (releaseYear != null) { - bundle.putInt(keyForField(FIELD_RELEASE_YEAR), releaseYear); + bundle.putInt(FIELD_RELEASE_YEAR, releaseYear); } if (releaseMonth != null) { - bundle.putInt(keyForField(FIELD_RELEASE_MONTH), releaseMonth); + bundle.putInt(FIELD_RELEASE_MONTH, releaseMonth); } if (releaseDay != null) { - bundle.putInt(keyForField(FIELD_RELEASE_DAY), releaseDay); + bundle.putInt(FIELD_RELEASE_DAY, releaseDay); } if (discNumber != null) { - bundle.putInt(keyForField(FIELD_DISC_NUMBER), discNumber); + bundle.putInt(FIELD_DISC_NUMBER, discNumber); } if (totalDiscCount != null) { - bundle.putInt(keyForField(FIELD_TOTAL_DISC_COUNT), totalDiscCount); + bundle.putInt(FIELD_TOTAL_DISC_COUNT, totalDiscCount); } if (artworkDataType != null) { - bundle.putInt(keyForField(FIELD_ARTWORK_DATA_TYPE), artworkDataType); + bundle.putInt(FIELD_ARTWORK_DATA_TYPE, artworkDataType); + } + if (mediaType != null) { + bundle.putInt(FIELD_MEDIA_TYPE, mediaType); } if (extras != null) { - bundle.putBundle(keyForField(FIELD_EXTRAS), extras); + bundle.putBundle(FIELD_EXTRAS, extras); } return bundle; } @@ -1005,80 +1250,152 @@ public final class MediaMetadata implements Bundleable { private static MediaMetadata fromBundle(Bundle bundle) { Builder builder = new Builder(); builder - .setTitle(bundle.getCharSequence(keyForField(FIELD_TITLE))) - .setArtist(bundle.getCharSequence(keyForField(FIELD_ARTIST))) - .setAlbumTitle(bundle.getCharSequence(keyForField(FIELD_ALBUM_TITLE))) - .setAlbumArtist(bundle.getCharSequence(keyForField(FIELD_ALBUM_ARTIST))) - .setDisplayTitle(bundle.getCharSequence(keyForField(FIELD_DISPLAY_TITLE))) - .setSubtitle(bundle.getCharSequence(keyForField(FIELD_SUBTITLE))) - .setDescription(bundle.getCharSequence(keyForField(FIELD_DESCRIPTION))) + .setTitle(bundle.getCharSequence(FIELD_TITLE)) + .setArtist(bundle.getCharSequence(FIELD_ARTIST)) + .setAlbumTitle(bundle.getCharSequence(FIELD_ALBUM_TITLE)) + .setAlbumArtist(bundle.getCharSequence(FIELD_ALBUM_ARTIST)) + .setDisplayTitle(bundle.getCharSequence(FIELD_DISPLAY_TITLE)) + .setSubtitle(bundle.getCharSequence(FIELD_SUBTITLE)) + .setDescription(bundle.getCharSequence(FIELD_DESCRIPTION)) .setArtworkData( - bundle.getByteArray(keyForField(FIELD_ARTWORK_DATA)), - bundle.containsKey(keyForField(FIELD_ARTWORK_DATA_TYPE)) - ? bundle.getInt(keyForField(FIELD_ARTWORK_DATA_TYPE)) + bundle.getByteArray(FIELD_ARTWORK_DATA), + bundle.containsKey(FIELD_ARTWORK_DATA_TYPE) + ? bundle.getInt(FIELD_ARTWORK_DATA_TYPE) : null) - .setArtworkUri(bundle.getParcelable(keyForField(FIELD_ARTWORK_URI))) - .setWriter(bundle.getCharSequence(keyForField(FIELD_WRITER))) - .setComposer(bundle.getCharSequence(keyForField(FIELD_COMPOSER))) - .setConductor(bundle.getCharSequence(keyForField(FIELD_CONDUCTOR))) - .setGenre(bundle.getCharSequence(keyForField(FIELD_GENRE))) - .setCompilation(bundle.getCharSequence(keyForField(FIELD_COMPILATION))) - .setStation(bundle.getCharSequence(keyForField(FIELD_STATION))) - .setExtras(bundle.getBundle(keyForField(FIELD_EXTRAS))); + .setArtworkUri(bundle.getParcelable(FIELD_ARTWORK_URI)) + .setWriter(bundle.getCharSequence(FIELD_WRITER)) + .setComposer(bundle.getCharSequence(FIELD_COMPOSER)) + .setConductor(bundle.getCharSequence(FIELD_CONDUCTOR)) + .setGenre(bundle.getCharSequence(FIELD_GENRE)) + .setCompilation(bundle.getCharSequence(FIELD_COMPILATION)) + .setStation(bundle.getCharSequence(FIELD_STATION)) + .setExtras(bundle.getBundle(FIELD_EXTRAS)); - if (bundle.containsKey(keyForField(FIELD_USER_RATING))) { - @Nullable Bundle fieldBundle = bundle.getBundle(keyForField(FIELD_USER_RATING)); + if (bundle.containsKey(FIELD_USER_RATING)) { + @Nullable Bundle fieldBundle = bundle.getBundle(FIELD_USER_RATING); if (fieldBundle != null) { builder.setUserRating(Rating.CREATOR.fromBundle(fieldBundle)); } } - if (bundle.containsKey(keyForField(FIELD_OVERALL_RATING))) { - @Nullable Bundle fieldBundle = bundle.getBundle(keyForField(FIELD_OVERALL_RATING)); + if (bundle.containsKey(FIELD_OVERALL_RATING)) { + @Nullable Bundle fieldBundle = bundle.getBundle(FIELD_OVERALL_RATING); if (fieldBundle != null) { builder.setOverallRating(Rating.CREATOR.fromBundle(fieldBundle)); } } - if (bundle.containsKey(keyForField(FIELD_TRACK_NUMBER))) { - builder.setTrackNumber(bundle.getInt(keyForField(FIELD_TRACK_NUMBER))); + if (bundle.containsKey(FIELD_TRACK_NUMBER)) { + builder.setTrackNumber(bundle.getInt(FIELD_TRACK_NUMBER)); } - if (bundle.containsKey(keyForField(FIELD_TOTAL_TRACK_COUNT))) { - builder.setTotalTrackCount(bundle.getInt(keyForField(FIELD_TOTAL_TRACK_COUNT))); + if (bundle.containsKey(FIELD_TOTAL_TRACK_COUNT)) { + builder.setTotalTrackCount(bundle.getInt(FIELD_TOTAL_TRACK_COUNT)); } - if (bundle.containsKey(keyForField(FIELD_FOLDER_TYPE))) { - builder.setFolderType(bundle.getInt(keyForField(FIELD_FOLDER_TYPE))); + if (bundle.containsKey(FIELD_FOLDER_TYPE)) { + builder.setFolderType(bundle.getInt(FIELD_FOLDER_TYPE)); } - if (bundle.containsKey(keyForField(FIELD_IS_PLAYABLE))) { - builder.setIsPlayable(bundle.getBoolean(keyForField(FIELD_IS_PLAYABLE))); + if (bundle.containsKey(FIELD_IS_BROWSABLE)) { + builder.setIsBrowsable(bundle.getBoolean(FIELD_IS_BROWSABLE)); } - if (bundle.containsKey(keyForField(FIELD_RECORDING_YEAR))) { - builder.setRecordingYear(bundle.getInt(keyForField(FIELD_RECORDING_YEAR))); + if (bundle.containsKey(FIELD_IS_PLAYABLE)) { + builder.setIsPlayable(bundle.getBoolean(FIELD_IS_PLAYABLE)); } - if (bundle.containsKey(keyForField(FIELD_RECORDING_MONTH))) { - builder.setRecordingMonth(bundle.getInt(keyForField(FIELD_RECORDING_MONTH))); + if (bundle.containsKey(FIELD_RECORDING_YEAR)) { + builder.setRecordingYear(bundle.getInt(FIELD_RECORDING_YEAR)); } - if (bundle.containsKey(keyForField(FIELD_RECORDING_DAY))) { - builder.setRecordingDay(bundle.getInt(keyForField(FIELD_RECORDING_DAY))); + if (bundle.containsKey(FIELD_RECORDING_MONTH)) { + builder.setRecordingMonth(bundle.getInt(FIELD_RECORDING_MONTH)); } - if (bundle.containsKey(keyForField(FIELD_RELEASE_YEAR))) { - builder.setReleaseYear(bundle.getInt(keyForField(FIELD_RELEASE_YEAR))); + if (bundle.containsKey(FIELD_RECORDING_DAY)) { + builder.setRecordingDay(bundle.getInt(FIELD_RECORDING_DAY)); } - if (bundle.containsKey(keyForField(FIELD_RELEASE_MONTH))) { - builder.setReleaseMonth(bundle.getInt(keyForField(FIELD_RELEASE_MONTH))); + if (bundle.containsKey(FIELD_RELEASE_YEAR)) { + builder.setReleaseYear(bundle.getInt(FIELD_RELEASE_YEAR)); } - if (bundle.containsKey(keyForField(FIELD_RELEASE_DAY))) { - builder.setReleaseDay(bundle.getInt(keyForField(FIELD_RELEASE_DAY))); + if (bundle.containsKey(FIELD_RELEASE_MONTH)) { + builder.setReleaseMonth(bundle.getInt(FIELD_RELEASE_MONTH)); } - if (bundle.containsKey(keyForField(FIELD_DISC_NUMBER))) { - builder.setDiscNumber(bundle.getInt(keyForField(FIELD_DISC_NUMBER))); + if (bundle.containsKey(FIELD_RELEASE_DAY)) { + builder.setReleaseDay(bundle.getInt(FIELD_RELEASE_DAY)); } - if (bundle.containsKey(keyForField(FIELD_TOTAL_DISC_COUNT))) { - builder.setTotalDiscCount(bundle.getInt(keyForField(FIELD_TOTAL_DISC_COUNT))); + if (bundle.containsKey(FIELD_DISC_NUMBER)) { + builder.setDiscNumber(bundle.getInt(FIELD_DISC_NUMBER)); + } + if (bundle.containsKey(FIELD_TOTAL_DISC_COUNT)) { + builder.setTotalDiscCount(bundle.getInt(FIELD_TOTAL_DISC_COUNT)); + } + if (bundle.containsKey(FIELD_MEDIA_TYPE)) { + builder.setMediaType(bundle.getInt(FIELD_MEDIA_TYPE)); } return builder.build(); } - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); + private static @FolderType int getFolderTypeFromMediaType(@MediaType int mediaType) { + switch (mediaType) { + case MEDIA_TYPE_ALBUM: + case MEDIA_TYPE_ARTIST: + case MEDIA_TYPE_AUDIO_BOOK: + case MEDIA_TYPE_AUDIO_BOOK_CHAPTER: + case MEDIA_TYPE_FOLDER_MOVIES: + case MEDIA_TYPE_FOLDER_NEWS: + case MEDIA_TYPE_FOLDER_RADIO_STATIONS: + case MEDIA_TYPE_FOLDER_TRAILERS: + case MEDIA_TYPE_FOLDER_VIDEOS: + case MEDIA_TYPE_GENRE: + case MEDIA_TYPE_MOVIE: + case MEDIA_TYPE_MUSIC: + case MEDIA_TYPE_NEWS: + case MEDIA_TYPE_PLAYLIST: + case MEDIA_TYPE_PODCAST: + case MEDIA_TYPE_PODCAST_EPISODE: + case MEDIA_TYPE_RADIO_STATION: + case MEDIA_TYPE_TRAILER: + case MEDIA_TYPE_TV_CHANNEL: + case MEDIA_TYPE_TV_SEASON: + case MEDIA_TYPE_TV_SERIES: + case MEDIA_TYPE_TV_SHOW: + case MEDIA_TYPE_VIDEO: + case MEDIA_TYPE_YEAR: + return FOLDER_TYPE_TITLES; + case MEDIA_TYPE_FOLDER_ALBUMS: + return FOLDER_TYPE_ALBUMS; + case MEDIA_TYPE_FOLDER_ARTISTS: + return FOLDER_TYPE_ARTISTS; + case MEDIA_TYPE_FOLDER_GENRES: + return FOLDER_TYPE_GENRES; + case MEDIA_TYPE_FOLDER_PLAYLISTS: + return FOLDER_TYPE_PLAYLISTS; + case MEDIA_TYPE_FOLDER_YEARS: + return FOLDER_TYPE_YEARS; + case MEDIA_TYPE_FOLDER_AUDIO_BOOKS: + case MEDIA_TYPE_FOLDER_MIXED: + case MEDIA_TYPE_FOLDER_TV_CHANNELS: + case MEDIA_TYPE_FOLDER_TV_SERIES: + case MEDIA_TYPE_FOLDER_TV_SHOWS: + case MEDIA_TYPE_FOLDER_PODCASTS: + case MEDIA_TYPE_MIXED: + default: + return FOLDER_TYPE_MIXED; + } + } + + private static @MediaType int getMediaTypeFromFolderType(@FolderType int folderType) { + switch (folderType) { + case FOLDER_TYPE_ALBUMS: + return MEDIA_TYPE_FOLDER_ALBUMS; + case FOLDER_TYPE_ARTISTS: + return MEDIA_TYPE_FOLDER_ARTISTS; + case FOLDER_TYPE_GENRES: + return MEDIA_TYPE_FOLDER_GENRES; + case FOLDER_TYPE_PLAYLISTS: + return MEDIA_TYPE_FOLDER_PLAYLISTS; + case FOLDER_TYPE_TITLES: + return MEDIA_TYPE_MIXED; + case FOLDER_TYPE_YEARS: + return MEDIA_TYPE_FOLDER_YEARS; + case FOLDER_TYPE_MIXED: + case FOLDER_TYPE_NONE: + default: + return MEDIA_TYPE_FOLDER_MIXED; + } } } diff --git a/libraries/common/src/main/java/androidx/media3/common/Metadata.java b/libraries/common/src/main/java/androidx/media3/common/Metadata.java index 6a55842551..201d9b1296 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Metadata.java +++ b/libraries/common/src/main/java/androidx/media3/common/Metadata.java @@ -50,11 +50,8 @@ public final class Metadata implements Parcelable { } /** - * Updates the {@link MediaMetadata.Builder} with the type specific values stored in this Entry. - * - *

The order of the {@link Entry} objects in the {@link Metadata} matters. If two {@link - * Entry} entries attempt to populate the same {@link MediaMetadata} field, then the last one in - * the list is used. + * Updates the {@link MediaMetadata.Builder} with the type-specific values stored in this {@code + * Entry}. * * @param builder The builder to be updated. */ diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java index 859773b2a6..1bcab6a3ee 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java @@ -587,6 +587,8 @@ public final class MimeTypes { return C.ENCODING_DTS_HD; case MimeTypes.AUDIO_TRUEHD: return C.ENCODING_DOLBY_TRUEHD; + case MimeTypes.AUDIO_OPUS: + return C.ENCODING_OPUS; default: return C.ENCODING_INVALID; } diff --git a/libraries/common/src/main/java/androidx/media3/common/PercentageRating.java b/libraries/common/src/main/java/androidx/media3/common/PercentageRating.java index afc20a1687..8504f3a6ce 100644 --- a/libraries/common/src/main/java/androidx/media3/common/PercentageRating.java +++ b/libraries/common/src/main/java/androidx/media3/common/PercentageRating.java @@ -16,18 +16,13 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; import androidx.annotation.FloatRange; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.Objects; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** A rating expressed as a percentage. */ public final class PercentageRating extends Rating { @@ -79,20 +74,14 @@ public final class PercentageRating extends Rating { private static final @RatingType int TYPE = RATING_TYPE_PERCENTAGE; - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_RATING_TYPE, FIELD_PERCENT}) - private @interface FieldNumber {} - - private static final int FIELD_PERCENT = 1; + private static final String FIELD_PERCENT = Util.intToStringMaxRadix(1); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_RATING_TYPE), TYPE); - bundle.putFloat(keyForField(FIELD_PERCENT), percent); + bundle.putInt(FIELD_RATING_TYPE, TYPE); + bundle.putFloat(FIELD_PERCENT, percent); return bundle; } @@ -100,14 +89,8 @@ public final class PercentageRating extends Rating { @UnstableApi public static final Creator CREATOR = PercentageRating::fromBundle; private static PercentageRating fromBundle(Bundle bundle) { - checkArgument( - bundle.getInt(keyForField(FIELD_RATING_TYPE), /* defaultValue= */ RATING_TYPE_UNSET) - == TYPE); - float percent = bundle.getFloat(keyForField(FIELD_PERCENT), /* defaultValue= */ RATING_UNSET); + checkArgument(bundle.getInt(FIELD_RATING_TYPE, /* defaultValue= */ RATING_TYPE_UNSET) == TYPE); + float percent = bundle.getFloat(FIELD_PERCENT, /* defaultValue= */ RATING_UNSET); return percent == RATING_UNSET ? new PercentageRating() : new PercentageRating(percent); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java b/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java index 2bcc674631..f9aa597856 100644 --- a/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java +++ b/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java @@ -346,13 +346,12 @@ public class PlaybackException extends Exception implements Bundleable { @UnstableApi protected PlaybackException(Bundle bundle) { this( - /* message= */ bundle.getString(keyForField(FIELD_STRING_MESSAGE)), + /* message= */ bundle.getString(FIELD_STRING_MESSAGE), /* cause= */ getCauseFromBundle(bundle), /* errorCode= */ bundle.getInt( - keyForField(FIELD_INT_ERROR_CODE), /* defaultValue= */ ERROR_CODE_UNSPECIFIED), + FIELD_INT_ERROR_CODE, /* defaultValue= */ ERROR_CODE_UNSPECIFIED), /* timestampMs= */ bundle.getLong( - keyForField(FIELD_LONG_TIMESTAMP_MS), - /* defaultValue= */ SystemClock.elapsedRealtime())); + FIELD_LONG_TIMESTAMP_MS, /* defaultValue= */ SystemClock.elapsedRealtime())); } /** Creates a new instance using the given values. */ @@ -401,18 +400,18 @@ public class PlaybackException extends Exception implements Bundleable { // Bundleable implementation. - private static final int FIELD_INT_ERROR_CODE = 0; - private static final int FIELD_LONG_TIMESTAMP_MS = 1; - private static final int FIELD_STRING_MESSAGE = 2; - private static final int FIELD_STRING_CAUSE_CLASS_NAME = 3; - private static final int FIELD_STRING_CAUSE_MESSAGE = 4; + private static final String FIELD_INT_ERROR_CODE = Util.intToStringMaxRadix(0); + private static final String FIELD_LONG_TIMESTAMP_MS = Util.intToStringMaxRadix(1); + private static final String FIELD_STRING_MESSAGE = Util.intToStringMaxRadix(2); + private static final String FIELD_STRING_CAUSE_CLASS_NAME = Util.intToStringMaxRadix(3); + private static final String FIELD_STRING_CAUSE_MESSAGE = Util.intToStringMaxRadix(4); /** * Defines a minimum field ID value for subclasses to use when implementing {@link #toBundle()} * and {@link Bundleable.Creator}. * *

Subclasses should obtain their {@link Bundle Bundle's} field keys by applying a non-negative - * offset on this constant and passing the result to {@link #keyForField(int)}. + * offset on this constant and passing the result to {@link Util#intToStringMaxRadix(int)}. */ @UnstableApi protected static final int FIELD_CUSTOM_ID_BASE = 1000; @@ -424,29 +423,17 @@ public class PlaybackException extends Exception implements Bundleable { @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_INT_ERROR_CODE), errorCode); - bundle.putLong(keyForField(FIELD_LONG_TIMESTAMP_MS), timestampMs); - bundle.putString(keyForField(FIELD_STRING_MESSAGE), getMessage()); + bundle.putInt(FIELD_INT_ERROR_CODE, errorCode); + bundle.putLong(FIELD_LONG_TIMESTAMP_MS, timestampMs); + bundle.putString(FIELD_STRING_MESSAGE, getMessage()); @Nullable Throwable cause = getCause(); if (cause != null) { - bundle.putString(keyForField(FIELD_STRING_CAUSE_CLASS_NAME), cause.getClass().getName()); - bundle.putString(keyForField(FIELD_STRING_CAUSE_MESSAGE), cause.getMessage()); + bundle.putString(FIELD_STRING_CAUSE_CLASS_NAME, cause.getClass().getName()); + bundle.putString(FIELD_STRING_CAUSE_MESSAGE, cause.getMessage()); } return bundle; } - /** - * Converts the given field number to a string which can be used as a field key when implementing - * {@link #toBundle()} and {@link Bundleable.Creator}. - * - *

Subclasses should use {@code field} values greater than or equal to {@link - * #FIELD_CUSTOM_ID_BASE}. - */ - @UnstableApi - protected static String keyForField(int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - // Creates a new {@link Throwable} with possibly {@code null} message. @SuppressWarnings("nullness:argument") private static Throwable createThrowable(Class clazz, @Nullable String message) @@ -462,8 +449,8 @@ public class PlaybackException extends Exception implements Bundleable { @Nullable private static Throwable getCauseFromBundle(Bundle bundle) { - @Nullable String causeClassName = bundle.getString(keyForField(FIELD_STRING_CAUSE_CLASS_NAME)); - @Nullable String causeMessage = bundle.getString(keyForField(FIELD_STRING_CAUSE_MESSAGE)); + @Nullable String causeClassName = bundle.getString(FIELD_STRING_CAUSE_CLASS_NAME); + @Nullable String causeMessage = bundle.getString(FIELD_STRING_CAUSE_MESSAGE); @Nullable Throwable cause = null; if (!TextUtils.isEmpty(causeClassName)) { try { diff --git a/libraries/common/src/main/java/androidx/media3/common/PlaybackParameters.java b/libraries/common/src/main/java/androidx/media3/common/PlaybackParameters.java index 84881a55ce..df63b7d111 100644 --- a/libraries/common/src/main/java/androidx/media3/common/PlaybackParameters.java +++ b/libraries/common/src/main/java/androidx/media3/common/PlaybackParameters.java @@ -15,20 +15,13 @@ */ package androidx.media3.common; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; import androidx.annotation.CheckResult; import androidx.annotation.FloatRange; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** Parameters that apply to playback, including speed setting. */ public final class PlaybackParameters implements Bundleable { @@ -122,21 +115,15 @@ public final class PlaybackParameters implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_SPEED, FIELD_PITCH}) - private @interface FieldNumber {} - - private static final int FIELD_SPEED = 0; - private static final int FIELD_PITCH = 1; + private static final String FIELD_SPEED = Util.intToStringMaxRadix(0); + private static final String FIELD_PITCH = Util.intToStringMaxRadix(1); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putFloat(keyForField(FIELD_SPEED), speed); - bundle.putFloat(keyForField(FIELD_PITCH), pitch); + bundle.putFloat(FIELD_SPEED, speed); + bundle.putFloat(FIELD_PITCH, pitch); return bundle; } @@ -144,12 +131,8 @@ public final class PlaybackParameters implements Bundleable { @UnstableApi public static final Creator CREATOR = bundle -> { - float speed = bundle.getFloat(keyForField(FIELD_SPEED), /* defaultValue= */ 1f); - float pitch = bundle.getFloat(keyForField(FIELD_PITCH), /* defaultValue= */ 1f); + float speed = bundle.getFloat(FIELD_SPEED, /* defaultValue= */ 1f); + float pitch = bundle.getFloat(FIELD_PITCH, /* defaultValue= */ 1f); return new PlaybackParameters(speed, pitch); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/Player.java b/libraries/common/src/main/java/androidx/media3/common/Player.java index 5e05ae6301..a33775d522 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -49,6 +49,10 @@ import java.util.List; * A media player interface defining traditional high-level functionality, such as the ability to * play, pause, seek and query properties of the currently playing media. * + *

All methods must be called from a single {@linkplain #getApplicationLooper() application + * thread} unless indicated otherwise. Callbacks in registered listeners are called on the same + * thread. + * *

This interface includes some convenience methods that can be implemented by calling other * methods in the interface. {@link BasePlayer} implements these convenience methods so inheriting * {@link BasePlayer} is recommended when implementing the interface so that only the minimal set of @@ -65,7 +69,7 @@ import java.util.List; */ public interface Player { - /** A set of {@link Event events}. */ + /** A set of {@linkplain Event events}. */ final class Events { private final FlagSet flags; @@ -73,7 +77,7 @@ public interface Player { /** * Creates an instance. * - * @param flags The {@link FlagSet} containing the {@link Event events}. + * @param flags The {@link FlagSet} containing the {@linkplain Event events}. */ @UnstableApi public Events(FlagSet flags) { @@ -91,10 +95,10 @@ public interface Player { } /** - * Returns whether any of the given {@link Event events} occurred. + * Returns whether any of the given {@linkplain Event events} occurred. * - * @param events The {@link Event events}. - * @return Whether any of the {@link Event events} occurred. + * @param events The {@linkplain Event events}. + * @return Whether any of the {@linkplain Event events} occurred. */ public boolean containsAny(@Event int... events) { return flags.containsAny(events); @@ -206,6 +210,7 @@ public interface Player { /** Creates an instance. */ @UnstableApi + @SuppressWarnings("deprecation") // Setting deprecated windowIndex field public PositionInfo( @Nullable Object windowUid, int mediaItemIndex, @@ -263,27 +268,14 @@ public interface Player { } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_MEDIA_ITEM_INDEX, - FIELD_MEDIA_ITEM, - FIELD_PERIOD_INDEX, - FIELD_POSITION_MS, - FIELD_CONTENT_POSITION_MS, - FIELD_AD_GROUP_INDEX, - FIELD_AD_INDEX_IN_AD_GROUP - }) - private @interface FieldNumber {} - private static final int FIELD_MEDIA_ITEM_INDEX = 0; - private static final int FIELD_MEDIA_ITEM = 1; - private static final int FIELD_PERIOD_INDEX = 2; - private static final int FIELD_POSITION_MS = 3; - private static final int FIELD_CONTENT_POSITION_MS = 4; - private static final int FIELD_AD_GROUP_INDEX = 5; - private static final int FIELD_AD_INDEX_IN_AD_GROUP = 6; + private static final String FIELD_MEDIA_ITEM_INDEX = Util.intToStringMaxRadix(0); + private static final String FIELD_MEDIA_ITEM = Util.intToStringMaxRadix(1); + private static final String FIELD_PERIOD_INDEX = Util.intToStringMaxRadix(2); + private static final String FIELD_POSITION_MS = Util.intToStringMaxRadix(3); + private static final String FIELD_CONTENT_POSITION_MS = Util.intToStringMaxRadix(4); + private static final String FIELD_AD_GROUP_INDEX = Util.intToStringMaxRadix(5); + private static final String FIELD_AD_INDEX_IN_AD_GROUP = Util.intToStringMaxRadix(6); /** * {@inheritDoc} @@ -294,16 +286,31 @@ public interface Player { @UnstableApi @Override public Bundle toBundle() { + return toBundle(/* canAccessCurrentMediaItem= */ true, /* canAccessTimeline= */ true); + } + + /** + * Returns a {@link Bundle} representing the information stored in this object, filtered by + * available commands. + * + * @param canAccessCurrentMediaItem Whether the {@link Bundle} should contain information + * accessbile with {@link #COMMAND_GET_CURRENT_MEDIA_ITEM}. + * @param canAccessTimeline Whether the {@link Bundle} should contain information accessbile + * with {@link #COMMAND_GET_TIMELINE}. + */ + @UnstableApi + public Bundle toBundle(boolean canAccessCurrentMediaItem, boolean canAccessTimeline) { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_MEDIA_ITEM_INDEX), mediaItemIndex); - if (mediaItem != null) { - bundle.putBundle(keyForField(FIELD_MEDIA_ITEM), mediaItem.toBundle()); + bundle.putInt(FIELD_MEDIA_ITEM_INDEX, canAccessTimeline ? mediaItemIndex : 0); + if (mediaItem != null && canAccessCurrentMediaItem) { + bundle.putBundle(FIELD_MEDIA_ITEM, mediaItem.toBundle()); } - bundle.putInt(keyForField(FIELD_PERIOD_INDEX), periodIndex); - bundle.putLong(keyForField(FIELD_POSITION_MS), positionMs); - bundle.putLong(keyForField(FIELD_CONTENT_POSITION_MS), contentPositionMs); - bundle.putInt(keyForField(FIELD_AD_GROUP_INDEX), adGroupIndex); - bundle.putInt(keyForField(FIELD_AD_INDEX_IN_AD_GROUP), adIndexInAdGroup); + bundle.putInt(FIELD_PERIOD_INDEX, canAccessTimeline ? periodIndex : 0); + bundle.putLong(FIELD_POSITION_MS, canAccessCurrentMediaItem ? positionMs : 0); + bundle.putLong(FIELD_CONTENT_POSITION_MS, canAccessCurrentMediaItem ? contentPositionMs : 0); + bundle.putInt(FIELD_AD_GROUP_INDEX, canAccessCurrentMediaItem ? adGroupIndex : C.INDEX_UNSET); + bundle.putInt( + FIELD_AD_INDEX_IN_AD_GROUP, canAccessCurrentMediaItem ? adIndexInAdGroup : C.INDEX_UNSET); return bundle; } @@ -311,22 +318,17 @@ public interface Player { @UnstableApi public static final Creator CREATOR = PositionInfo::fromBundle; private static PositionInfo fromBundle(Bundle bundle) { - int mediaItemIndex = - bundle.getInt(keyForField(FIELD_MEDIA_ITEM_INDEX), /* defaultValue= */ C.INDEX_UNSET); - @Nullable Bundle mediaItemBundle = bundle.getBundle(keyForField(FIELD_MEDIA_ITEM)); + int mediaItemIndex = bundle.getInt(FIELD_MEDIA_ITEM_INDEX, /* defaultValue= */ 0); + @Nullable Bundle mediaItemBundle = bundle.getBundle(FIELD_MEDIA_ITEM); @Nullable MediaItem mediaItem = mediaItemBundle == null ? null : MediaItem.CREATOR.fromBundle(mediaItemBundle); - int periodIndex = - bundle.getInt(keyForField(FIELD_PERIOD_INDEX), /* defaultValue= */ C.INDEX_UNSET); - long positionMs = - bundle.getLong(keyForField(FIELD_POSITION_MS), /* defaultValue= */ C.TIME_UNSET); - long contentPositionMs = - bundle.getLong(keyForField(FIELD_CONTENT_POSITION_MS), /* defaultValue= */ C.TIME_UNSET); - int adGroupIndex = - bundle.getInt(keyForField(FIELD_AD_GROUP_INDEX), /* defaultValue= */ C.INDEX_UNSET); + int periodIndex = bundle.getInt(FIELD_PERIOD_INDEX, /* defaultValue= */ 0); + long positionMs = bundle.getLong(FIELD_POSITION_MS, /* defaultValue= */ 0); + long contentPositionMs = bundle.getLong(FIELD_CONTENT_POSITION_MS, /* defaultValue= */ 0); + int adGroupIndex = bundle.getInt(FIELD_AD_GROUP_INDEX, /* defaultValue= */ C.INDEX_UNSET); int adIndexInAdGroup = - bundle.getInt(keyForField(FIELD_AD_INDEX_IN_AD_GROUP), /* defaultValue= */ C.INDEX_UNSET); + bundle.getInt(FIELD_AD_INDEX_IN_AD_GROUP, /* defaultValue= */ C.INDEX_UNSET); return new PositionInfo( /* windowUid= */ null, mediaItemIndex, @@ -338,14 +340,10 @@ public interface Player { adGroupIndex, adIndexInAdGroup); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** - * A set of {@link Command commands}. + * A set of {@linkplain Command commands}. * *

Instances are immutable. */ @@ -429,9 +427,9 @@ public interface Player { } /** - * Adds {@link Command commands}. + * Adds {@linkplain Command commands}. * - * @param commands The {@link Command commands} to add. + * @param commands The {@linkplain Command commands} to add. * @return This builder. * @throws IllegalStateException If {@link #build()} has already been called. */ @@ -444,7 +442,7 @@ public interface Player { /** * Adds {@link Commands}. * - * @param commands The set of {@link Command commands} to add. + * @param commands The set of {@linkplain Command commands} to add. * @return This builder. * @throws IllegalStateException If {@link #build()} has already been called. */ @@ -455,7 +453,7 @@ public interface Player { } /** - * Adds all existing {@link Command commands}. + * Adds all existing {@linkplain Command commands}. * * @return This builder. * @throws IllegalStateException If {@link #build()} has already been called. @@ -494,9 +492,9 @@ public interface Player { } /** - * Removes {@link Command commands}. + * Removes {@linkplain Command commands}. * - * @param commands The {@link Command commands} to remove. + * @param commands The {@linkplain Command commands} to remove. * @return This builder. * @throws IllegalStateException If {@link #build()} has already been called. */ @@ -576,13 +574,7 @@ public interface Player { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_COMMANDS}) - private @interface FieldNumber {} - - private static final int FIELD_COMMANDS = 0; + private static final String FIELD_COMMANDS = Util.intToStringMaxRadix(0); @UnstableApi @Override @@ -592,7 +584,7 @@ public interface Player { for (int i = 0; i < flags.size(); i++) { commandsBundle.add(flags.get(i)); } - bundle.putIntegerArrayList(keyForField(FIELD_COMMANDS), commandsBundle); + bundle.putIntegerArrayList(FIELD_COMMANDS, commandsBundle); return bundle; } @@ -600,8 +592,7 @@ public interface Player { @UnstableApi public static final Creator CREATOR = Commands::fromBundle; private static Commands fromBundle(Bundle bundle) { - @Nullable - ArrayList commands = bundle.getIntegerArrayList(keyForField(FIELD_COMMANDS)); + @Nullable ArrayList commands = bundle.getIntegerArrayList(FIELD_COMMANDS); if (commands == null) { return Commands.EMPTY; } @@ -611,16 +602,18 @@ public interface Player { } return builder.build(); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** - * Listener of all changes in the Player. + * Listener for changes in a {@link Player}. * *

All methods have no-op default implementations to allow selective overrides. + * + *

If the return value of a {@link Player} getter changes due to a change in {@linkplain + * #onAvailableCommandsChanged(Commands) command availability}, the corresponding listener + * method(s) will be invoked. If the return value of a {@link Player} getter does not change + * because the corresponding command is {@linkplain #onAvailableCommandsChanged(Commands) not + * available}, the corresponding listener method will not be invoked. */ interface Listener { @@ -630,8 +623,6 @@ public interface Player { *

State changes and events that happen within one {@link Looper} message queue iteration are * reported together and only after all individual callbacks were triggered. * - *

Only state changes represented by {@link Event events} are reported through this method. - * *

Listeners should prefer this method over individual callbacks in the following cases: * *

    @@ -657,7 +648,7 @@ public interface Player { default void onEvents(Player player, Events events) {} /** - * Called when the timeline has been refreshed. + * Called when the value of {@link Player#getCurrentTimeline()} changes. * *

    Note that the current {@link MediaItem} or playback position may change as a result of a * timeline change. If playback can't continue smoothly because of this timeline change, a @@ -676,8 +667,8 @@ public interface Player { * Called when playback transitions to a media item or starts repeating a media item according * to the current {@link #getRepeatMode() repeat mode}. * - *

    Note that this callback is also called when the playlist becomes non-empty or empty as a - * consequence of a playlist change. + *

    Note that this callback is also called when the value of {@link #getCurrentTimeline()} + * becomes non-empty or empty. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -689,7 +680,7 @@ public interface Player { @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {} /** - * Called when the tracks change. + * Called when the value of {@link Player#getCurrentTracks()} changes. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -699,14 +690,7 @@ public interface Player { default void onTracksChanged(Tracks tracks) {} /** - * Called when the combined {@link MediaMetadata} changes. - * - *

    The provided {@link MediaMetadata} is a combination of the {@link MediaItem#mediaMetadata - * MediaItem metadata}, the static metadata in the media's {@link Format#metadata Format}, and - * any timed metadata that has been parsed from the media and output via {@link - * Listener#onMetadata(Metadata)}. If a field is populated in the {@link - * MediaItem#mediaMetadata}, it will be prioritised above the same field coming from static or - * timed metadata. + * Called when the value of {@link Player#getMediaMetadata()} changes. * *

    This method may be called multiple times in quick succession. * @@ -718,7 +702,7 @@ public interface Player { default void onMediaMetadataChanged(MediaMetadata mediaMetadata) {} /** - * Called when the playlist {@link MediaMetadata} changes. + * Called when the value of {@link Player#getPlaylistMetadata()} changes. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -777,7 +761,7 @@ public interface Player { *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. * - * @param playbackState The new playback {@link State state}. + * @param playbackState The new playback {@link State}. */ default void onPlaybackStateChanged(@State int playbackState) {} @@ -788,7 +772,7 @@ public interface Player { * other events that happen in the same {@link Looper} message queue iteration. * * @param playWhenReady Whether playback will proceed when ready. - * @param reason The {@link PlayWhenReadyChangeReason reason} for the change. + * @param reason The {@link PlayWhenReadyChangeReason} for the change. */ default void onPlayWhenReadyChanged( boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {} @@ -830,7 +814,7 @@ public interface Player { *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. * - * @param shuffleModeEnabled Whether shuffling of {@link MediaItem media items} is enabled. + * @param shuffleModeEnabled Whether shuffling of {@linkplain MediaItem media items} is enabled. */ default void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {} @@ -887,10 +871,10 @@ public interface Player { PositionInfo oldPosition, PositionInfo newPosition, @DiscontinuityReason int reason) {} /** - * Called when the current playback parameters change. The playback parameters may change due to - * a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player itself may change - * them (for example, if audio playback switches to passthrough or offload mode, where speed - * adjustment is no longer possible). + * Called when the value of {@link #getPlaybackParameters()} changes. The playback parameters + * may change due to a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player + * itself may change them (for example, if audio playback switches to passthrough or offload + * mode, where speed adjustment is no longer possible). * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -951,7 +935,7 @@ public interface Player { default void onAudioSessionIdChanged(int audioSessionId) {} /** - * Called when the audio attributes change. + * Called when the value of {@link #getAudioAttributes()} changes. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -961,7 +945,7 @@ public interface Player { default void onAudioAttributesChanged(AudioAttributes audioAttributes) {} /** - * Called when the volume changes. + * Called when the value of {@link #getVolume()} changes. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -991,7 +975,7 @@ public interface Player { default void onDeviceInfoChanged(DeviceInfo deviceInfo) {} /** - * Called when the device volume or mute state changes. + * Called when the value of {@link #getDeviceVolume()} or {@link #isDeviceMuted()} changes. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -1035,10 +1019,10 @@ public interface Player { default void onRenderedFirstFrame() {} /** - * Called when there is a change in the {@link Cue Cues}. + * Called when the value of {@link #getCurrentCues()} changes. * - *

    Both {@link #onCues(List)} and {@link #onCues(CueGroup)} are called when there is a change - * in the cues. You should only implement one or the other. + *

    Both this method and {@link #onCues(CueGroup)} are called when there is a change in the + * cues. You should only implement one or the other. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -1050,10 +1034,10 @@ public interface Player { default void onCues(List cues) {} /** - * Called when there is a change in the {@link CueGroup}. + * Called when the value of {@link #getCurrentCues()} changes. * - *

    Both {@link #onCues(List)} and {@link #onCues(CueGroup)} are called when there is a change - * in the cues. You should only implement one or the other. + *

    Both this method and {@link #onCues(List)} are called when there is a change in the cues. + * You should only implement one or the other. * *

    {@link #onEvents(Player, Events)} will also be called to report this event along with * other events that happen in the same {@link Looper} message queue iteration. @@ -1400,21 +1384,50 @@ public interface Player { int EVENT_DEVICE_VOLUME_CHANGED = 30; /** - * Commands that can be executed on a {@code Player}. One of {@link #COMMAND_PLAY_PAUSE}, {@link - * #COMMAND_PREPARE}, {@link #COMMAND_STOP}, {@link #COMMAND_SEEK_TO_DEFAULT_POSITION}, {@link - * #COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM}, {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM}, {@link - * #COMMAND_SEEK_TO_PREVIOUS}, {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM}, {@link - * #COMMAND_SEEK_TO_NEXT}, {@link #COMMAND_SEEK_TO_MEDIA_ITEM}, {@link #COMMAND_SEEK_BACK}, {@link - * #COMMAND_SEEK_FORWARD}, {@link #COMMAND_SET_SPEED_AND_PITCH}, {@link - * #COMMAND_SET_SHUFFLE_MODE}, {@link #COMMAND_SET_REPEAT_MODE}, {@link - * #COMMAND_GET_CURRENT_MEDIA_ITEM}, {@link #COMMAND_GET_TIMELINE}, {@link - * #COMMAND_GET_MEDIA_ITEMS_METADATA}, {@link #COMMAND_SET_MEDIA_ITEMS_METADATA}, {@link - * #COMMAND_CHANGE_MEDIA_ITEMS}, {@link #COMMAND_GET_AUDIO_ATTRIBUTES}, {@link - * #COMMAND_GET_VOLUME}, {@link #COMMAND_GET_DEVICE_VOLUME}, {@link #COMMAND_SET_VOLUME}, {@link - * #COMMAND_SET_DEVICE_VOLUME}, {@link #COMMAND_ADJUST_DEVICE_VOLUME}, {@link - * #COMMAND_SET_VIDEO_SURFACE}, {@link #COMMAND_GET_TEXT}, {@link - * #COMMAND_SET_TRACK_SELECTION_PARAMETERS}, {@link #COMMAND_GET_TRACKS} or {@link - * #COMMAND_SET_MEDIA_ITEM}. + * Commands that indicate which method calls are currently permitted on a particular {@code + * Player} instance. + * + *

    The currently available commands can be inspected with {@link #getAvailableCommands()} and + * {@link #isCommandAvailable(int)}. + * + *

    See the documentation of each command constant for the details of which methods it permits + * calling. + * + *

    One of the following values: + * + *

      + *
    • {@link #COMMAND_PLAY_PAUSE} + *
    • {@link #COMMAND_PREPARE} + *
    • {@link #COMMAND_STOP} + *
    • {@link #COMMAND_SEEK_TO_DEFAULT_POSITION} + *
    • {@link #COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM} + *
    • {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM} + *
    • {@link #COMMAND_SEEK_TO_PREVIOUS} + *
    • {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} + *
    • {@link #COMMAND_SEEK_TO_NEXT} + *
    • {@link #COMMAND_SEEK_TO_MEDIA_ITEM} + *
    • {@link #COMMAND_SEEK_BACK} + *
    • {@link #COMMAND_SEEK_FORWARD} + *
    • {@link #COMMAND_SET_SPEED_AND_PITCH} + *
    • {@link #COMMAND_SET_SHUFFLE_MODE} + *
    • {@link #COMMAND_SET_REPEAT_MODE} + *
    • {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} + *
    • {@link #COMMAND_GET_TIMELINE} + *
    • {@link #COMMAND_GET_MEDIA_ITEMS_METADATA} + *
    • {@link #COMMAND_SET_MEDIA_ITEMS_METADATA} + *
    • {@link #COMMAND_SET_MEDIA_ITEM} + *
    • {@link #COMMAND_CHANGE_MEDIA_ITEMS} + *
    • {@link #COMMAND_GET_AUDIO_ATTRIBUTES} + *
    • {@link #COMMAND_GET_VOLUME} + *
    • {@link #COMMAND_GET_DEVICE_VOLUME} + *
    • {@link #COMMAND_SET_VOLUME} + *
    • {@link #COMMAND_SET_DEVICE_VOLUME} + *
    • {@link #COMMAND_ADJUST_DEVICE_VOLUME} + *
    • {@link #COMMAND_SET_VIDEO_SURFACE} + *
    • {@link #COMMAND_GET_TEXT} + *
    • {@link #COMMAND_SET_TRACK_SELECTION_PARAMETERS} + *
    • {@link #COMMAND_GET_TRACKS} + *
    */ // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // with Kotlin usages from before TYPE_USE was added. @@ -1456,84 +1469,359 @@ public interface Player { COMMAND_GET_TRACKS, }) @interface Command {} - /** Command to start, pause or resume playback. */ + /** + * Command to start, pause or resume playback. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #play()} + *
    • {@link #pause()} + *
    • {@link #setPlayWhenReady(boolean)} + *
    + */ int COMMAND_PLAY_PAUSE = 1; - /** Command to prepare the player. */ + + /** + * Command to prepare the player. + * + *

    The {@link #prepare()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_PREPARE = 2; - /** Command to stop playback or release the player. */ + + /** + * Command to stop playback. + * + *

    The {@link #stop()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_STOP = 3; - /** Command to seek to the default position of the current {@link MediaItem}. */ + + /** + * Command to seek to the default position of the current {@link MediaItem}. + * + *

    The {@link #seekToDefaultPosition()} method must only be called if this command is + * {@linkplain #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_TO_DEFAULT_POSITION = 4; - /** Command to seek anywhere into the current {@link MediaItem}. */ + + /** + * Command to seek anywhere inside the current {@link MediaItem}. + * + *

    The {@link #seekTo(long)} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM = 5; /** * @deprecated Use {@link #COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM} instead. */ @UnstableApi @Deprecated int COMMAND_SEEK_IN_CURRENT_WINDOW = COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM; - /** Command to seek to the default position of the previous {@link MediaItem}. */ + + /** + * Command to seek to the default position of the previous {@link MediaItem}. + * + *

    The {@link #seekToPreviousMediaItem()} method must only be called if this command is + * {@linkplain #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM = 6; /** * @deprecated Use {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM} instead. */ @UnstableApi @Deprecated int COMMAND_SEEK_TO_PREVIOUS_WINDOW = COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; - /** Command to seek to an earlier position in the current or previous {@link MediaItem}. */ + /** + * Command to seek to an earlier position in the current {@link MediaItem} or the default position + * of the previous {@link MediaItem}. + * + *

    The {@link #seekToPrevious()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_TO_PREVIOUS = 7; - /** Command to seek to the default position of the next {@link MediaItem}. */ + /** + * Command to seek to the default position of the next {@link MediaItem}. + * + *

    The {@link #seekToNextMediaItem()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_TO_NEXT_MEDIA_ITEM = 8; /** * @deprecated Use {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} instead. */ @UnstableApi @Deprecated int COMMAND_SEEK_TO_NEXT_WINDOW = COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; - /** Command to seek to a later position in the current or next {@link MediaItem}. */ + /** + * Command to seek to a later position in the current {@link MediaItem} or the default position of + * the next {@link MediaItem}. + * + *

    The {@link #seekToNext()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_TO_NEXT = 9; - /** Command to seek anywhere in any {@link MediaItem}. */ + + /** + * Command to seek anywhere in any {@link MediaItem}. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #seekTo(int, long)} + *
    • {@link #seekToDefaultPosition(int)} + *
    + */ int COMMAND_SEEK_TO_MEDIA_ITEM = 10; /** * @deprecated Use {@link #COMMAND_SEEK_TO_MEDIA_ITEM} instead. */ @UnstableApi @Deprecated int COMMAND_SEEK_TO_WINDOW = COMMAND_SEEK_TO_MEDIA_ITEM; - /** Command to seek back by a fixed increment into the current {@link MediaItem}. */ + /** + * Command to seek back by a fixed increment inside the current {@link MediaItem}. + * + *

    The {@link #seekBack()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_BACK = 11; - /** Command to seek forward by a fixed increment into the current {@link MediaItem}. */ + /** + * Command to seek forward by a fixed increment inside the current {@link MediaItem}. + * + *

    The {@link #seekForward()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SEEK_FORWARD = 12; - /** Command to set the playback speed and pitch. */ + + /** + * Command to set the playback speed and pitch. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #setPlaybackParameters(PlaybackParameters)} + *
    • {@link #setPlaybackSpeed(float)} + *
    + */ int COMMAND_SET_SPEED_AND_PITCH = 13; - /** Command to enable shuffling. */ + + /** + * Command to enable shuffling. + * + *

    The {@link #setShuffleModeEnabled(boolean)} method must only be called if this command is + * {@linkplain #isCommandAvailable(int) available}. + */ int COMMAND_SET_SHUFFLE_MODE = 14; - /** Command to set the repeat mode. */ + + /** + * Command to set the repeat mode. + * + *

    The {@link #setRepeatMode(int)} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SET_REPEAT_MODE = 15; - /** Command to get the currently playing {@link MediaItem}. */ + + /** + * Command to get information about the currently playing {@link MediaItem}. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #getCurrentMediaItem()} + *
    • {@link #isCurrentMediaItemDynamic()} + *
    • {@link #isCurrentMediaItemLive()} + *
    • {@link #isCurrentMediaItemSeekable()} + *
    • {@link #getCurrentLiveOffset()} + *
    • {@link #getDuration()} + *
    • {@link #getCurrentPosition()} + *
    • {@link #getBufferedPosition()} + *
    • {@link #getContentDuration()} + *
    • {@link #getContentPosition()} + *
    • {@link #getContentBufferedPosition()} + *
    • {@link #getTotalBufferedDuration()} + *
    • {@link #isPlayingAd()} + *
    • {@link #getCurrentAdGroupIndex()} + *
    • {@link #getCurrentAdIndexInAdGroup()} + *
    + */ int COMMAND_GET_CURRENT_MEDIA_ITEM = 16; - /** Command to get the information about the current timeline. */ + + /** + * Command to get the information about the current timeline. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #getCurrentTimeline()} + *
    • {@link #getCurrentMediaItemIndex()} + *
    • {@link #getCurrentPeriodIndex()} + *
    • {@link #getMediaItemCount()} + *
    • {@link #getMediaItemAt(int)} + *
    • {@link #getNextMediaItemIndex()} + *
    • {@link #getPreviousMediaItemIndex()} + *
    • {@link #hasPreviousMediaItem()} + *
    • {@link #hasNextMediaItem()} + *
    + */ int COMMAND_GET_TIMELINE = 17; - /** Command to get the {@link MediaItem MediaItems} metadata. */ + + /** + * Command to get metadata related to the playlist and current {@link MediaItem}. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #getMediaMetadata()} + *
    • {@link #getPlaylistMetadata()} + *
    + */ + // TODO(b/263132691): Rename this to COMMAND_GET_METADATA int COMMAND_GET_MEDIA_ITEMS_METADATA = 18; - /** Command to set the {@link MediaItem MediaItems} metadata. */ + + /** + * Command to set the playlist metadata. + * + *

    The {@link #setPlaylistMetadata(MediaMetadata)} method must only be called if this command + * is {@linkplain #isCommandAvailable(int) available}. + */ + // TODO(b/263132691): Rename this to COMMAND_SET_PLAYLIST_METADATA int COMMAND_SET_MEDIA_ITEMS_METADATA = 19; - /** Command to set a {@link MediaItem MediaItem}. */ + + /** + * Command to set a {@link MediaItem}. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #setMediaItem(MediaItem)} + *
    • {@link #setMediaItem(MediaItem, boolean)} + *
    • {@link #setMediaItem(MediaItem, long)} + *
    + */ int COMMAND_SET_MEDIA_ITEM = 31; - /** Command to change the {@link MediaItem MediaItems} in the playlist. */ + /** + * Command to change the {@linkplain MediaItem media items} in the playlist. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #addMediaItem(MediaItem)} + *
    • {@link #addMediaItem(int, MediaItem)} + *
    • {@link #addMediaItems(List)} + *
    • {@link #addMediaItems(int, List)} + *
    • {@link #clearMediaItems()} + *
    • {@link #moveMediaItem(int, int)} + *
    • {@link #moveMediaItems(int, int, int)} + *
    • {@link #removeMediaItem(int)} + *
    • {@link #removeMediaItems(int, int)} + *
    • {@link #setMediaItems(List)} + *
    • {@link #setMediaItems(List, boolean)} + *
    • {@link #setMediaItems(List, int, long)} + *
    + */ int COMMAND_CHANGE_MEDIA_ITEMS = 20; - /** Command to get the player current {@link AudioAttributes}. */ + + /** + * Command to get the player current {@link AudioAttributes}. + * + *

    The {@link #getAudioAttributes()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_GET_AUDIO_ATTRIBUTES = 21; - /** Command to get the player volume. */ + + /** + * Command to get the player volume. + * + *

    The {@link #getVolume()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_GET_VOLUME = 22; - /** Command to get the device volume and whether it is muted. */ + + /** + * Command to get the device volume and whether it is muted. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #getDeviceVolume()} + *
    • {@link #isDeviceMuted()} + *
    + */ int COMMAND_GET_DEVICE_VOLUME = 23; - /** Command to set the player volume. */ + + /** + * Command to set the player volume. + * + *

    The {@link #setVolume(float)} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SET_VOLUME = 24; - /** Command to set the device volume and mute it. */ + /** + * Command to set the device volume. + * + *

    The {@link #setDeviceVolume(int)} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_SET_DEVICE_VOLUME = 25; - /** Command to increase and decrease the device volume and mute it. */ + + /** + * Command to increase and decrease the device volume and mute it. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #increaseDeviceVolume()} + *
    • {@link #decreaseDeviceVolume()} + *
    • {@link #setDeviceMuted(boolean)} + *
    + */ int COMMAND_ADJUST_DEVICE_VOLUME = 26; - /** Command to set and clear the surface on which to render the video. */ + + /** + * Command to set and clear the surface on which to render the video. + * + *

    The following methods must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}: + * + *

      + *
    • {@link #setVideoSurface(Surface)} + *
    • {@link #clearVideoSurface()} + *
    • {@link #clearVideoSurface(Surface)} + *
    • {@link #setVideoSurfaceHolder(SurfaceHolder)} + *
    • {@link #clearVideoSurfaceHolder(SurfaceHolder)} + *
    • {@link #setVideoSurfaceView(SurfaceView)} + *
    • {@link #clearVideoSurfaceView(SurfaceView)} + *
    + */ int COMMAND_SET_VIDEO_SURFACE = 27; - /** Command to get the text that should currently be displayed by the player. */ + + /** + * Command to get the text that should currently be displayed by the player. + * + *

    The {@link #getCurrentCues()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_GET_TEXT = 28; - /** Command to set the player's track selection parameters. */ + + /** + * Command to set the player's track selection parameters. + * + *

    The {@link #setTrackSelectionParameters(TrackSelectionParameters)} method must only be + * called if this command is {@linkplain #isCommandAvailable(int) available}. + */ int COMMAND_SET_TRACK_SELECTION_PARAMETERS = 29; - /** Command to get details of the current track selection. */ + + /** + * Command to get details of the current track selection. + * + *

    The {@link #getCurrentTracks()} method must only be called if this command is {@linkplain + * #isCommandAvailable(int) available}. + */ int COMMAND_GET_TRACKS = 30; /** Represents an invalid {@link Command}. */ @@ -1542,6 +1830,8 @@ public interface Player { /** * Returns the {@link Looper} associated with the application thread that's used to access the * player and on which player events are received. + * + *

    This method can be called from any thread. */ Looper getApplicationLooper(); @@ -1551,6 +1841,8 @@ public interface Player { *

    The listener's methods will be called on the thread associated with {@link * #getApplicationLooper()}. * + *

    This method can be called from any thread. + * * @param listener The listener to register. */ void addListener(Listener listener); @@ -1564,17 +1856,23 @@ public interface Player { void removeListener(Listener listener); /** - * Clears the playlist, adds the specified {@link MediaItem MediaItems} and resets the position to - * the default position. + * Clears the playlist, adds the specified {@linkplain MediaItem media items} and resets the + * position to the default position. * - * @param mediaItems The new {@link MediaItem MediaItems}. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param mediaItems The new {@linkplain MediaItem media items}. */ void setMediaItems(List mediaItems); /** - * Clears the playlist and adds the specified {@link MediaItem MediaItems}. + * Clears the playlist and adds the specified {@linkplain MediaItem media items}. * - * @param mediaItems The new {@link MediaItem MediaItems}. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param mediaItems The new {@linkplain MediaItem media items}. * @param resetPosition Whether the playback position should be reset to the default position in * the first {@link Timeline.Window}. If false, playback will start from the position defined * by {@link #getCurrentMediaItemIndex()} and {@link #getCurrentPosition()}. @@ -1582,9 +1880,12 @@ public interface Player { void setMediaItems(List mediaItems, boolean resetPosition); /** - * Clears the playlist and adds the specified {@link MediaItem MediaItems}. + * Clears the playlist and adds the specified {@linkplain MediaItem media items}. * - * @param mediaItems The new {@link MediaItem MediaItems}. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param mediaItems The new {@linkplain MediaItem media items}. * @param startIndex The {@link MediaItem} index to start playback from. If {@link C#INDEX_UNSET} * is passed, the current position is not reset. * @param startPositionMs The position in milliseconds to start playback from. If {@link @@ -1600,6 +1901,9 @@ public interface Player { * Clears the playlist, adds the specified {@link MediaItem} and resets the position to the * default position. * + *

    This method must only be called if {@link #COMMAND_SET_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @param mediaItem The new {@link MediaItem}. */ void setMediaItem(MediaItem mediaItem); @@ -1607,6 +1911,9 @@ public interface Player { /** * Clears the playlist and adds the specified {@link MediaItem}. * + *

    This method must only be called if {@link #COMMAND_SET_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @param mediaItem The new {@link MediaItem}. * @param startPositionMs The position in milliseconds to start playback from. */ @@ -1615,6 +1922,9 @@ public interface Player { /** * Clears the playlist and adds the specified {@link MediaItem}. * + *

    This method must only be called if {@link #COMMAND_SET_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @param mediaItem The new {@link MediaItem}. * @param resetPosition Whether the playback position should be reset to the default position. If * false, playback will start from the position defined by {@link #getCurrentMediaItemIndex()} @@ -1625,6 +1935,9 @@ public interface Player { /** * Adds a media item to the end of the playlist. * + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * * @param mediaItem The {@link MediaItem} to add. */ void addMediaItem(MediaItem mediaItem); @@ -1632,6 +1945,9 @@ public interface Player { /** * Adds a media item at the given index of the playlist. * + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * * @param index The index at which to add the media item. If the index is larger than the size of * the playlist, the media item is added to the end of the playlist. * @param mediaItem The {@link MediaItem} to add. @@ -1641,23 +1957,33 @@ public interface Player { /** * Adds a list of media items to the end of the playlist. * - * @param mediaItems The {@link MediaItem MediaItems} to add. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param mediaItems The {@linkplain MediaItem media items} to add. */ void addMediaItems(List mediaItems); /** * Adds a list of media items at the given index of the playlist. * + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * * @param index The index at which to add the media items. If the index is larger than the size of * the playlist, the media items are added to the end of the playlist. - * @param mediaItems The {@link MediaItem MediaItems} to add. + * @param mediaItems The {@linkplain MediaItem media items} to add. */ void addMediaItems(int index, List mediaItems); /** * Moves the media item at the current index to the new index. * - * @param currentIndex The current index of the media item to move. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param currentIndex The current index of the media item to move. If the index is larger than + * the size of the playlist, the request is ignored. * @param newIndex The new index of the media item. If the new index is larger than the size of * the playlist the item is moved to the end of the playlist. */ @@ -1666,8 +1992,13 @@ public interface Player { /** * Moves the media item range to the new index. * - * @param fromIndex The start of the range to move. - * @param toIndex The first item not to be included in the range (exclusive). + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param fromIndex The start of the range to move. If the index is larger than the size of the + * playlist, the request is ignored. + * @param toIndex The first item not to be included in the range (exclusive). If the index is + * larger than the size of the playlist, items up to the end of the playlist are moved. * @param newIndex The new index of the first media item of the range. If the new index is larger * than the size of the remaining playlist after removing the range, the range is moved to the * end of the playlist. @@ -1677,20 +2008,33 @@ public interface Player { /** * Removes the media item at the given index of the playlist. * - * @param index The index at which to remove the media item. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param index The index at which to remove the media item. If the index is larger than the size + * of the playlist, the request is ignored. */ void removeMediaItem(int index); /** * Removes a range of media items from the playlist. * - * @param fromIndex The index at which to start removing media items. + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + * + * @param fromIndex The index at which to start removing media items. If the index is larger than + * the size of the playlist, the request is ignored. * @param toIndex The index of the first item to be kept (exclusive). If the index is larger than - * the size of the playlist, media items to the end of the playlist are removed. + * the size of the playlist, media items up to the end of the playlist are removed. */ void removeMediaItems(int fromIndex, int toIndex); - /** Clears the playlist. */ + /** + * Clears the playlist. + * + *

    This method must only be called if {@link #COMMAND_CHANGE_MEDIA_ITEMS} is {@linkplain + * #getAvailableCommands() available}. + */ void clearMediaItems(); /** @@ -1698,13 +2042,6 @@ public interface Player { * *

    This method does not execute the command. * - *

    Executing a command that is not available (for example, calling {@link - * #seekToNextMediaItem()} if {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} is unavailable) will - * neither throw an exception nor generate a {@link #getPlayerError()} player error}. - * - *

    {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM} and {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} - * are unavailable if there is no such {@link MediaItem}. - * * @param command A {@link Command}. * @return Whether the {@link Command} is available. * @see Listener#onAvailableCommandsChanged(Commands) @@ -1721,13 +2058,6 @@ public interface Player { * Listener#onAvailableCommandsChanged(Commands)} to get an update when the available commands * change. * - *

    Executing a command that is not available (for example, calling {@link - * #seekToNextMediaItem()} if {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} is unavailable) will - * neither throw an exception nor generate a {@link #getPlayerError()} player error}. - * - *

    {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM} and {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} - * are unavailable if there is no such {@link MediaItem}. - * * @return The currently available {@link Commands}. * @see Listener#onAvailableCommandsChanged */ @@ -1736,15 +2066,18 @@ public interface Player { /** * Prepares the player. * + *

    This method must only be called if {@link #COMMAND_PREPARE} is {@linkplain + * #getAvailableCommands() available}. + * *

    This will move the player out of {@link #STATE_IDLE idle state} and the player will start * loading media and acquire resources needed for playback. */ void prepare(); /** - * Returns the current {@link State playback state} of the player. + * Returns the current {@linkplain State playback state} of the player. * - * @return The current {@link State playback state}. + * @return The current {@linkplain State playback state}. * @see Listener#onPlaybackStateChanged(int) */ @State @@ -1754,7 +2087,7 @@ public interface Player { * Returns the reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code * true}, or {@link #PLAYBACK_SUPPRESSION_REASON_NONE} if playback is not suppressed. * - * @return The current {@link PlaybackSuppressionReason playback suppression reason}. + * @return The current {@link PlaybackSuppressionReason}. * @see Listener#onPlaybackSuppressionReasonChanged(int) */ @PlaybackSuppressionReason @@ -1792,11 +2125,19 @@ public interface Player { /** * Resumes playback as soon as {@link #getPlaybackState()} == {@link #STATE_READY}. Equivalent to - * {@code setPlayWhenReady(true)}. + * {@link #setPlayWhenReady(boolean) setPlayWhenReady(true)}. + * + *

    This method must only be called if {@link #COMMAND_PLAY_PAUSE} is {@linkplain + * #getAvailableCommands() available}. */ void play(); - /** Pauses playback. Equivalent to {@code setPlayWhenReady(false)}. */ + /** + * Pauses playback. Equivalent to {@link #setPlayWhenReady(boolean) setPlayWhenReady(false)}. + * + *

    This method must only be called if {@link #COMMAND_PLAY_PAUSE} is {@linkplain + * #getAvailableCommands() available}. + */ void pause(); /** @@ -1804,6 +2145,9 @@ public interface Player { * *

    If the player is already in the ready state then this method pauses and resumes playback. * + *

    This method must only be called if {@link #COMMAND_PLAY_PAUSE} is {@linkplain + * #getAvailableCommands() available}. + * * @param playWhenReady Whether playback should proceed when ready. */ void setPlayWhenReady(boolean playWhenReady); @@ -1819,6 +2163,9 @@ public interface Player { /** * Sets the {@link RepeatMode} to be used for playback. * + *

    This method must only be called if {@link #COMMAND_SET_REPEAT_MODE} is {@linkplain + * #getAvailableCommands() available}. + * * @param repeatMode The repeat mode. */ void setRepeatMode(@RepeatMode int repeatMode); @@ -1835,6 +2182,9 @@ public interface Player { /** * Sets whether shuffling of media items is enabled. * + *

    This method must only be called if {@link #COMMAND_SET_SHUFFLE_MODE} is {@linkplain + * #getAvailableCommands() available}. + * * @param shuffleModeEnabled Whether shuffling is enabled. */ void setShuffleModeEnabled(boolean shuffleModeEnabled); @@ -1858,6 +2208,9 @@ public interface Player { * Seeks to the default position associated with the current {@link MediaItem}. The position can * depend on the type of media being played. For live streams it will typically be the live edge. * For other streams it will typically be the start. + * + *

    This method must only be called if {@link #COMMAND_SEEK_TO_DEFAULT_POSITION} is {@linkplain + * #getAvailableCommands() available}. */ void seekToDefaultPosition(); @@ -1866,16 +2219,21 @@ public interface Player { * depend on the type of media being played. For live streams it will typically be the live edge. * For other streams it will typically be the start. * + *

    This method must only be called if {@link #COMMAND_SEEK_TO_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @param mediaItemIndex The index of the {@link MediaItem} whose associated default position - * should be seeked to. - * @throws IllegalSeekPositionException If the player has a non-empty timeline and the provided - * {@code mediaItemIndex} is not within the bounds of the current timeline. + * should be seeked to. If the index is larger than the size of the playlist, the request is + * ignored. */ void seekToDefaultPosition(int mediaItemIndex); /** * Seeks to a position specified in milliseconds in the current {@link MediaItem}. * + *

    This method must only be called if {@link #COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM} is + * {@linkplain #getAvailableCommands() available}. + * * @param positionMs The seek position in the current {@link MediaItem}, or {@link C#TIME_UNSET} * to seek to the media item's default position. */ @@ -1884,11 +2242,13 @@ public interface Player { /** * Seeks to a position specified in milliseconds in the specified {@link MediaItem}. * - * @param mediaItemIndex The index of the {@link MediaItem}. + *

    This method must only be called if {@link #COMMAND_SEEK_TO_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * + * @param mediaItemIndex The index of the {@link MediaItem}. If the index is larger than the size + * of the playlist, the request is ignored. * @param positionMs The seek position in the specified {@link MediaItem}, or {@link C#TIME_UNSET} * to seek to the media item's default position. - * @throws IllegalSeekPositionException If the player has a non-empty timeline and the provided - * {@code mediaItemIndex} is not within the bounds of the current timeline. */ void seekTo(int mediaItemIndex, long positionMs); @@ -1902,6 +2262,9 @@ public interface Player { /** * Seeks back in the current {@link MediaItem} by {@link #getSeekBackIncrement()} milliseconds. + * + *

    This method must only be called if {@link #COMMAND_SEEK_BACK} is {@linkplain + * #getAvailableCommands() available}. */ void seekBack(); @@ -1916,6 +2279,9 @@ public interface Player { /** * Seeks forward in the current {@link MediaItem} by {@link #getSeekForwardIncrement()} * milliseconds. + * + *

    This method must only be called if {@link #COMMAND_SEEK_FORWARD} is {@linkplain + * #getAvailableCommands() available}. */ void seekForward(); @@ -1940,6 +2306,9 @@ public interface Player { *

    Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. */ boolean hasPreviousMediaItem(); @@ -1965,6 +2334,9 @@ public interface Player { *

    Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

    This method must only be called if {@link #COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM} is + * {@linkplain #getAvailableCommands() available}. */ void seekToPreviousMediaItem(); @@ -1996,6 +2368,9 @@ public interface Player { * MediaItem}. *

  • Otherwise, seeks to 0 in the current {@link MediaItem}. *
+ * + *

This method must only be called if {@link #COMMAND_SEEK_TO_PREVIOUS} is {@linkplain + * #getAvailableCommands() available}. */ void seekToPrevious(); @@ -2020,6 +2395,9 @@ public interface Player { *

Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. */ boolean hasNextMediaItem(); @@ -2045,6 +2423,9 @@ public interface Player { *

Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

This method must only be called if {@link #COMMAND_SEEK_TO_NEXT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ void seekToNextMediaItem(); @@ -2060,6 +2441,9 @@ public interface Player { * has not ended, seeks to the live edge of the current {@link MediaItem}. *

  • Otherwise, does nothing. * + * + *

    This method must only be called if {@link #COMMAND_SEEK_TO_NEXT} is {@linkplain + * #getAvailableCommands() available}. */ void seekToNext(); @@ -2071,6 +2455,9 @@ public interface Player { * Listener#onPlaybackParametersChanged(PlaybackParameters)} will be called whenever the currently * active playback parameters change. * + *

    This method must only be called if {@link #COMMAND_SET_SPEED_AND_PITCH} is {@linkplain + * #getAvailableCommands() available}. + * * @param playbackParameters The playback parameters. */ void setPlaybackParameters(PlaybackParameters playbackParameters); @@ -2081,8 +2468,11 @@ public interface Player { *

    This is equivalent to {@code * setPlaybackParameters(getPlaybackParameters().withSpeed(speed))}. * + *

    This method must only be called if {@link #COMMAND_SET_SPEED_AND_PITCH} is {@linkplain + * #getAvailableCommands() available}. + * * @param speed The linear factor by which playback will be sped up. Must be higher than 0. 1 is - * normal speed, 2 is twice as fast, 0.5 is half normal speed... + * normal speed, 2 is twice as fast, 0.5 is half normal speed. */ void setPlaybackSpeed(@FloatRange(from = 0, fromInclusive = false) float speed); @@ -2104,6 +2494,9 @@ public interface Player { * *

    Calling this method does not clear the playlist, reset the playback position or the playback * error. + * + *

    This method must only be called if {@link #COMMAND_STOP} is {@linkplain + * #getAvailableCommands() available}. */ void stop(); @@ -2120,11 +2513,15 @@ public interface Player { * Releases the player. This method must be called when the player is no longer required. The * player must not be used after calling this method. */ + // TODO(b/261158047): Document that COMMAND_RELEASE must be available once it exists. void release(); /** * Returns the current tracks. * + *

    This method must only be called if {@link #COMMAND_GET_TRACKS} is {@linkplain + * #getAvailableCommands() available}. + * * @see Listener#onTracksChanged(Tracks) */ Tracks getCurrentTracks(); @@ -2152,6 +2549,9 @@ public interface Player { * .setMaxVideoSizeSd() * .build()) * } + * + *

    This method must only be called if {@link #COMMAND_SET_TRACK_SELECTION_PARAMETERS} is + * {@linkplain #getAvailableCommands() available}. */ void setTrackSelectionParameters(TrackSelectionParameters parameters); @@ -2164,16 +2564,27 @@ public interface Player { * metadata that has been parsed from the media and output via {@link * Listener#onMetadata(Metadata)}. If a field is populated in the {@link MediaItem#mediaMetadata}, * it will be prioritised above the same field coming from static or timed metadata. + * + *

    This method must only be called if {@link #COMMAND_GET_MEDIA_ITEMS_METADATA} is {@linkplain + * #getAvailableCommands() available}. */ MediaMetadata getMediaMetadata(); /** * Returns the playlist {@link MediaMetadata}, as set by {@link * #setPlaylistMetadata(MediaMetadata)}, or {@link MediaMetadata#EMPTY} if not supported. + * + *

    This method must only be called if {@link #COMMAND_GET_MEDIA_ITEMS_METADATA} is {@linkplain + * #getAvailableCommands() available}. */ MediaMetadata getPlaylistMetadata(); - /** Sets the playlist {@link MediaMetadata}. */ + /** + * Sets the playlist {@link MediaMetadata}. + * + *

    This method must only be called if {@link #COMMAND_SET_MEDIA_ITEMS_METADATA} is {@linkplain + * #getAvailableCommands() available}. + */ void setPlaylistMetadata(MediaMetadata mediaMetadata); /** @@ -2186,11 +2597,19 @@ public interface Player { /** * Returns the current {@link Timeline}. Never null, but may be empty. * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. + * * @see Listener#onTimelineChanged(Timeline, int) */ Timeline getCurrentTimeline(); - /** Returns the index of the period currently being played. */ + /** + * Returns the index of the period currently being played. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. + */ int getCurrentPeriodIndex(); /** @@ -2204,6 +2623,9 @@ public interface Player { * Returns the index of the current {@link MediaItem} in the {@link #getCurrentTimeline() * timeline}, or the prospective index if the {@link #getCurrentTimeline() current timeline} is * empty. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. */ int getCurrentMediaItemIndex(); @@ -2223,6 +2645,9 @@ public interface Player { *

    Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. */ int getNextMediaItemIndex(); @@ -2242,38 +2667,63 @@ public interface Player { *

    Note: When the repeat mode is {@link #REPEAT_MODE_ONE}, this method behaves the same as when * the current repeat mode is {@link #REPEAT_MODE_OFF}. See {@link #REPEAT_MODE_ONE} for more * details. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. */ int getPreviousMediaItemIndex(); /** * Returns the currently playing {@link MediaItem}. May be null if the timeline is empty. * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @see Listener#onMediaItemTransition(MediaItem, int) */ @Nullable MediaItem getCurrentMediaItem(); - /** Returns the number of {@link MediaItem media items} in the playlist. */ + /** + * Returns the number of {@linkplain MediaItem media items} in the playlist. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. + */ int getMediaItemCount(); - /** Returns the {@link MediaItem} at the given index. */ + /** + * Returns the {@link MediaItem} at the given index. + * + *

    This method must only be called if {@link #COMMAND_GET_TIMELINE} is {@linkplain + * #getAvailableCommands() available}. + */ MediaItem getMediaItemAt(int index); /** * Returns the duration of the current content or ad in milliseconds, or {@link C#TIME_UNSET} if * the duration is not known. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getDuration(); /** * Returns the playback position in the current content or ad, in milliseconds, or the prospective * position in milliseconds if the {@link #getCurrentTimeline() current timeline} is empty. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getCurrentPosition(); /** * Returns an estimate of the position in the current content or ad up to which data is buffered, * in milliseconds. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getBufferedPosition(); @@ -2286,7 +2736,10 @@ public interface Player { /** * Returns an estimate of the total buffered duration from the current position, in milliseconds. - * This includes pre-buffered data for subsequent ads and {@link MediaItem media items}. + * This includes pre-buffered data for subsequent ads and {@linkplain MediaItem media items}. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getTotalBufferedDuration(); @@ -2301,6 +2754,9 @@ public interface Player { * Returns whether the current {@link MediaItem} is dynamic (may change when the {@link Timeline} * is updated), or {@code false} if the {@link Timeline} is empty. * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @see Timeline.Window#isDynamic */ boolean isCurrentMediaItemDynamic(); @@ -2316,6 +2772,9 @@ public interface Player { * Returns whether the current {@link MediaItem} is live, or {@code false} if the {@link Timeline} * is empty. * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @see Timeline.Window#isLive() */ boolean isCurrentMediaItemLive(); @@ -2330,6 +2789,9 @@ public interface Player { * *

    Note that this offset may rely on an accurate local time, so this method may return an * incorrect value if the difference between system clock and server clock is unknown. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getCurrentLiveOffset(); @@ -2344,22 +2806,36 @@ public interface Player { * Returns whether the current {@link MediaItem} is seekable, or {@code false} if the {@link * Timeline} is empty. * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + * * @see Timeline.Window#isSeekable */ boolean isCurrentMediaItemSeekable(); - /** Returns whether the player is currently playing an ad. */ + /** + * Returns whether the player is currently playing an ad. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. + */ boolean isPlayingAd(); /** * If {@link #isPlayingAd()} returns true, returns the index of the ad group in the period * currently being played. Returns {@link C#INDEX_UNSET} otherwise. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ int getCurrentAdGroupIndex(); /** * If {@link #isPlayingAd()} returns true, returns the index of the ad in its ad group. Returns * {@link C#INDEX_UNSET} otherwise. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ int getCurrentAdIndexInAdGroup(); @@ -2367,6 +2843,9 @@ public interface Player { * If {@link #isPlayingAd()} returns {@code true}, returns the duration of the current content in * milliseconds, or {@link C#TIME_UNSET} if the duration is not known. If there is no ad playing, * the returned duration is the same as that returned by {@link #getDuration()}. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getContentDuration(); @@ -2374,6 +2853,9 @@ public interface Player { * If {@link #isPlayingAd()} returns {@code true}, returns the content position that will be * played once all ads in the ad group have finished playing, in milliseconds. If there is no ad * playing, the returned position is the same as that returned by {@link #getCurrentPosition()}. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getContentPosition(); @@ -2381,16 +2863,27 @@ public interface Player { * If {@link #isPlayingAd()} returns {@code true}, returns an estimate of the content position in * the current content up to which data is buffered, in milliseconds. If there is no ad playing, * the returned position is the same as that returned by {@link #getBufferedPosition()}. + * + *

    This method must only be called if {@link #COMMAND_GET_CURRENT_MEDIA_ITEM} is {@linkplain + * #getAvailableCommands() available}. */ long getContentBufferedPosition(); - /** Returns the attributes for audio playback. */ + /** + * Returns the attributes for audio playback. + * + *

    This method must only be called if {@link #COMMAND_GET_AUDIO_ATTRIBUTES} is {@linkplain + * #getAvailableCommands() available}. + */ AudioAttributes getAudioAttributes(); /** * Sets the audio volume, valid values are between 0 (silence) and 1 (unity gain, signal * unchanged), inclusive. * + *

    This method must only be called if {@link #COMMAND_SET_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + * * @param volume Linear output gain to apply to all audio channels. */ void setVolume(@FloatRange(from = 0, to = 1.0) float volume); @@ -2398,6 +2891,9 @@ public interface Player { /** * Returns the audio volume, with 0 being silence and 1 being unity gain (signal unchanged). * + *

    This method must only be called if {@link #COMMAND_GET_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + * * @return The linear gain applied to all audio channels. */ @FloatRange(from = 0, to = 1.0) @@ -2406,6 +2902,9 @@ public interface Player { /** * Clears any {@link Surface}, {@link SurfaceHolder}, {@link SurfaceView} or {@link TextureView} * currently set on the player. + * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. */ void clearVideoSurface(); @@ -2413,6 +2912,9 @@ public interface Player { * Clears the {@link Surface} onto which video is being rendered if it matches the one passed. * Else does nothing. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surface The surface to clear. */ void clearVideoSurface(@Nullable Surface surface); @@ -2428,6 +2930,9 @@ public interface Player { * this method, since passing the holder allows the player to track the lifecycle of the surface * automatically. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surface The {@link Surface}. */ void setVideoSurface(@Nullable Surface surface); @@ -2439,6 +2944,9 @@ public interface Player { *

    The thread that calls the {@link SurfaceHolder.Callback} methods must be the thread * associated with {@link #getApplicationLooper()}. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surfaceHolder The surface holder. */ void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder); @@ -2447,6 +2955,9 @@ public interface Player { * Clears the {@link SurfaceHolder} that holds the {@link Surface} onto which video is being * rendered if it matches the one passed. Else does nothing. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surfaceHolder The surface holder to clear. */ void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder); @@ -2458,6 +2969,9 @@ public interface Player { *

    The thread that calls the {@link SurfaceHolder.Callback} methods must be the thread * associated with {@link #getApplicationLooper()}. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surfaceView The surface view. */ void setVideoSurfaceView(@Nullable SurfaceView surfaceView); @@ -2466,6 +2980,9 @@ public interface Player { * Clears the {@link SurfaceView} onto which video is being rendered if it matches the one passed. * Else does nothing. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param surfaceView The texture view to clear. */ void clearVideoSurfaceView(@Nullable SurfaceView surfaceView); @@ -2477,6 +2994,9 @@ public interface Player { *

    The thread that calls the {@link TextureView.SurfaceTextureListener} methods must be the * thread associated with {@link #getApplicationLooper()}. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param textureView The texture view. */ void setVideoTextureView(@Nullable TextureView textureView); @@ -2485,6 +3005,9 @@ public interface Player { * Clears the {@link TextureView} onto which video is being rendered if it matches the one passed. * Else does nothing. * + *

    This method must only be called if {@link #COMMAND_SET_VIDEO_SURFACE} is {@linkplain + * #getAvailableCommands() available}. + * * @param textureView The texture view to clear. */ void clearVideoTextureView(@Nullable TextureView textureView); @@ -2507,7 +3030,12 @@ public interface Player { @UnstableApi Size getSurfaceSize(); - /** Returns the current {@link CueGroup}. */ + /** + * Returns the current {@link CueGroup}. + * + *

    This method must only be called if {@link #COMMAND_GET_TEXT} is {@linkplain + * #getAvailableCommands() available}. + */ CueGroup getCurrentCues(); /** Gets the device information. */ @@ -2523,26 +3051,52 @@ public interface Player { * *

    For devices with {@link DeviceInfo#PLAYBACK_TYPE_REMOTE remote playback}, the volume of the * remote device is returned. + * + *

    This method must only be called if {@link #COMMAND_GET_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. */ @IntRange(from = 0) int getDeviceVolume(); - /** Gets whether the device is muted or not. */ + /** + * Gets whether the device is muted or not. + * + *

    This method must only be called if {@link #COMMAND_GET_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + */ boolean isDeviceMuted(); /** * Sets the volume of the device. * + *

    This method must only be called if {@link #COMMAND_SET_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + * * @param volume The volume to set. */ void setDeviceVolume(@IntRange(from = 0) int volume); - /** Increases the volume of the device. */ + /** + * Increases the volume of the device. + * + *

    This method must only be called if {@link #COMMAND_ADJUST_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + */ void increaseDeviceVolume(); - /** Decreases the volume of the device. */ + /** + * Decreases the volume of the device. + * + *

    This method must only be called if {@link #COMMAND_ADJUST_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + */ void decreaseDeviceVolume(); - /** Sets the mute state of the device. */ + /** + * Sets the mute state of the device. + * + *

    This method must only be called if {@link #COMMAND_ADJUST_DEVICE_VOLUME} is {@linkplain + * #getAvailableCommands() available}. + */ void setDeviceMuted(boolean muted); } diff --git a/libraries/common/src/main/java/androidx/media3/common/Rating.java b/libraries/common/src/main/java/androidx/media3/common/Rating.java index f0df87c434..0d0cd18533 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Rating.java +++ b/libraries/common/src/main/java/androidx/media3/common/Rating.java @@ -20,6 +20,7 @@ import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; import androidx.annotation.IntDef; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -60,21 +61,14 @@ public abstract class Rating implements Bundleable { /* package */ static final int RATING_TYPE_STAR = 2; /* package */ static final int RATING_TYPE_THUMB = 3; - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_RATING_TYPE}) - private @interface FieldNumber {} - - /* package */ static final int FIELD_RATING_TYPE = 0; + /* package */ static final String FIELD_RATING_TYPE = Util.intToStringMaxRadix(0); /** Object that can restore a {@link Rating} from a {@link Bundle}. */ @UnstableApi public static final Creator CREATOR = Rating::fromBundle; private static Rating fromBundle(Bundle bundle) { @RatingType - int ratingType = - bundle.getInt(keyForField(FIELD_RATING_TYPE), /* defaultValue= */ RATING_TYPE_UNSET); + int ratingType = bundle.getInt(FIELD_RATING_TYPE, /* defaultValue= */ RATING_TYPE_UNSET); switch (ratingType) { case RATING_TYPE_HEART: return HeartRating.CREATOR.fromBundle(bundle); @@ -89,8 +83,4 @@ public abstract class Rating implements Bundleable { throw new IllegalArgumentException("Unknown RatingType: " + ratingType); } } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java index f3a073be7f..842f63912b 100644 --- a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java @@ -15,15 +15,27 @@ */ package androidx.media3.common; +import static androidx.annotation.VisibleForTesting.PROTECTED; +import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.common.util.Util.msToUs; +import static androidx.media3.common.util.Util.usToMs; +import static java.lang.Math.max; +import static java.lang.Math.min; +import android.graphics.Rect; import android.os.Looper; +import android.os.SystemClock; +import android.util.Pair; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.TextureView; +import androidx.annotation.FloatRange; +import androidx.annotation.IntRange; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.media3.common.text.CueGroup; import androidx.media3.common.util.Clock; import androidx.media3.common.util.HandlerWrapper; @@ -32,10 +44,13 @@ import androidx.media3.common.util.Size; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.ForOverride; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; @@ -90,18 +105,129 @@ public abstract class SimpleBasePlayer extends BasePlayer { private Commands availableCommands; private boolean playWhenReady; private @PlayWhenReadyChangeReason int playWhenReadyChangeReason; + private @Player.State int playbackState; + private @PlaybackSuppressionReason int playbackSuppressionReason; + @Nullable private PlaybackException playerError; + private @RepeatMode int repeatMode; + private boolean shuffleModeEnabled; + private boolean isLoading; + private long seekBackIncrementMs; + private long seekForwardIncrementMs; + private long maxSeekToPreviousPositionMs; + private PlaybackParameters playbackParameters; + private TrackSelectionParameters trackSelectionParameters; + private AudioAttributes audioAttributes; + private float volume; + private VideoSize videoSize; + private CueGroup currentCues; + private DeviceInfo deviceInfo; + private int deviceVolume; + private boolean isDeviceMuted; + private Size surfaceSize; + private boolean newlyRenderedFirstFrame; + private Metadata timedMetadata; + private ImmutableList playlist; + private Timeline timeline; + private MediaMetadata playlistMetadata; + private int currentMediaItemIndex; + private int currentAdGroupIndex; + private int currentAdIndexInAdGroup; + @Nullable private Long contentPositionMs; + private PositionSupplier contentPositionMsSupplier; + @Nullable private Long adPositionMs; + private PositionSupplier adPositionMsSupplier; + private PositionSupplier contentBufferedPositionMsSupplier; + private PositionSupplier adBufferedPositionMsSupplier; + private PositionSupplier totalBufferedDurationMsSupplier; + private boolean hasPositionDiscontinuity; + private @Player.DiscontinuityReason int positionDiscontinuityReason; + private long discontinuityPositionMs; /** Creates the builder. */ public Builder() { availableCommands = Commands.EMPTY; playWhenReady = false; playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST; + playbackState = Player.STATE_IDLE; + playbackSuppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_NONE; + playerError = null; + repeatMode = Player.REPEAT_MODE_OFF; + shuffleModeEnabled = false; + isLoading = false; + seekBackIncrementMs = C.DEFAULT_SEEK_BACK_INCREMENT_MS; + seekForwardIncrementMs = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS; + maxSeekToPreviousPositionMs = C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS; + playbackParameters = PlaybackParameters.DEFAULT; + trackSelectionParameters = TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT; + audioAttributes = AudioAttributes.DEFAULT; + volume = 1f; + videoSize = VideoSize.UNKNOWN; + currentCues = CueGroup.EMPTY_TIME_ZERO; + deviceInfo = DeviceInfo.UNKNOWN; + deviceVolume = 0; + isDeviceMuted = false; + surfaceSize = Size.UNKNOWN; + newlyRenderedFirstFrame = false; + timedMetadata = new Metadata(/* presentationTimeUs= */ C.TIME_UNSET); + playlist = ImmutableList.of(); + timeline = Timeline.EMPTY; + playlistMetadata = MediaMetadata.EMPTY; + currentMediaItemIndex = C.INDEX_UNSET; + currentAdGroupIndex = C.INDEX_UNSET; + currentAdIndexInAdGroup = C.INDEX_UNSET; + contentPositionMs = null; + contentPositionMsSupplier = PositionSupplier.getConstant(C.TIME_UNSET); + adPositionMs = null; + adPositionMsSupplier = PositionSupplier.ZERO; + contentBufferedPositionMsSupplier = PositionSupplier.getConstant(C.TIME_UNSET); + adBufferedPositionMsSupplier = PositionSupplier.ZERO; + totalBufferedDurationMsSupplier = PositionSupplier.ZERO; + hasPositionDiscontinuity = false; + positionDiscontinuityReason = Player.DISCONTINUITY_REASON_INTERNAL; + discontinuityPositionMs = 0; } private Builder(State state) { this.availableCommands = state.availableCommands; this.playWhenReady = state.playWhenReady; this.playWhenReadyChangeReason = state.playWhenReadyChangeReason; + this.playbackState = state.playbackState; + this.playbackSuppressionReason = state.playbackSuppressionReason; + this.playerError = state.playerError; + this.repeatMode = state.repeatMode; + this.shuffleModeEnabled = state.shuffleModeEnabled; + this.isLoading = state.isLoading; + this.seekBackIncrementMs = state.seekBackIncrementMs; + this.seekForwardIncrementMs = state.seekForwardIncrementMs; + this.maxSeekToPreviousPositionMs = state.maxSeekToPreviousPositionMs; + this.playbackParameters = state.playbackParameters; + this.trackSelectionParameters = state.trackSelectionParameters; + this.audioAttributes = state.audioAttributes; + this.volume = state.volume; + this.videoSize = state.videoSize; + this.currentCues = state.currentCues; + this.deviceInfo = state.deviceInfo; + this.deviceVolume = state.deviceVolume; + this.isDeviceMuted = state.isDeviceMuted; + this.surfaceSize = state.surfaceSize; + this.newlyRenderedFirstFrame = state.newlyRenderedFirstFrame; + this.timedMetadata = state.timedMetadata; + this.playlist = state.playlist; + this.timeline = state.timeline; + this.playlistMetadata = state.playlistMetadata; + this.currentMediaItemIndex = state.currentMediaItemIndex; + this.currentAdGroupIndex = state.currentAdGroupIndex; + this.currentAdIndexInAdGroup = state.currentAdIndexInAdGroup; + this.contentPositionMs = null; + this.contentPositionMsSupplier = state.contentPositionMsSupplier; + this.adPositionMs = null; + this.adPositionMsSupplier = state.adPositionMsSupplier; + this.contentBufferedPositionMsSupplier = state.contentBufferedPositionMsSupplier; + this.adBufferedPositionMsSupplier = state.adBufferedPositionMsSupplier; + this.totalBufferedDurationMsSupplier = state.totalBufferedDurationMsSupplier; + this.hasPositionDiscontinuity = state.hasPositionDiscontinuity; + this.positionDiscontinuityReason = state.positionDiscontinuityReason; + this.discontinuityPositionMs = state.discontinuityPositionMs; } /** @@ -132,6 +258,512 @@ public abstract class SimpleBasePlayer extends BasePlayer { return this; } + /** + * Sets the {@linkplain Player.State state} of the player. + * + *

    If the {@linkplain #setPlaylist playlist} is empty, the state must be either {@link + * Player#STATE_IDLE} or {@link Player#STATE_ENDED}. + * + * @param playbackState The {@linkplain Player.State state} of the player. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlaybackState(@Player.State int playbackState) { + this.playbackState = playbackState; + return this; + } + + /** + * Sets the reason why playback is suppressed even if {@link #getPlayWhenReady()} is true. + * + * @param playbackSuppressionReason The {@link Player.PlaybackSuppressionReason} why playback + * is suppressed even if {@link #getPlayWhenReady()} is true. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlaybackSuppressionReason( + @Player.PlaybackSuppressionReason int playbackSuppressionReason) { + this.playbackSuppressionReason = playbackSuppressionReason; + return this; + } + + /** + * Sets last error that caused playback to fail, or null if there was no error. + * + *

    The {@linkplain #setPlaybackState playback state} must be set to {@link + * Player#STATE_IDLE} while an error is set. + * + * @param playerError The last error that caused playback to fail, or null if there was no + * error. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlayerError(@Nullable PlaybackException playerError) { + this.playerError = playerError; + return this; + } + + /** + * Sets the {@link RepeatMode} used for playback. + * + * @param repeatMode The {@link RepeatMode} used for playback. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setRepeatMode(@Player.RepeatMode int repeatMode) { + this.repeatMode = repeatMode; + return this; + } + + /** + * Sets whether shuffling of media items is enabled. + * + * @param shuffleModeEnabled Whether shuffling of media items is enabled. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setShuffleModeEnabled(boolean shuffleModeEnabled) { + this.shuffleModeEnabled = shuffleModeEnabled; + return this; + } + + /** + * Sets whether the player is currently loading its source. + * + *

    The player can not be marked as loading if the {@linkplain #setPlaybackState state} is + * {@link Player#STATE_IDLE} or {@link Player#STATE_ENDED}. + * + * @param isLoading Whether the player is currently loading its source. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsLoading(boolean isLoading) { + this.isLoading = isLoading; + return this; + } + + /** + * Sets the {@link Player#seekBack()} increment in milliseconds. + * + * @param seekBackIncrementMs The {@link Player#seekBack()} increment in milliseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setSeekBackIncrementMs(long seekBackIncrementMs) { + this.seekBackIncrementMs = seekBackIncrementMs; + return this; + } + + /** + * Sets the {@link Player#seekForward()} increment in milliseconds. + * + * @param seekForwardIncrementMs The {@link Player#seekForward()} increment in milliseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setSeekForwardIncrementMs(long seekForwardIncrementMs) { + this.seekForwardIncrementMs = seekForwardIncrementMs; + return this; + } + + /** + * Sets the maximum position for which {@link #seekToPrevious()} seeks to the previous item, + * in milliseconds. + * + * @param maxSeekToPreviousPositionMs The maximum position for which {@link #seekToPrevious()} + * seeks to the previous item, in milliseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMaxSeekToPreviousPositionMs(long maxSeekToPreviousPositionMs) { + this.maxSeekToPreviousPositionMs = maxSeekToPreviousPositionMs; + return this; + } + + /** + * Sets the currently active {@link PlaybackParameters}. + * + * @param playbackParameters The currently active {@link PlaybackParameters}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlaybackParameters(PlaybackParameters playbackParameters) { + this.playbackParameters = playbackParameters; + return this; + } + + /** + * Sets the currently active {@link TrackSelectionParameters}. + * + * @param trackSelectionParameters The currently active {@link TrackSelectionParameters}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setTrackSelectionParameters( + TrackSelectionParameters trackSelectionParameters) { + this.trackSelectionParameters = trackSelectionParameters; + return this; + } + + /** + * Sets the current {@link AudioAttributes}. + * + * @param audioAttributes The current {@link AudioAttributes}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAudioAttributes(AudioAttributes audioAttributes) { + this.audioAttributes = audioAttributes; + return this; + } + + /** + * Sets the current audio volume, with 0 being silence and 1 being unity gain (signal + * unchanged). + * + * @param volume The current audio volume, with 0 being silence and 1 being unity gain (signal + * unchanged). + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setVolume(@FloatRange(from = 0, to = 1.0) float volume) { + checkArgument(volume >= 0.0f && volume <= 1.0f); + this.volume = volume; + return this; + } + + /** + * Sets the current video size. + * + * @param videoSize The current video size. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setVideoSize(VideoSize videoSize) { + this.videoSize = videoSize; + return this; + } + + /** + * Sets the current {@linkplain CueGroup cues}. + * + * @param currentCues The current {@linkplain CueGroup cues}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setCurrentCues(CueGroup currentCues) { + this.currentCues = currentCues; + return this; + } + + /** + * Sets the {@link DeviceInfo}. + * + * @param deviceInfo The {@link DeviceInfo}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setDeviceInfo(DeviceInfo deviceInfo) { + this.deviceInfo = deviceInfo; + return this; + } + + /** + * Sets the current device volume. + * + * @param deviceVolume The current device volume. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setDeviceVolume(@IntRange(from = 0) int deviceVolume) { + checkArgument(deviceVolume >= 0); + this.deviceVolume = deviceVolume; + return this; + } + + /** + * Sets whether the device is muted. + * + * @param isDeviceMuted Whether the device is muted. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsDeviceMuted(boolean isDeviceMuted) { + this.isDeviceMuted = isDeviceMuted; + return this; + } + + /** + * Sets the size of the surface onto which the video is being rendered. + * + * @param surfaceSize The surface size. Dimensions may be {@link C#LENGTH_UNSET} if unknown, + * or 0 if the video is not rendered onto a surface. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setSurfaceSize(Size surfaceSize) { + this.surfaceSize = surfaceSize; + return this; + } + + /** + * Sets whether a frame has been rendered for the first time since setting the surface, a + * rendering reset, or since the stream being rendered was changed. + * + *

    Note: As this will trigger a {@link Listener#onRenderedFirstFrame()} event, the flag + * should only be set for the first {@link State} update after the first frame was rendered. + * + * @param newlyRenderedFirstFrame Whether the first frame was newly rendered. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setNewlyRenderedFirstFrame(boolean newlyRenderedFirstFrame) { + this.newlyRenderedFirstFrame = newlyRenderedFirstFrame; + return this; + } + + /** + * Sets the most recent timed {@link Metadata}. + * + *

    Metadata with a {@link Metadata#presentationTimeUs} of {@link C#TIME_UNSET} will not be + * forwarded to listeners. + * + * @param timedMetadata The most recent timed {@link Metadata}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setTimedMetadata(Metadata timedMetadata) { + this.timedMetadata = timedMetadata; + return this; + } + + /** + * Sets the list of {@link MediaItemData media items} in the playlist. + * + *

    All items must have unique {@linkplain MediaItemData.Builder#setUid UIDs}. + * + * @param playlist The list of {@link MediaItemData media items} in the playlist. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlaylist(List playlist) { + HashSet uids = new HashSet<>(); + for (int i = 0; i < playlist.size(); i++) { + checkArgument(uids.add(playlist.get(i).uid), "Duplicate MediaItemData UID in playlist"); + } + this.playlist = ImmutableList.copyOf(playlist); + this.timeline = new PlaylistTimeline(this.playlist); + return this; + } + + /** + * Sets the playlist {@link MediaMetadata}. + * + * @param playlistMetadata The playlist {@link MediaMetadata}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPlaylistMetadata(MediaMetadata playlistMetadata) { + this.playlistMetadata = playlistMetadata; + return this; + } + + /** + * Sets the current media item index. + * + *

    The media item index must be less than the number of {@linkplain #setPlaylist media + * items in the playlist}, if set. + * + * @param currentMediaItemIndex The current media item index, or {@link C#INDEX_UNSET} to + * assume the default first item in the playlist. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setCurrentMediaItemIndex(int currentMediaItemIndex) { + this.currentMediaItemIndex = currentMediaItemIndex; + return this; + } + + /** + * Sets the current ad indices, or {@link C#INDEX_UNSET} if no ad is playing. + * + *

    Either both indices need to be {@link C#INDEX_UNSET} or both are not {@link + * C#INDEX_UNSET}. + * + *

    Ads indices can only be set if there is a corresponding {@link AdPlaybackState} defined + * in the current {@linkplain MediaItemData.Builder#setPeriods period}. + * + * @param adGroupIndex The current ad group index, or {@link C#INDEX_UNSET} if no ad is + * playing. + * @param adIndexInAdGroup The current ad index in the ad group, or {@link C#INDEX_UNSET} if + * no ad is playing. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setCurrentAd(int adGroupIndex, int adIndexInAdGroup) { + checkArgument((adGroupIndex == C.INDEX_UNSET) == (adIndexInAdGroup == C.INDEX_UNSET)); + this.currentAdGroupIndex = adGroupIndex; + this.currentAdIndexInAdGroup = adIndexInAdGroup; + return this; + } + + /** + * Sets the current content playback position in milliseconds. + * + *

    This position will be converted to an advancing {@link PositionSupplier} if the overall + * state indicates an advancing playback position. + * + *

    This method overrides any other {@link PositionSupplier} set via {@link + * #setContentPositionMs(PositionSupplier)}. + * + * @param positionMs The current content playback position in milliseconds, or {@link + * C#TIME_UNSET} to indicate the default start position. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setContentPositionMs(long positionMs) { + this.contentPositionMs = positionMs; + return this; + } + + /** + * Sets the {@link PositionSupplier} for the current content playback position in + * milliseconds. + * + *

    The supplier is expected to return the updated position on every call if the playback is + * advancing, for example by using {@link PositionSupplier#getExtrapolating}. + * + *

    This method overrides any other position set via {@link #setContentPositionMs(long)}. + * + * @param contentPositionMsSupplier The {@link PositionSupplier} for the current content + * playback position in milliseconds, or {@link C#TIME_UNSET} to indicate the default + * start position. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setContentPositionMs(PositionSupplier contentPositionMsSupplier) { + this.contentPositionMs = null; + this.contentPositionMsSupplier = contentPositionMsSupplier; + return this; + } + + /** + * Sets the current ad playback position in milliseconds. The value is unused if no ad is + * playing. + * + *

    This position will be converted to an advancing {@link PositionSupplier} if the overall + * state indicates an advancing ad playback position. + * + *

    This method overrides any other {@link PositionSupplier} set via {@link + * #setAdPositionMs(PositionSupplier)}. + * + * @param positionMs The current ad playback position in milliseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAdPositionMs(long positionMs) { + this.adPositionMs = positionMs; + return this; + } + + /** + * Sets the {@link PositionSupplier} for the current ad playback position in milliseconds. The + * value is unused if no ad is playing. + * + *

    The supplier is expected to return the updated position on every call if the playback is + * advancing, for example by using {@link PositionSupplier#getExtrapolating}. + * + *

    This method overrides any other position set via {@link #setAdPositionMs(long)}. + * + * @param adPositionMsSupplier The {@link PositionSupplier} for the current ad playback + * position in milliseconds. The value is unused if no ad is playing. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAdPositionMs(PositionSupplier adPositionMsSupplier) { + this.adPositionMs = null; + this.adPositionMsSupplier = adPositionMsSupplier; + return this; + } + + /** + * Sets the {@link PositionSupplier} for the estimated position up to which the currently + * playing content is buffered, in milliseconds. + * + * @param contentBufferedPositionMsSupplier The {@link PositionSupplier} for the estimated + * position up to which the currently playing content is buffered, in milliseconds, or + * {@link C#TIME_UNSET} to indicate the default start position. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setContentBufferedPositionMs( + PositionSupplier contentBufferedPositionMsSupplier) { + this.contentBufferedPositionMsSupplier = contentBufferedPositionMsSupplier; + return this; + } + + /** + * Sets the {@link PositionSupplier} for the estimated position up to which the currently + * playing ad is buffered, in milliseconds. The value is unused if no ad is playing. + * + * @param adBufferedPositionMsSupplier The {@link PositionSupplier} for the estimated position + * up to which the currently playing ad is buffered, in milliseconds. The value is unused + * if no ad is playing. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAdBufferedPositionMs(PositionSupplier adBufferedPositionMsSupplier) { + this.adBufferedPositionMsSupplier = adBufferedPositionMsSupplier; + return this; + } + + /** + * Sets the {@link PositionSupplier} for the estimated total buffered duration in + * milliseconds. + * + * @param totalBufferedDurationMsSupplier The {@link PositionSupplier} for the estimated total + * buffered duration in milliseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setTotalBufferedDurationMs(PositionSupplier totalBufferedDurationMsSupplier) { + this.totalBufferedDurationMsSupplier = totalBufferedDurationMsSupplier; + return this; + } + + /** + * Signals that a position discontinuity happened since the last player update and sets the + * reason for it. + * + * @param positionDiscontinuityReason The {@linkplain Player.DiscontinuityReason reason} for + * the discontinuity. + * @param discontinuityPositionMs The position, in milliseconds, in the current content or ad + * from which playback continues after the discontinuity. + * @return This builder. + * @see #clearPositionDiscontinuity + */ + @CanIgnoreReturnValue + public Builder setPositionDiscontinuity( + @Player.DiscontinuityReason int positionDiscontinuityReason, + long discontinuityPositionMs) { + this.hasPositionDiscontinuity = true; + this.positionDiscontinuityReason = positionDiscontinuityReason; + this.discontinuityPositionMs = discontinuityPositionMs; + return this; + } + + /** + * Clears a previously set position discontinuity signal. + * + * @return This builder. + * @see #hasPositionDiscontinuity + */ + @CanIgnoreReturnValue + public Builder clearPositionDiscontinuity() { + this.hasPositionDiscontinuity = false; + return this; + } + /** Builds the {@link State}. */ public State build() { return new State(this); @@ -144,11 +776,222 @@ public abstract class SimpleBasePlayer extends BasePlayer { public final boolean playWhenReady; /** The last reason for changing {@link #playWhenReady}. */ public final @PlayWhenReadyChangeReason int playWhenReadyChangeReason; + /** The {@linkplain Player.State state} of the player. */ + public final @Player.State int playbackState; + /** The reason why playback is suppressed even if {@link #getPlayWhenReady()} is true. */ + public final @PlaybackSuppressionReason int playbackSuppressionReason; + /** The last error that caused playback to fail, or null if there was no error. */ + @Nullable public final PlaybackException playerError; + /** The {@link RepeatMode} used for playback. */ + public final @RepeatMode int repeatMode; + /** Whether shuffling of media items is enabled. */ + public final boolean shuffleModeEnabled; + /** Whether the player is currently loading its source. */ + public final boolean isLoading; + /** The {@link Player#seekBack()} increment in milliseconds. */ + public final long seekBackIncrementMs; + /** The {@link Player#seekForward()} increment in milliseconds. */ + public final long seekForwardIncrementMs; + /** + * The maximum position for which {@link #seekToPrevious()} seeks to the previous item, in + * milliseconds. + */ + public final long maxSeekToPreviousPositionMs; + /** The currently active {@link PlaybackParameters}. */ + public final PlaybackParameters playbackParameters; + /** The currently active {@link TrackSelectionParameters}. */ + public final TrackSelectionParameters trackSelectionParameters; + /** The current {@link AudioAttributes}. */ + public final AudioAttributes audioAttributes; + /** The current audio volume, with 0 being silence and 1 being unity gain (signal unchanged). */ + @FloatRange(from = 0, to = 1.0) + public final float volume; + /** The current video size. */ + public final VideoSize videoSize; + /** The current {@linkplain CueGroup cues}. */ + public final CueGroup currentCues; + /** The {@link DeviceInfo}. */ + public final DeviceInfo deviceInfo; + /** The current device volume. */ + @IntRange(from = 0) + public final int deviceVolume; + /** Whether the device is muted. */ + public final boolean isDeviceMuted; + /** The size of the surface onto which the video is being rendered. */ + public final Size surfaceSize; + /** + * Whether a frame has been rendered for the first time since setting the surface, a rendering + * reset, or since the stream being rendered was changed. + */ + public final boolean newlyRenderedFirstFrame; + /** The most recent timed metadata. */ + public final Metadata timedMetadata; + /** The media items in the playlist. */ + public final ImmutableList playlist; + /** The {@link Timeline} derived from the {@link #playlist}. */ + public final Timeline timeline; + /** The playlist {@link MediaMetadata}. */ + public final MediaMetadata playlistMetadata; + /** + * The current media item index, or {@link C#INDEX_UNSET} to assume the default first item of + * the playlist is played. + */ + public final int currentMediaItemIndex; + /** The current ad group index, or {@link C#INDEX_UNSET} if no ad is playing. */ + public final int currentAdGroupIndex; + /** The current ad index in the ad group, or {@link C#INDEX_UNSET} if no ad is playing. */ + public final int currentAdIndexInAdGroup; + /** + * The {@link PositionSupplier} for the current content playback position in milliseconds, or + * {@link C#TIME_UNSET} to indicate the default start position. + */ + public final PositionSupplier contentPositionMsSupplier; + /** + * The {@link PositionSupplier} for the current ad playback position in milliseconds. The value + * is unused if no ad is playing. + */ + public final PositionSupplier adPositionMsSupplier; + /** + * The {@link PositionSupplier} for the estimated position up to which the currently playing + * content is buffered, in milliseconds, or {@link C#TIME_UNSET} to indicate the default start + * position. + */ + public final PositionSupplier contentBufferedPositionMsSupplier; + /** + * The {@link PositionSupplier} for the estimated position up to which the currently playing ad + * is buffered, in milliseconds. The value is unused if no ad is playing. + */ + public final PositionSupplier adBufferedPositionMsSupplier; + /** The {@link PositionSupplier} for the estimated total buffered duration in milliseconds. */ + public final PositionSupplier totalBufferedDurationMsSupplier; + /** Signals that a position discontinuity happened since the last update to the player. */ + public final boolean hasPositionDiscontinuity; + /** + * The {@linkplain Player.DiscontinuityReason reason} for the last position discontinuity. The + * value is unused if {@link #hasPositionDiscontinuity} is {@code false}. + */ + public final @Player.DiscontinuityReason int positionDiscontinuityReason; + /** + * The position, in milliseconds, in the current content or ad from which playback continued + * after the discontinuity. The value is unused if {@link #hasPositionDiscontinuity} is {@code + * false}. + */ + public final long discontinuityPositionMs; private State(Builder builder) { + if (builder.timeline.isEmpty()) { + checkArgument( + builder.playbackState == Player.STATE_IDLE + || builder.playbackState == Player.STATE_ENDED, + "Empty playlist only allowed in STATE_IDLE or STATE_ENDED"); + checkArgument( + builder.currentAdGroupIndex == C.INDEX_UNSET + && builder.currentAdIndexInAdGroup == C.INDEX_UNSET, + "Ads not allowed if playlist is empty"); + } else { + int mediaItemIndex = builder.currentMediaItemIndex; + if (mediaItemIndex == C.INDEX_UNSET) { + mediaItemIndex = 0; // TODO: Use shuffle order to find first index. + } else { + checkArgument( + builder.currentMediaItemIndex < builder.timeline.getWindowCount(), + "currentMediaItemIndex must be less than playlist.size()"); + } + if (builder.currentAdGroupIndex != C.INDEX_UNSET) { + Timeline.Period period = new Timeline.Period(); + Timeline.Window window = new Timeline.Window(); + long contentPositionMs = + builder.contentPositionMs != null + ? builder.contentPositionMs + : builder.contentPositionMsSupplier.get(); + int periodIndex = + getPeriodIndexFromWindowPosition( + builder.timeline, mediaItemIndex, contentPositionMs, window, period); + builder.timeline.getPeriod(periodIndex, period); + checkArgument( + builder.currentAdGroupIndex < period.getAdGroupCount(), + "PeriodData has less ad groups than adGroupIndex"); + int adCountInGroup = period.getAdCountInAdGroup(builder.currentAdGroupIndex); + if (adCountInGroup != C.LENGTH_UNSET) { + checkArgument( + builder.currentAdIndexInAdGroup < adCountInGroup, + "Ad group has less ads than adIndexInGroupIndex"); + } + } + } + if (builder.playerError != null) { + checkArgument( + builder.playbackState == Player.STATE_IDLE, "Player error only allowed in STATE_IDLE"); + } + if (builder.playbackState == Player.STATE_IDLE + || builder.playbackState == Player.STATE_ENDED) { + checkArgument( + !builder.isLoading, "isLoading only allowed when not in STATE_IDLE or STATE_ENDED"); + } + PositionSupplier contentPositionMsSupplier = builder.contentPositionMsSupplier; + if (builder.contentPositionMs != null) { + if (builder.currentAdGroupIndex == C.INDEX_UNSET + && builder.playWhenReady + && builder.playbackState == Player.STATE_READY + && builder.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE + && builder.contentPositionMs != C.TIME_UNSET) { + contentPositionMsSupplier = + PositionSupplier.getExtrapolating( + builder.contentPositionMs, builder.playbackParameters.speed); + } else { + contentPositionMsSupplier = PositionSupplier.getConstant(builder.contentPositionMs); + } + } + PositionSupplier adPositionMsSupplier = builder.adPositionMsSupplier; + if (builder.adPositionMs != null) { + if (builder.currentAdGroupIndex != C.INDEX_UNSET + && builder.playWhenReady + && builder.playbackState == Player.STATE_READY + && builder.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) { + adPositionMsSupplier = + PositionSupplier.getExtrapolating(builder.adPositionMs, /* playbackSpeed= */ 1f); + } else { + adPositionMsSupplier = PositionSupplier.getConstant(builder.adPositionMs); + } + } this.availableCommands = builder.availableCommands; this.playWhenReady = builder.playWhenReady; this.playWhenReadyChangeReason = builder.playWhenReadyChangeReason; + this.playbackState = builder.playbackState; + this.playbackSuppressionReason = builder.playbackSuppressionReason; + this.playerError = builder.playerError; + this.repeatMode = builder.repeatMode; + this.shuffleModeEnabled = builder.shuffleModeEnabled; + this.isLoading = builder.isLoading; + this.seekBackIncrementMs = builder.seekBackIncrementMs; + this.seekForwardIncrementMs = builder.seekForwardIncrementMs; + this.maxSeekToPreviousPositionMs = builder.maxSeekToPreviousPositionMs; + this.playbackParameters = builder.playbackParameters; + this.trackSelectionParameters = builder.trackSelectionParameters; + this.audioAttributes = builder.audioAttributes; + this.volume = builder.volume; + this.videoSize = builder.videoSize; + this.currentCues = builder.currentCues; + this.deviceInfo = builder.deviceInfo; + this.deviceVolume = builder.deviceVolume; + this.isDeviceMuted = builder.isDeviceMuted; + this.surfaceSize = builder.surfaceSize; + this.newlyRenderedFirstFrame = builder.newlyRenderedFirstFrame; + this.timedMetadata = builder.timedMetadata; + this.playlist = builder.playlist; + this.timeline = builder.timeline; + this.playlistMetadata = builder.playlistMetadata; + this.currentMediaItemIndex = builder.currentMediaItemIndex; + this.currentAdGroupIndex = builder.currentAdGroupIndex; + this.currentAdIndexInAdGroup = builder.currentAdIndexInAdGroup; + this.contentPositionMsSupplier = contentPositionMsSupplier; + this.adPositionMsSupplier = adPositionMsSupplier; + this.contentBufferedPositionMsSupplier = builder.contentBufferedPositionMsSupplier; + this.adBufferedPositionMsSupplier = builder.adBufferedPositionMsSupplier; + this.totalBufferedDurationMsSupplier = builder.totalBufferedDurationMsSupplier; + this.hasPositionDiscontinuity = builder.hasPositionDiscontinuity; + this.positionDiscontinuityReason = builder.positionDiscontinuityReason; + this.discontinuityPositionMs = builder.discontinuityPositionMs; } /** Returns a {@link Builder} pre-populated with the current state values. */ @@ -167,7 +1010,41 @@ public abstract class SimpleBasePlayer extends BasePlayer { State state = (State) o; return playWhenReady == state.playWhenReady && playWhenReadyChangeReason == state.playWhenReadyChangeReason - && availableCommands.equals(state.availableCommands); + && availableCommands.equals(state.availableCommands) + && playbackState == state.playbackState + && playbackSuppressionReason == state.playbackSuppressionReason + && Util.areEqual(playerError, state.playerError) + && repeatMode == state.repeatMode + && shuffleModeEnabled == state.shuffleModeEnabled + && isLoading == state.isLoading + && seekBackIncrementMs == state.seekBackIncrementMs + && seekForwardIncrementMs == state.seekForwardIncrementMs + && maxSeekToPreviousPositionMs == state.maxSeekToPreviousPositionMs + && playbackParameters.equals(state.playbackParameters) + && trackSelectionParameters.equals(state.trackSelectionParameters) + && audioAttributes.equals(state.audioAttributes) + && volume == state.volume + && videoSize.equals(state.videoSize) + && currentCues.equals(state.currentCues) + && deviceInfo.equals(state.deviceInfo) + && deviceVolume == state.deviceVolume + && isDeviceMuted == state.isDeviceMuted + && surfaceSize.equals(state.surfaceSize) + && newlyRenderedFirstFrame == state.newlyRenderedFirstFrame + && timedMetadata.equals(state.timedMetadata) + && playlist.equals(state.playlist) + && playlistMetadata.equals(state.playlistMetadata) + && currentMediaItemIndex == state.currentMediaItemIndex + && currentAdGroupIndex == state.currentAdGroupIndex + && currentAdIndexInAdGroup == state.currentAdIndexInAdGroup + && contentPositionMsSupplier.equals(state.contentPositionMsSupplier) + && adPositionMsSupplier.equals(state.adPositionMsSupplier) + && contentBufferedPositionMsSupplier.equals(state.contentBufferedPositionMsSupplier) + && adBufferedPositionMsSupplier.equals(state.adBufferedPositionMsSupplier) + && totalBufferedDurationMsSupplier.equals(state.totalBufferedDurationMsSupplier) + && hasPositionDiscontinuity == state.hasPositionDiscontinuity + && positionDiscontinuityReason == state.positionDiscontinuityReason + && discontinuityPositionMs == state.discontinuityPositionMs; } @Override @@ -176,16 +1053,915 @@ public abstract class SimpleBasePlayer extends BasePlayer { result = 31 * result + availableCommands.hashCode(); result = 31 * result + (playWhenReady ? 1 : 0); result = 31 * result + playWhenReadyChangeReason; + result = 31 * result + playbackState; + result = 31 * result + playbackSuppressionReason; + result = 31 * result + (playerError == null ? 0 : playerError.hashCode()); + result = 31 * result + repeatMode; + result = 31 * result + (shuffleModeEnabled ? 1 : 0); + result = 31 * result + (isLoading ? 1 : 0); + result = 31 * result + (int) (seekBackIncrementMs ^ (seekBackIncrementMs >>> 32)); + result = 31 * result + (int) (seekForwardIncrementMs ^ (seekForwardIncrementMs >>> 32)); + result = + 31 * result + (int) (maxSeekToPreviousPositionMs ^ (maxSeekToPreviousPositionMs >>> 32)); + result = 31 * result + playbackParameters.hashCode(); + result = 31 * result + trackSelectionParameters.hashCode(); + result = 31 * result + audioAttributes.hashCode(); + result = 31 * result + Float.floatToRawIntBits(volume); + result = 31 * result + videoSize.hashCode(); + result = 31 * result + currentCues.hashCode(); + result = 31 * result + deviceInfo.hashCode(); + result = 31 * result + deviceVolume; + result = 31 * result + (isDeviceMuted ? 1 : 0); + result = 31 * result + surfaceSize.hashCode(); + result = 31 * result + (newlyRenderedFirstFrame ? 1 : 0); + result = 31 * result + timedMetadata.hashCode(); + result = 31 * result + playlist.hashCode(); + result = 31 * result + playlistMetadata.hashCode(); + result = 31 * result + currentMediaItemIndex; + result = 31 * result + currentAdGroupIndex; + result = 31 * result + currentAdIndexInAdGroup; + result = 31 * result + contentPositionMsSupplier.hashCode(); + result = 31 * result + adPositionMsSupplier.hashCode(); + result = 31 * result + contentBufferedPositionMsSupplier.hashCode(); + result = 31 * result + adBufferedPositionMsSupplier.hashCode(); + result = 31 * result + totalBufferedDurationMsSupplier.hashCode(); + result = 31 * result + (hasPositionDiscontinuity ? 1 : 0); + result = 31 * result + positionDiscontinuityReason; + result = 31 * result + (int) (discontinuityPositionMs ^ (discontinuityPositionMs >>> 32)); return result; } } + private static final class PlaylistTimeline extends Timeline { + + private final ImmutableList playlist; + private final int[] firstPeriodIndexByWindowIndex; + private final int[] windowIndexByPeriodIndex; + private final HashMap periodIndexByUid; + + public PlaylistTimeline(ImmutableList playlist) { + int mediaItemCount = playlist.size(); + this.playlist = playlist; + this.firstPeriodIndexByWindowIndex = new int[mediaItemCount]; + int periodCount = 0; + for (int i = 0; i < mediaItemCount; i++) { + MediaItemData mediaItemData = playlist.get(i); + firstPeriodIndexByWindowIndex[i] = periodCount; + periodCount += getPeriodCountInMediaItem(mediaItemData); + } + this.windowIndexByPeriodIndex = new int[periodCount]; + this.periodIndexByUid = new HashMap<>(); + int periodIndex = 0; + for (int i = 0; i < mediaItemCount; i++) { + MediaItemData mediaItemData = playlist.get(i); + for (int j = 0; j < getPeriodCountInMediaItem(mediaItemData); j++) { + periodIndexByUid.put(mediaItemData.getPeriodUid(j), periodIndex); + windowIndexByPeriodIndex[periodIndex] = i; + periodIndex++; + } + } + } + + @Override + public int getWindowCount() { + return playlist.size(); + } + + @Override + public int getNextWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + // TODO: Support shuffle order. + return super.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getPreviousWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + // TODO: Support shuffle order. + return super.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + // TODO: Support shuffle order. + return super.getLastWindowIndex(shuffleModeEnabled); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + // TODO: Support shuffle order. + return super.getFirstWindowIndex(shuffleModeEnabled); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + return playlist + .get(windowIndex) + .getWindow(firstPeriodIndexByWindowIndex[windowIndex], window); + } + + @Override + public int getPeriodCount() { + return windowIndexByPeriodIndex.length; + } + + @Override + public Period getPeriodByUid(Object periodUid, Period period) { + int periodIndex = checkNotNull(periodIndexByUid.get(periodUid)); + return getPeriod(periodIndex, period, /* setIds= */ true); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + int windowIndex = windowIndexByPeriodIndex[periodIndex]; + int periodIndexInWindow = periodIndex - firstPeriodIndexByWindowIndex[windowIndex]; + return playlist.get(windowIndex).getPeriod(windowIndex, periodIndexInWindow, period); + } + + @Override + public int getIndexOfPeriod(Object uid) { + @Nullable Integer index = periodIndexByUid.get(uid); + return index == null ? C.INDEX_UNSET : index; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + int windowIndex = windowIndexByPeriodIndex[periodIndex]; + int periodIndexInWindow = periodIndex - firstPeriodIndexByWindowIndex[windowIndex]; + return playlist.get(windowIndex).getPeriodUid(periodIndexInWindow); + } + + private static int getPeriodCountInMediaItem(MediaItemData mediaItemData) { + return mediaItemData.periods.isEmpty() ? 1 : mediaItemData.periods.size(); + } + } + + /** + * An immutable description of an item in the playlist, containing both static setup information + * like {@link MediaItem} and dynamic data that is generally read from the media like the + * duration. + */ + protected static final class MediaItemData { + + /** A builder for {@link MediaItemData} objects. */ + public static final class Builder { + + private Object uid; + private Tracks tracks; + private MediaItem mediaItem; + @Nullable private MediaMetadata mediaMetadata; + @Nullable private Object manifest; + @Nullable private MediaItem.LiveConfiguration liveConfiguration; + private long presentationStartTimeMs; + private long windowStartTimeMs; + private long elapsedRealtimeEpochOffsetMs; + private boolean isSeekable; + private boolean isDynamic; + private long defaultPositionUs; + private long durationUs; + private long positionInFirstPeriodUs; + private boolean isPlaceholder; + private ImmutableList periods; + + /** + * Creates the builder. + * + * @param uid The unique identifier of the media item within a playlist. This value will be + * set as {@link Timeline.Window#uid} for this item. + */ + public Builder(Object uid) { + this.uid = uid; + tracks = Tracks.EMPTY; + mediaItem = MediaItem.EMPTY; + mediaMetadata = null; + manifest = null; + liveConfiguration = null; + presentationStartTimeMs = C.TIME_UNSET; + windowStartTimeMs = C.TIME_UNSET; + elapsedRealtimeEpochOffsetMs = C.TIME_UNSET; + isSeekable = false; + isDynamic = false; + defaultPositionUs = 0; + durationUs = C.TIME_UNSET; + positionInFirstPeriodUs = 0; + isPlaceholder = false; + periods = ImmutableList.of(); + } + + private Builder(MediaItemData mediaItemData) { + this.uid = mediaItemData.uid; + this.tracks = mediaItemData.tracks; + this.mediaItem = mediaItemData.mediaItem; + this.mediaMetadata = mediaItemData.mediaMetadata; + this.manifest = mediaItemData.manifest; + this.liveConfiguration = mediaItemData.liveConfiguration; + this.presentationStartTimeMs = mediaItemData.presentationStartTimeMs; + this.windowStartTimeMs = mediaItemData.windowStartTimeMs; + this.elapsedRealtimeEpochOffsetMs = mediaItemData.elapsedRealtimeEpochOffsetMs; + this.isSeekable = mediaItemData.isSeekable; + this.isDynamic = mediaItemData.isDynamic; + this.defaultPositionUs = mediaItemData.defaultPositionUs; + this.durationUs = mediaItemData.durationUs; + this.positionInFirstPeriodUs = mediaItemData.positionInFirstPeriodUs; + this.isPlaceholder = mediaItemData.isPlaceholder; + this.periods = mediaItemData.periods; + } + + /** + * Sets the unique identifier of this media item within a playlist. + * + *

    This value will be set as {@link Timeline.Window#uid} for this item. + * + * @param uid The unique identifier of this media item within a playlist. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setUid(Object uid) { + this.uid = uid; + return this; + } + + /** + * Sets the {@link Tracks} of this media item. + * + * @param tracks The {@link Tracks} of this media item. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setTracks(Tracks tracks) { + this.tracks = tracks; + return this; + } + + /** + * Sets the {@link MediaItem}. + * + * @param mediaItem The {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + return this; + } + + /** + * Sets the {@link MediaMetadata}. + * + *

    This data includes static data from the {@link MediaItem#mediaMetadata MediaItem} and + * the media's {@link Format#metadata Format}, as well any dynamic metadata that has been + * parsed from the media. If null, the metadata is assumed to be the simple combination of the + * {@link MediaItem#mediaMetadata MediaItem} metadata and the metadata of the selected {@link + * Format#metadata Formats}. + * + * @param mediaMetadata The {@link MediaMetadata}, or null to assume that the metadata is the + * simple combination of the {@link MediaItem#mediaMetadata MediaItem} metadata and the + * metadata of the selected {@link Format#metadata Formats}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaMetadata(@Nullable MediaMetadata mediaMetadata) { + this.mediaMetadata = mediaMetadata; + return this; + } + + /** + * Sets the manifest of the media item. + * + * @param manifest The manifest of the media item, or null if not applicable. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setManifest(@Nullable Object manifest) { + this.manifest = manifest; + return this; + } + + /** + * Sets the active {@link MediaItem.LiveConfiguration}, or null if the media item is not live. + * + * @param liveConfiguration The active {@link MediaItem.LiveConfiguration}, or null if the + * media item is not live. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setLiveConfiguration(@Nullable MediaItem.LiveConfiguration liveConfiguration) { + this.liveConfiguration = liveConfiguration; + return this; + } + + /** + * Sets the start time of the live presentation. + * + *

    This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is + * {@linkplain #setLiveConfiguration live}. + * + * @param presentationStartTimeMs The start time of the live presentation, in milliseconds + * since the Unix epoch, or {@link C#TIME_UNSET} if unknown or not applicable. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPresentationStartTimeMs(long presentationStartTimeMs) { + this.presentationStartTimeMs = presentationStartTimeMs; + return this; + } + + /** + * Sets the start time of the live window. + * + *

    This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is + * {@linkplain #setLiveConfiguration live}. The value should also be greater or equal than the + * {@linkplain #setPresentationStartTimeMs presentation start time}, if set. + * + * @param windowStartTimeMs The start time of the live window, in milliseconds since the Unix + * epoch, or {@link C#TIME_UNSET} if unknown or not applicable. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setWindowStartTimeMs(long windowStartTimeMs) { + this.windowStartTimeMs = windowStartTimeMs; + return this; + } + + /** + * Sets the offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix + * epoch according to the clock of the media origin server. + * + *

    This value can only be set to anything other than {@link C#TIME_UNSET} if the stream is + * {@linkplain #setLiveConfiguration live}. + * + * @param elapsedRealtimeEpochOffsetMs The offset between {@link + * SystemClock#elapsedRealtime()} and the time since the Unix epoch according to the clock + * of the media origin server, or {@link C#TIME_UNSET} if unknown or not applicable. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setElapsedRealtimeEpochOffsetMs(long elapsedRealtimeEpochOffsetMs) { + this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; + return this; + } + + /** + * Sets whether it's possible to seek within this media item. + * + * @param isSeekable Whether it's possible to seek within this media item. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsSeekable(boolean isSeekable) { + this.isSeekable = isSeekable; + return this; + } + + /** + * Sets whether this media item may change over time, for example a moving live window. + * + * @param isDynamic Whether this media item may change over time, for example a moving live + * window. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsDynamic(boolean isDynamic) { + this.isDynamic = isDynamic; + return this; + } + + /** + * Sets the default position relative to the start of the media item at which to begin + * playback, in microseconds. + * + *

    The default position must be less or equal to the {@linkplain #setDurationUs duration}, + * is set. + * + * @param defaultPositionUs The default position relative to the start of the media item at + * which to begin playback, in microseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setDefaultPositionUs(long defaultPositionUs) { + checkArgument(defaultPositionUs >= 0); + this.defaultPositionUs = defaultPositionUs; + return this; + } + + /** + * Sets the duration of the media item, in microseconds. + * + *

    If both this duration and all {@linkplain #setPeriods period} durations are set, the sum + * of this duration and the {@linkplain #setPositionInFirstPeriodUs offset in the first + * period} must match the total duration of all periods. + * + * @param durationUs The duration of the media item, in microseconds, or {@link C#TIME_UNSET} + * if unknown. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setDurationUs(long durationUs) { + checkArgument(durationUs == C.TIME_UNSET || durationUs >= 0); + this.durationUs = durationUs; + return this; + } + + /** + * Sets the position of the start of this media item relative to the start of the first period + * belonging to it, in microseconds. + * + * @param positionInFirstPeriodUs The position of the start of this media item relative to the + * start of the first period belonging to it, in microseconds. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPositionInFirstPeriodUs(long positionInFirstPeriodUs) { + checkArgument(positionInFirstPeriodUs >= 0); + this.positionInFirstPeriodUs = positionInFirstPeriodUs; + return this; + } + + /** + * Sets whether this media item contains placeholder information because the real information + * has yet to be loaded. + * + * @param isPlaceholder Whether this media item contains placeholder information because the + * real information has yet to be loaded. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsPlaceholder(boolean isPlaceholder) { + this.isPlaceholder = isPlaceholder; + return this; + } + + /** + * Sets the list of {@linkplain PeriodData periods} in this media item. + * + *

    All periods must have unique {@linkplain PeriodData.Builder#setUid UIDs} and only the + * last period is allowed to have an unset {@linkplain PeriodData.Builder#setDurationUs + * duration}. + * + * @param periods The list of {@linkplain PeriodData periods} in this media item, or an empty + * list to assume a single period without ads and the same duration as the media item. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setPeriods(List periods) { + int periodCount = periods.size(); + for (int i = 0; i < periodCount - 1; i++) { + checkArgument( + periods.get(i).durationUs != C.TIME_UNSET, "Periods other than last need a duration"); + for (int j = i + 1; j < periodCount; j++) { + checkArgument( + !periods.get(i).uid.equals(periods.get(j).uid), + "Duplicate PeriodData UIDs in period list"); + } + } + this.periods = ImmutableList.copyOf(periods); + return this; + } + + /** Builds the {@link MediaItemData}. */ + public MediaItemData build() { + return new MediaItemData(this); + } + } + + /** The unique identifier of this media item. */ + public final Object uid; + /** The {@link Tracks} of this media item. */ + public final Tracks tracks; + /** The {@link MediaItem}. */ + public final MediaItem mediaItem; + /** + * The {@link MediaMetadata}, including static data from the {@link MediaItem#mediaMetadata + * MediaItem} and the media's {@link Format#metadata Format}, as well any dynamic metadata that + * has been parsed from the media. If null, the metadata is assumed to be the simple combination + * of the {@link MediaItem#mediaMetadata MediaItem} metadata and the metadata of the selected + * {@link Format#metadata Formats}. + */ + @Nullable public final MediaMetadata mediaMetadata; + /** The manifest of the media item, or null if not applicable. */ + @Nullable public final Object manifest; + /** The active {@link MediaItem.LiveConfiguration}, or null if the media item is not live. */ + @Nullable public final MediaItem.LiveConfiguration liveConfiguration; + /** + * The start time of the live presentation, in milliseconds since the Unix epoch, or {@link + * C#TIME_UNSET} if unknown or not applicable. + */ + public final long presentationStartTimeMs; + /** + * The start time of the live window, in milliseconds since the Unix epoch, or {@link + * C#TIME_UNSET} if unknown or not applicable. + */ + public final long windowStartTimeMs; + /** + * The offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix epoch + * according to the clock of the media origin server, or {@link C#TIME_UNSET} if unknown or not + * applicable. + */ + public final long elapsedRealtimeEpochOffsetMs; + /** Whether it's possible to seek within this media item. */ + public final boolean isSeekable; + /** Whether this media item may change over time, for example a moving live window. */ + public final boolean isDynamic; + /** + * The default position relative to the start of the media item at which to begin playback, in + * microseconds. + */ + public final long defaultPositionUs; + /** The duration of the media item, in microseconds, or {@link C#TIME_UNSET} if unknown. */ + public final long durationUs; + /** + * The position of the start of this media item relative to the start of the first period + * belonging to it, in microseconds. + */ + public final long positionInFirstPeriodUs; + /** + * Whether this media item contains placeholder information because the real information has yet + * to be loaded. + */ + public final boolean isPlaceholder; + /** + * The list of {@linkplain PeriodData periods} in this media item, or an empty list to assume a + * single period without ads and the same duration as the media item. + */ + public final ImmutableList periods; + + private final long[] periodPositionInWindowUs; + private final MediaMetadata combinedMediaMetadata; + + private MediaItemData(Builder builder) { + if (builder.liveConfiguration == null) { + checkArgument( + builder.presentationStartTimeMs == C.TIME_UNSET, + "presentationStartTimeMs can only be set if liveConfiguration != null"); + checkArgument( + builder.windowStartTimeMs == C.TIME_UNSET, + "windowStartTimeMs can only be set if liveConfiguration != null"); + checkArgument( + builder.elapsedRealtimeEpochOffsetMs == C.TIME_UNSET, + "elapsedRealtimeEpochOffsetMs can only be set if liveConfiguration != null"); + } else if (builder.presentationStartTimeMs != C.TIME_UNSET + && builder.windowStartTimeMs != C.TIME_UNSET) { + checkArgument( + builder.windowStartTimeMs >= builder.presentationStartTimeMs, + "windowStartTimeMs can't be less than presentationStartTimeMs"); + } + int periodCount = builder.periods.size(); + if (builder.durationUs != C.TIME_UNSET) { + checkArgument( + builder.defaultPositionUs <= builder.durationUs, + "defaultPositionUs can't be greater than durationUs"); + } + this.uid = builder.uid; + this.tracks = builder.tracks; + this.mediaItem = builder.mediaItem; + this.mediaMetadata = builder.mediaMetadata; + this.manifest = builder.manifest; + this.liveConfiguration = builder.liveConfiguration; + this.presentationStartTimeMs = builder.presentationStartTimeMs; + this.windowStartTimeMs = builder.windowStartTimeMs; + this.elapsedRealtimeEpochOffsetMs = builder.elapsedRealtimeEpochOffsetMs; + this.isSeekable = builder.isSeekable; + this.isDynamic = builder.isDynamic; + this.defaultPositionUs = builder.defaultPositionUs; + this.durationUs = builder.durationUs; + this.positionInFirstPeriodUs = builder.positionInFirstPeriodUs; + this.isPlaceholder = builder.isPlaceholder; + this.periods = builder.periods; + periodPositionInWindowUs = new long[periods.size()]; + if (!periods.isEmpty()) { + periodPositionInWindowUs[0] = -positionInFirstPeriodUs; + for (int i = 0; i < periodCount - 1; i++) { + periodPositionInWindowUs[i + 1] = periodPositionInWindowUs[i] + periods.get(i).durationUs; + } + } + combinedMediaMetadata = + mediaMetadata != null ? mediaMetadata : getCombinedMediaMetadata(mediaItem, tracks); + } + + /** Returns a {@link Builder} pre-populated with the current values. */ + public Builder buildUpon() { + return new Builder(this); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MediaItemData)) { + return false; + } + MediaItemData mediaItemData = (MediaItemData) o; + return this.uid.equals(mediaItemData.uid) + && this.tracks.equals(mediaItemData.tracks) + && this.mediaItem.equals(mediaItemData.mediaItem) + && Util.areEqual(this.mediaMetadata, mediaItemData.mediaMetadata) + && Util.areEqual(this.manifest, mediaItemData.manifest) + && Util.areEqual(this.liveConfiguration, mediaItemData.liveConfiguration) + && this.presentationStartTimeMs == mediaItemData.presentationStartTimeMs + && this.windowStartTimeMs == mediaItemData.windowStartTimeMs + && this.elapsedRealtimeEpochOffsetMs == mediaItemData.elapsedRealtimeEpochOffsetMs + && this.isSeekable == mediaItemData.isSeekable + && this.isDynamic == mediaItemData.isDynamic + && this.defaultPositionUs == mediaItemData.defaultPositionUs + && this.durationUs == mediaItemData.durationUs + && this.positionInFirstPeriodUs == mediaItemData.positionInFirstPeriodUs + && this.isPlaceholder == mediaItemData.isPlaceholder + && this.periods.equals(mediaItemData.periods); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + uid.hashCode(); + result = 31 * result + tracks.hashCode(); + result = 31 * result + mediaItem.hashCode(); + result = 31 * result + (mediaMetadata == null ? 0 : mediaMetadata.hashCode()); + result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); + result = 31 * result + (liveConfiguration == null ? 0 : liveConfiguration.hashCode()); + result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); + result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); + result = + 31 * result + + (int) (elapsedRealtimeEpochOffsetMs ^ (elapsedRealtimeEpochOffsetMs >>> 32)); + result = 31 * result + (isSeekable ? 1 : 0); + result = 31 * result + (isDynamic ? 1 : 0); + result = 31 * result + (int) (defaultPositionUs ^ (defaultPositionUs >>> 32)); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32)); + result = 31 * result + (isPlaceholder ? 1 : 0); + result = 31 * result + periods.hashCode(); + return result; + } + + private Timeline.Window getWindow(int firstPeriodIndex, Timeline.Window window) { + int periodCount = periods.isEmpty() ? 1 : periods.size(); + window.set( + uid, + mediaItem, + manifest, + presentationStartTimeMs, + windowStartTimeMs, + elapsedRealtimeEpochOffsetMs, + isSeekable, + isDynamic, + liveConfiguration, + defaultPositionUs, + durationUs, + firstPeriodIndex, + /* lastPeriodIndex= */ firstPeriodIndex + periodCount - 1, + positionInFirstPeriodUs); + window.isPlaceholder = isPlaceholder; + return window; + } + + private Timeline.Period getPeriod( + int windowIndex, int periodIndexInMediaItem, Timeline.Period period) { + if (periods.isEmpty()) { + period.set( + /* id= */ uid, + uid, + windowIndex, + /* durationUs= */ positionInFirstPeriodUs + durationUs, + /* positionInWindowUs= */ 0, + AdPlaybackState.NONE, + isPlaceholder); + } else { + PeriodData periodData = periods.get(periodIndexInMediaItem); + Object periodId = periodData.uid; + Object periodUid = Pair.create(uid, periodId); + period.set( + periodId, + periodUid, + windowIndex, + periodData.durationUs, + periodPositionInWindowUs[periodIndexInMediaItem], + periodData.adPlaybackState, + periodData.isPlaceholder); + } + return period; + } + + private Object getPeriodUid(int periodIndexInMediaItem) { + if (periods.isEmpty()) { + return uid; + } + Object periodId = periods.get(periodIndexInMediaItem).uid; + return Pair.create(uid, periodId); + } + + private static MediaMetadata getCombinedMediaMetadata(MediaItem mediaItem, Tracks tracks) { + MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder(); + int trackGroupCount = tracks.getGroups().size(); + for (int i = 0; i < trackGroupCount; i++) { + Tracks.Group group = tracks.getGroups().get(i); + for (int j = 0; j < group.length; j++) { + if (group.isTrackSelected(j)) { + Format format = group.getTrackFormat(j); + if (format.metadata != null) { + for (int k = 0; k < format.metadata.length(); k++) { + format.metadata.get(k).populateMediaMetadata(metadataBuilder); + } + } + } + } + } + return metadataBuilder.populate(mediaItem.mediaMetadata).build(); + } + } + + /** Data describing the properties of a period inside a {@link MediaItemData}. */ + protected static final class PeriodData { + + /** A builder for {@link PeriodData} objects. */ + public static final class Builder { + + private Object uid; + private long durationUs; + private AdPlaybackState adPlaybackState; + private boolean isPlaceholder; + + /** + * Creates the builder. + * + * @param uid The unique identifier of the period within its media item. + */ + public Builder(Object uid) { + this.uid = uid; + this.durationUs = 0; + this.adPlaybackState = AdPlaybackState.NONE; + this.isPlaceholder = false; + } + + private Builder(PeriodData periodData) { + this.uid = periodData.uid; + this.durationUs = periodData.durationUs; + this.adPlaybackState = periodData.adPlaybackState; + this.isPlaceholder = periodData.isPlaceholder; + } + + /** + * Sets the unique identifier of the period within its media item. + * + * @param uid The unique identifier of the period within its media item. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setUid(Object uid) { + this.uid = uid; + return this; + } + + /** + * Sets the total duration of the period, in microseconds, or {@link C#TIME_UNSET} if unknown. + * + *

    Only the last period in a media item can have an unknown duration. + * + * @param durationUs The total duration of the period, in microseconds, or {@link + * C#TIME_UNSET} if unknown. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setDurationUs(long durationUs) { + checkArgument(durationUs == C.TIME_UNSET || durationUs >= 0); + this.durationUs = durationUs; + return this; + } + + /** + * Sets the {@link AdPlaybackState}. + * + * @param adPlaybackState The {@link AdPlaybackState}, or {@link AdPlaybackState#NONE} if + * there are no ads. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAdPlaybackState(AdPlaybackState adPlaybackState) { + this.adPlaybackState = adPlaybackState; + return this; + } + + /** + * Sets whether this period contains placeholder information because the real information has + * yet to be loaded + * + * @param isPlaceholder Whether this period contains placeholder information because the real + * information has yet to be loaded. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setIsPlaceholder(boolean isPlaceholder) { + this.isPlaceholder = isPlaceholder; + return this; + } + + /** Builds the {@link PeriodData}. */ + public PeriodData build() { + return new PeriodData(this); + } + } + + /** The unique identifier of the period within its media item. */ + public final Object uid; + /** + * The total duration of the period, in microseconds, or {@link C#TIME_UNSET} if unknown. Only + * the last period in a media item can have an unknown duration. + */ + public final long durationUs; + /** + * The {@link AdPlaybackState} of the period, or {@link AdPlaybackState#NONE} if there are no + * ads. + */ + public final AdPlaybackState adPlaybackState; + /** + * Whether this period contains placeholder information because the real information has yet to + * be loaded. + */ + public final boolean isPlaceholder; + + private PeriodData(Builder builder) { + this.uid = builder.uid; + this.durationUs = builder.durationUs; + this.adPlaybackState = builder.adPlaybackState; + this.isPlaceholder = builder.isPlaceholder; + } + + /** Returns a {@link Builder} pre-populated with the current values. */ + public Builder buildUpon() { + return new Builder(this); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PeriodData)) { + return false; + } + PeriodData periodData = (PeriodData) o; + return this.uid.equals(periodData.uid) + && this.durationUs == periodData.durationUs + && this.adPlaybackState.equals(periodData.adPlaybackState) + && this.isPlaceholder == periodData.isPlaceholder; + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + uid.hashCode(); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + adPlaybackState.hashCode(); + result = 31 * result + (isPlaceholder ? 1 : 0); + return result; + } + } + + /** A supplier for a position. */ + protected interface PositionSupplier { + + /** An instance returning a constant position of zero. */ + PositionSupplier ZERO = getConstant(/* positionMs= */ 0); + + /** + * Returns an instance that returns a constant value. + * + * @param positionMs The constant position to return, in milliseconds. + */ + static PositionSupplier getConstant(long positionMs) { + return () -> positionMs; + } + + /** + * Returns an instance that extrapolates the provided position into the future. + * + * @param currentPositionMs The current position in milliseconds. + * @param playbackSpeed The playback speed with which the position is assumed to increase. + */ + static PositionSupplier getExtrapolating(long currentPositionMs, float playbackSpeed) { + long startTimeMs = SystemClock.elapsedRealtime(); + return () -> { + long currentTimeMs = SystemClock.elapsedRealtime(); + return currentPositionMs + (long) ((currentTimeMs - startTimeMs) * playbackSpeed); + }; + } + + /** Returns the position. */ + long get(); + } + + /** + * Position difference threshold below which we do not automatically report a position + * discontinuity, in milliseconds. + */ + private static final long POSITION_DISCONTINUITY_THRESHOLD_MS = 1000; + private final ListenerSet listeners; private final Looper applicationLooper; private final HandlerWrapper applicationHandler; private final HashSet> pendingOperations; + private final Timeline.Period period; private @MonotonicNonNull State state; + private boolean released; /** * Creates the base class. @@ -208,6 +1984,7 @@ public abstract class SimpleBasePlayer extends BasePlayer { this.applicationLooper = applicationLooper; applicationHandler = clock.createHandler(applicationLooper, /* callback= */ null); pendingOperations = new HashSet<>(); + period = new Timeline.Period(); @SuppressWarnings("nullness:argument.type.incompatible") // Using this in constructor. ListenerSet listenerSet = new ListenerSet<>( @@ -225,8 +2002,7 @@ public abstract class SimpleBasePlayer extends BasePlayer { @Override public final void removeListener(Listener listener) { - // Don't verify application thread. We allow calls to this method from any thread. - checkNotNull(listener); + verifyApplicationThreadAndInitState(); listeners.remove(listener); } @@ -245,8 +2021,9 @@ public abstract class SimpleBasePlayer extends BasePlayer { @Override public final void setPlayWhenReady(boolean playWhenReady) { verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. State state = this.state; - if (!state.availableCommands.contains(Player.COMMAND_PLAY_PAUSE)) { + if (!shouldHandleCommand(Player.COMMAND_PLAY_PAUSE)) { return; } updateStateForPendingOperation( @@ -266,382 +2043,684 @@ public abstract class SimpleBasePlayer extends BasePlayer { @Override public final void setMediaItems(List mediaItems, boolean resetPosition) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + int startIndex = resetPosition ? C.INDEX_UNSET : state.currentMediaItemIndex; + long startPositionMs = resetPosition ? C.TIME_UNSET : state.contentPositionMsSupplier.get(); + setMediaItemsInternal(mediaItems, startIndex, startPositionMs); } @Override public final void setMediaItems( List mediaItems, int startIndex, long startPositionMs) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + if (startIndex == C.INDEX_UNSET) { + startIndex = state.currentMediaItemIndex; + startPositionMs = state.contentPositionMsSupplier.get(); + } + setMediaItemsInternal(mediaItems, startIndex, startPositionMs); + } + + @RequiresNonNull("state") + private void setMediaItemsInternal( + List mediaItems, int startIndex, long startPositionMs) { + checkArgument(startIndex == C.INDEX_UNSET || startIndex >= 0); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) + && (mediaItems.size() != 1 || !shouldHandleCommand(Player.COMMAND_SET_MEDIA_ITEM))) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetMediaItems(mediaItems, startIndex, startPositionMs), + /* placeholderStateSupplier= */ () -> { + ArrayList placeholderPlaylist = new ArrayList<>(); + for (int i = 0; i < mediaItems.size(); i++) { + placeholderPlaylist.add(getPlaceholderMediaItemData(mediaItems.get(i))); + } + return getStateWithNewPlaylistAndPosition( + state, placeholderPlaylist, startIndex, startPositionMs); + }); } @Override public final void addMediaItems(int index, List mediaItems) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + checkArgument(index >= 0); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + int playlistSize = state.playlist.size(); + if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) || mediaItems.isEmpty()) { + return; + } + int correctedIndex = min(index, playlistSize); + updateStateForPendingOperation( + /* pendingOperation= */ handleAddMediaItems(correctedIndex, mediaItems), + /* placeholderStateSupplier= */ () -> { + ArrayList placeholderPlaylist = new ArrayList<>(state.playlist); + for (int i = 0; i < mediaItems.size(); i++) { + placeholderPlaylist.add( + i + correctedIndex, getPlaceholderMediaItemData(mediaItems.get(i))); + } + return getStateWithNewPlaylist(state, placeholderPlaylist, period); + }); } @Override public final void moveMediaItems(int fromIndex, int toIndex, int newIndex) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + checkArgument(fromIndex >= 0 && toIndex >= fromIndex && newIndex >= 0); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + int playlistSize = state.playlist.size(); + if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) + || playlistSize == 0 + || fromIndex >= playlistSize) { + return; + } + int correctedToIndex = min(toIndex, playlistSize); + int correctedNewIndex = min(newIndex, state.playlist.size() - (correctedToIndex - fromIndex)); + if (fromIndex == correctedToIndex || correctedNewIndex == fromIndex) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleMoveMediaItems( + fromIndex, correctedToIndex, correctedNewIndex), + /* placeholderStateSupplier= */ () -> { + ArrayList placeholderPlaylist = new ArrayList<>(state.playlist); + Util.moveItems(placeholderPlaylist, fromIndex, correctedToIndex, correctedNewIndex); + return getStateWithNewPlaylist(state, placeholderPlaylist, period); + }); } @Override public final void removeMediaItems(int fromIndex, int toIndex) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + checkArgument(fromIndex >= 0 && toIndex >= fromIndex); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + int playlistSize = state.playlist.size(); + if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) + || playlistSize == 0 + || fromIndex >= playlistSize) { + return; + } + int correctedToIndex = min(toIndex, playlistSize); + if (fromIndex == correctedToIndex) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleRemoveMediaItems(fromIndex, correctedToIndex), + /* placeholderStateSupplier= */ () -> { + ArrayList placeholderPlaylist = new ArrayList<>(state.playlist); + Util.removeRange(placeholderPlaylist, fromIndex, correctedToIndex); + return getStateWithNewPlaylist(state, placeholderPlaylist, period); + }); } @Override public final void prepare() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_PREPARE)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handlePrepare(), + /* placeholderStateSupplier= */ () -> + state + .buildUpon() + .setPlayerError(null) + .setPlaybackState(state.timeline.isEmpty() ? STATE_ENDED : STATE_BUFFERING) + .build()); } @Override + @Player.State public final int getPlaybackState() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.playbackState; } @Override public final int getPlaybackSuppressionReason() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.playbackSuppressionReason; } @Nullable @Override public final PlaybackException getPlayerError() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.playerError; } @Override - public final void setRepeatMode(int repeatMode) { - // TODO: implement. - throw new IllegalStateException(); + public final void setRepeatMode(@Player.RepeatMode int repeatMode) { + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_REPEAT_MODE)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetRepeatMode(repeatMode), + /* placeholderStateSupplier= */ () -> state.buildUpon().setRepeatMode(repeatMode).build()); } @Override + @Player.RepeatMode public final int getRepeatMode() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.repeatMode; } @Override public final void setShuffleModeEnabled(boolean shuffleModeEnabled) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_SHUFFLE_MODE)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetShuffleModeEnabled(shuffleModeEnabled), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setShuffleModeEnabled(shuffleModeEnabled).build()); } @Override public final boolean getShuffleModeEnabled() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.shuffleModeEnabled; } @Override public final boolean isLoading() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.isLoading; } @Override - public final void seekTo(int mediaItemIndex, long positionMs) { - // TODO: implement. - throw new IllegalStateException(); + @VisibleForTesting(otherwise = PROTECTED) + public final void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem) { + verifyApplicationThreadAndInitState(); + checkArgument(mediaItemIndex >= 0); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(seekCommand) + || isPlayingAd() + || (!state.playlist.isEmpty() && mediaItemIndex >= state.playlist.size())) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSeek(mediaItemIndex, positionMs, seekCommand), + /* placeholderStateSupplier= */ () -> + getStateWithNewPlaylistAndPosition(state, state.playlist, mediaItemIndex, positionMs), + /* seeked= */ true, + isRepeatingCurrentItem); } @Override public final long getSeekBackIncrement() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.seekBackIncrementMs; } @Override public final long getSeekForwardIncrement() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.seekForwardIncrementMs; } @Override public final long getMaxSeekToPreviousPosition() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.maxSeekToPreviousPositionMs; } @Override public final void setPlaybackParameters(PlaybackParameters playbackParameters) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_SPEED_AND_PITCH)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetPlaybackParameters(playbackParameters), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setPlaybackParameters(playbackParameters).build()); } @Override public final PlaybackParameters getPlaybackParameters() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.playbackParameters; } @Override public final void stop() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_STOP)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleStop(), + /* placeholderStateSupplier= */ () -> + state + .buildUpon() + .setPlaybackState(Player.STATE_IDLE) + .setTotalBufferedDurationMs(PositionSupplier.ZERO) + .setContentBufferedPositionMs( + PositionSupplier.getConstant(getContentPositionMsInternal(state))) + .setAdBufferedPositionMs(state.adPositionMsSupplier) + .setIsLoading(false) + .build()); } @Override public final void stop(boolean reset) { - // TODO: implement. - throw new IllegalStateException(); + stop(); + if (reset) { + clearMediaItems(); + } } @Override public final void release() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (released) { // TODO(b/261158047): Replace by !shouldHandleCommand(Player.COMMAND_RELEASE) + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleRelease(), /* placeholderStateSupplier= */ () -> state); + released = true; + listeners.release(); + // Enforce some final state values in case getters are called after release. + this.state = + this.state + .buildUpon() + .setPlaybackState(Player.STATE_IDLE) + .setTotalBufferedDurationMs(PositionSupplier.ZERO) + .setContentBufferedPositionMs( + PositionSupplier.getConstant(getContentPositionMsInternal(state))) + .setAdBufferedPositionMs(state.adPositionMsSupplier) + .setIsLoading(false) + .build(); } @Override public final Tracks getCurrentTracks() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return getCurrentTracksInternal(state); } @Override public final TrackSelectionParameters getTrackSelectionParameters() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.trackSelectionParameters; } @Override public final void setTrackSelectionParameters(TrackSelectionParameters parameters) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetTrackSelectionParameters(parameters), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setTrackSelectionParameters(parameters).build()); } @Override public final MediaMetadata getMediaMetadata() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return getMediaMetadataInternal(state); } @Override public final MediaMetadata getPlaylistMetadata() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.playlistMetadata; } @Override public final void setPlaylistMetadata(MediaMetadata mediaMetadata) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_MEDIA_ITEMS_METADATA)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetPlaylistMetadata(mediaMetadata), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setPlaylistMetadata(mediaMetadata).build()); } @Override public final Timeline getCurrentTimeline() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.timeline; } @Override public final int getCurrentPeriodIndex() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return getCurrentPeriodIndexInternal(state, window, period); } @Override public final int getCurrentMediaItemIndex() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return getCurrentMediaItemIndexInternal(state); } @Override public final long getDuration() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + if (isPlayingAd()) { + state.timeline.getPeriod(getCurrentPeriodIndex(), period); + long adDurationUs = + period.getAdDurationUs(state.currentAdGroupIndex, state.currentAdIndexInAdGroup); + return Util.usToMs(adDurationUs); + } + return getContentDuration(); } @Override public final long getCurrentPosition() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return isPlayingAd() ? state.adPositionMsSupplier.get() : getContentPosition(); } @Override public final long getBufferedPosition() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return isPlayingAd() + ? max(state.adBufferedPositionMsSupplier.get(), state.adPositionMsSupplier.get()) + : getContentBufferedPosition(); } @Override public final long getTotalBufferedDuration() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.totalBufferedDurationMsSupplier.get(); } @Override public final boolean isPlayingAd() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.currentAdGroupIndex != C.INDEX_UNSET; } @Override public final int getCurrentAdGroupIndex() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.currentAdGroupIndex; } @Override public final int getCurrentAdIndexInAdGroup() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.currentAdIndexInAdGroup; } @Override public final long getContentPosition() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return getContentPositionMsInternal(state); } @Override public final long getContentBufferedPosition() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return max(getContentBufferedPositionMsInternal(state), getContentPositionMsInternal(state)); } @Override public final AudioAttributes getAudioAttributes() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.audioAttributes; } @Override public final void setVolume(float volume) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VOLUME)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetVolume(volume), + /* placeholderStateSupplier= */ () -> state.buildUpon().setVolume(volume).build()); } @Override public final float getVolume() { - // TODO: implement. - throw new IllegalStateException(); - } - - @Override - public final void clearVideoSurface() { - // TODO: implement. - throw new IllegalStateException(); - } - - @Override - public final void clearVideoSurface(@Nullable Surface surface) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.volume; } @Override public final void setVideoSurface(@Nullable Surface surface) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) { + return; + } + if (surface == null) { + clearVideoSurface(); + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetVideoOutput(surface), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setSurfaceSize(Size.UNKNOWN).build()); } @Override public final void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) { - // TODO: implement. - throw new IllegalStateException(); - } - - @Override - public final void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) { + return; + } + if (surfaceHolder == null) { + clearVideoSurface(); + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetVideoOutput(surfaceHolder), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setSurfaceSize(getSurfaceHolderSize(surfaceHolder)).build()); } @Override public final void setVideoSurfaceView(@Nullable SurfaceView surfaceView) { - // TODO: implement. - throw new IllegalStateException(); - } - - @Override - public final void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) { + return; + } + if (surfaceView == null) { + clearVideoSurface(); + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetVideoOutput(surfaceView), + /* placeholderStateSupplier= */ () -> + state + .buildUpon() + .setSurfaceSize(getSurfaceHolderSize(surfaceView.getHolder())) + .build()); } @Override public final void setVideoTextureView(@Nullable TextureView textureView) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) { + return; + } + if (textureView == null) { + clearVideoSurface(); + return; + } + Size surfaceSize; + if (textureView.isAvailable()) { + surfaceSize = new Size(textureView.getWidth(), textureView.getHeight()); + } else { + surfaceSize = Size.ZERO; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetVideoOutput(textureView), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setSurfaceSize(surfaceSize).build()); + } + + @Override + public final void clearVideoSurface() { + clearVideoOutput(/* videoOutput= */ null); + } + + @Override + public final void clearVideoSurface(@Nullable Surface surface) { + clearVideoOutput(surface); + } + + @Override + public final void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) { + clearVideoOutput(surfaceHolder); + } + + @Override + public final void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) { + clearVideoOutput(surfaceView); } @Override public final void clearVideoTextureView(@Nullable TextureView textureView) { - // TODO: implement. - throw new IllegalStateException(); + clearVideoOutput(textureView); + } + + private void clearVideoOutput(@Nullable Object videoOutput) { + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_VIDEO_SURFACE)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleClearVideoOutput(videoOutput), + /* placeholderStateSupplier= */ () -> state.buildUpon().setSurfaceSize(Size.ZERO).build()); } @Override public final VideoSize getVideoSize() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.videoSize; } @Override public final Size getSurfaceSize() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.surfaceSize; } @Override public final CueGroup getCurrentCues() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.currentCues; } @Override public final DeviceInfo getDeviceInfo() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.deviceInfo; } @Override public final int getDeviceVolume() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.deviceVolume; } @Override public final boolean isDeviceMuted() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + return state.isDeviceMuted; } @Override public final void setDeviceVolume(int volume) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_SET_DEVICE_VOLUME)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetDeviceVolume(volume), + /* placeholderStateSupplier= */ () -> state.buildUpon().setDeviceVolume(volume).build()); } @Override public final void increaseDeviceVolume() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleIncreaseDeviceVolume(), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setDeviceVolume(state.deviceVolume + 1).build()); } @Override public final void decreaseDeviceVolume() { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleDecreaseDeviceVolume(), + /* placeholderStateSupplier= */ () -> + state.buildUpon().setDeviceVolume(max(0, state.deviceVolume - 1)).build()); } @Override public final void setDeviceMuted(boolean muted) { - // TODO: implement. - throw new IllegalStateException(); + verifyApplicationThreadAndInitState(); + // Use a local copy to ensure the lambda below uses the current state value. + State state = this.state; + if (!shouldHandleCommand(Player.COMMAND_ADJUST_DEVICE_VOLUME)) { + return; + } + updateStateForPendingOperation( + /* pendingOperation= */ handleSetDeviceMuted(muted), + /* placeholderStateSupplier= */ () -> state.buildUpon().setIsDeviceMuted(muted).build()); } /** @@ -655,10 +2734,11 @@ public abstract class SimpleBasePlayer extends BasePlayer { */ protected final void invalidateState() { verifyApplicationThreadAndInitState(); - if (!pendingOperations.isEmpty()) { + if (!pendingOperations.isEmpty() || released) { return; } - updateStateAndInformListeners(getState()); + updateStateAndInformListeners( + getState(), /* seeked= */ false, /* isRepeatingCurrentItem= */ false); } /** @@ -695,36 +2775,462 @@ public abstract class SimpleBasePlayer extends BasePlayer { } /** - * Handles calls to set {@link State#playWhenReady}. + * Returns the placeholder {@link MediaItemData} used for a new {@link MediaItem} added to the + * playlist. * - *

    Will only be called if {@link Player.Command#COMMAND_PLAY_PAUSE} is available. + *

    An implementation only needs to override this method if it can determine a more accurate + * placeholder state than the default. + * + * @param mediaItem The {@link MediaItem} added to the playlist. + * @return The {@link MediaItemData} used as placeholder while adding the item to the playlist is + * in progress. + */ + @ForOverride + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return new MediaItemData.Builder(new PlaceholderUid()) + .setMediaItem(mediaItem) + .setIsDynamic(true) + .setIsPlaceholder(true) + .build(); + } + + /** + * Handles calls to {@link Player#setPlayWhenReady}, {@link Player#play} and {@link Player#pause}. + * + *

    Will only be called if {@link Player#COMMAND_PLAY_PAUSE} is available. * * @param playWhenReady The requested {@link State#playWhenReady} * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} * changes caused by this call. - * @see Player#setPlayWhenReady(boolean) - * @see Player#play() - * @see Player#pause() */ @ForOverride protected ListenableFuture handleSetPlayWhenReady(boolean playWhenReady) { - throw new IllegalStateException(); + throw new IllegalStateException("Missing implementation to handle COMMAND_PLAY_PAUSE"); + } + + /** + * Handles calls to {@link Player#prepare}. + * + *

    Will only be called if {@link Player#COMMAND_PREPARE} is available. + * + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handlePrepare() { + throw new IllegalStateException("Missing implementation to handle COMMAND_PREPARE"); + } + + /** + * Handles calls to {@link Player#stop}. + * + *

    Will only be called if {@link Player#COMMAND_STOP} is available. + * + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleStop() { + throw new IllegalStateException("Missing implementation to handle COMMAND_STOP"); + } + + /** + * Handles calls to {@link Player#release}. + * + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + // TODO(b/261158047): Add that this method will only be called if COMMAND_RELEASE is available. + @ForOverride + protected ListenableFuture handleRelease() { + throw new IllegalStateException("Missing implementation to handle COMMAND_RELEASE"); + } + + /** + * Handles calls to {@link Player#setRepeatMode}. + * + *

    Will only be called if {@link Player#COMMAND_SET_REPEAT_MODE} is available. + * + * @param repeatMode The requested {@link RepeatMode}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetRepeatMode(@RepeatMode int repeatMode) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_REPEAT_MODE"); + } + + /** + * Handles calls to {@link Player#setShuffleModeEnabled}. + * + *

    Will only be called if {@link Player#COMMAND_SET_SHUFFLE_MODE} is available. + * + * @param shuffleModeEnabled Whether shuffle mode was requested to be enabled. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetShuffleModeEnabled(boolean shuffleModeEnabled) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_SHUFFLE_MODE"); + } + + /** + * Handles calls to {@link Player#setPlaybackParameters} or {@link Player#setPlaybackSpeed}. + * + *

    Will only be called if {@link Player#COMMAND_SET_SPEED_AND_PITCH} is available. + * + * @param playbackParameters The requested {@link PlaybackParameters}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetPlaybackParameters(PlaybackParameters playbackParameters) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_SPEED_AND_PITCH"); + } + + /** + * Handles calls to {@link Player#setTrackSelectionParameters}. + * + *

    Will only be called if {@link Player#COMMAND_SET_TRACK_SELECTION_PARAMETERS} is available. + * + * @param trackSelectionParameters The requested {@link TrackSelectionParameters}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetTrackSelectionParameters( + TrackSelectionParameters trackSelectionParameters) { + throw new IllegalStateException( + "Missing implementation to handle COMMAND_SET_TRACK_SELECTION_PARAMETERS"); + } + + /** + * Handles calls to {@link Player#setPlaylistMetadata}. + * + *

    Will only be called if {@link Player#COMMAND_SET_MEDIA_ITEMS_METADATA} is available. + * + * @param playlistMetadata The requested {@linkplain MediaMetadata playlist metadata}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetPlaylistMetadata(MediaMetadata playlistMetadata) { + throw new IllegalStateException( + "Missing implementation to handle COMMAND_SET_MEDIA_ITEMS_METADATA"); + } + + /** + * Handles calls to {@link Player#setVolume}. + * + *

    Will only be called if {@link Player#COMMAND_SET_VOLUME} is available. + * + * @param volume The requested audio volume, with 0 being silence and 1 being unity gain (signal + * unchanged). + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetVolume(@FloatRange(from = 0, to = 1.0) float volume) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VOLUME"); + } + + /** + * Handles calls to {@link Player#setDeviceVolume}. + * + *

    Will only be called if {@link Player#COMMAND_SET_DEVICE_VOLUME} is available. + * + * @param deviceVolume The requested device volume. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetDeviceVolume(@IntRange(from = 0) int deviceVolume) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_DEVICE_VOLUME"); + } + + /** + * Handles calls to {@link Player#increaseDeviceVolume()}. + * + *

    Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available. + * + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleIncreaseDeviceVolume() { + throw new IllegalStateException( + "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME"); + } + + /** + * Handles calls to {@link Player#decreaseDeviceVolume()}. + * + *

    Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available. + * + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleDecreaseDeviceVolume() { + throw new IllegalStateException( + "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME"); + } + + /** + * Handles calls to {@link Player#setDeviceMuted}. + * + *

    Will only be called if {@link Player#COMMAND_ADJUST_DEVICE_VOLUME} is available. + * + * @param muted Whether the device was requested to be muted. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetDeviceMuted(boolean muted) { + throw new IllegalStateException( + "Missing implementation to handle COMMAND_ADJUST_DEVICE_VOLUME"); + } + + /** + * Handles calls to set the video output. + * + *

    Will only be called if {@link Player#COMMAND_SET_VIDEO_SURFACE} is available. + * + * @param videoOutput The requested video output. This is either a {@link Surface}, {@link + * SurfaceHolder}, {@link TextureView} or {@link SurfaceView}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetVideoOutput(Object videoOutput) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VIDEO_SURFACE"); + } + + /** + * Handles calls to clear the video output. + * + *

    Will only be called if {@link Player#COMMAND_SET_VIDEO_SURFACE} is available. + * + * @param videoOutput The video output to clear. If null any current output should be cleared. If + * non-null, the output should only be cleared if it matches the provided argument. This is + * either a {@link Surface}, {@link SurfaceHolder}, {@link TextureView} or {@link + * SurfaceView}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleClearVideoOutput(@Nullable Object videoOutput) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_VIDEO_SURFACE"); + } + + /** + * Handles calls to {@link Player#setMediaItem} and {@link Player#setMediaItems}. + * + *

    Will only be called if {@link Player#COMMAND_SET_MEDIA_ITEM} or {@link + * Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. If only {@link Player#COMMAND_SET_MEDIA_ITEM} + * is available, the list of media items will always contain exactly one item. + * + * @param mediaItems The media items to add. + * @param startIndex The index at which to start playback from, or {@link C#INDEX_UNSET} to start + * at the default item. + * @param startPositionMs The position in milliseconds to start playback from, or {@link + * C#TIME_UNSET} to start at the default position in the media item. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + throw new IllegalStateException("Missing implementation to handle COMMAND_SET_MEDIA_ITEM(S)"); + } + + /** + * Handles calls to {@link Player#addMediaItem} and {@link Player#addMediaItems}. + * + *

    Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. + * + * @param index The index at which to add the items. The index is in the range 0 <= {@code + * index} <= {@link #getMediaItemCount()}. + * @param mediaItems The media items to add. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS"); + } + + /** + * Handles calls to {@link Player#moveMediaItem} and {@link Player#moveMediaItems}. + * + *

    Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. + * + * @param fromIndex The start index of the items to move. The index is in the range 0 <= {@code + * fromIndex} < {@link #getMediaItemCount()}. + * @param toIndex The index of the first item not to be included in the move (exclusive). The + * index is in the range {@code fromIndex} < {@code toIndex} <= {@link + * #getMediaItemCount()}. + * @param newIndex The new index of the first moved item. The index is in the range {@code 0} + * <= {@code newIndex} < {@link #getMediaItemCount() - (toIndex - fromIndex)}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleMoveMediaItems(int fromIndex, int toIndex, int newIndex) { + throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS"); + } + + /** + * Handles calls to {@link Player#removeMediaItem} and {@link Player#removeMediaItems}. + * + *

    Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. + * + * @param fromIndex The index at which to start removing media items. The index is in the range 0 + * <= {@code fromIndex} < {@link #getMediaItemCount()}. + * @param toIndex The index of the first item to be kept (exclusive). The index is in the range + * {@code fromIndex} < {@code toIndex} <= {@link #getMediaItemCount()}. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS"); + } + + /** + * Handles calls to {@link Player#seekTo} and other seek operations (for example, {@link + * Player#seekToNext}). + * + *

    Will only be called if the appropriate {@link Player.Command}, for example {@link + * Player#COMMAND_SEEK_TO_MEDIA_ITEM} or {@link Player#COMMAND_SEEK_TO_NEXT}, is available. + * + * @param mediaItemIndex The media item index to seek to. The index is in the range 0 <= {@code + * mediaItemIndex} < {@code mediaItems.size()}. + * @param positionMs The position in milliseconds to start playback from, or {@link C#TIME_UNSET} + * to start at the default position in the media item. + * @param seekCommand The {@link Player.Command} used to trigger the seek. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + throw new IllegalStateException("Missing implementation to handle one of the COMMAND_SEEK_*"); + } + + @RequiresNonNull("state") + private boolean shouldHandleCommand(@Player.Command int commandCode) { + return !released && state.availableCommands.contains(commandCode); } @SuppressWarnings("deprecation") // Calling deprecated listener methods. @RequiresNonNull("state") - private void updateStateAndInformListeners(State newState) { + private void updateStateAndInformListeners( + State newState, boolean seeked, boolean isRepeatingCurrentItem) { State previousState = state; // Assign new state immediately such that all getters return the right values, but use a // snapshot of the previous and new state so that listener invocations are triggered correctly. this.state = newState; + if (newState.hasPositionDiscontinuity || newState.newlyRenderedFirstFrame) { + // Clear one-time events to avoid signalling them again later. + this.state = + this.state + .buildUpon() + .clearPositionDiscontinuity() + .setNewlyRenderedFirstFrame(false) + .build(); + } boolean playWhenReadyChanged = previousState.playWhenReady != newState.playWhenReady; - if (playWhenReadyChanged /* TODO: || playbackStateChanged */) { + boolean playbackStateChanged = previousState.playbackState != newState.playbackState; + Tracks previousTracks = getCurrentTracksInternal(previousState); + Tracks newTracks = getCurrentTracksInternal(newState); + MediaMetadata previousMediaMetadata = getMediaMetadataInternal(previousState); + MediaMetadata newMediaMetadata = getMediaMetadataInternal(newState); + int positionDiscontinuityReason = + getPositionDiscontinuityReason(previousState, newState, seeked, window, period); + boolean timelineChanged = !previousState.timeline.equals(newState.timeline); + int mediaItemTransitionReason = + getMediaItemTransitionReason( + previousState, newState, positionDiscontinuityReason, isRepeatingCurrentItem, window); + + if (timelineChanged) { + @Player.TimelineChangeReason + int timelineChangeReason = getTimelineChangeReason(previousState.playlist, newState.playlist); + listeners.queueEvent( + Player.EVENT_TIMELINE_CHANGED, + listener -> listener.onTimelineChanged(newState.timeline, timelineChangeReason)); + } + if (positionDiscontinuityReason != C.INDEX_UNSET) { + PositionInfo previousPositionInfo = + getPositionInfo(previousState, /* useDiscontinuityPosition= */ false, window, period); + PositionInfo positionInfo = + getPositionInfo( + newState, + /* useDiscontinuityPosition= */ newState.hasPositionDiscontinuity, + window, + period); + listeners.queueEvent( + Player.EVENT_POSITION_DISCONTINUITY, + listener -> { + listener.onPositionDiscontinuity(positionDiscontinuityReason); + listener.onPositionDiscontinuity( + previousPositionInfo, positionInfo, positionDiscontinuityReason); + }); + } + if (mediaItemTransitionReason != C.INDEX_UNSET) { + @Nullable + MediaItem mediaItem = + newState.timeline.isEmpty() + ? null + : newState.playlist.get(getCurrentMediaItemIndexInternal(newState)).mediaItem; + listeners.queueEvent( + Player.EVENT_MEDIA_ITEM_TRANSITION, + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } + if (!Util.areEqual(previousState.playerError, newState.playerError)) { + listeners.queueEvent( + Player.EVENT_PLAYER_ERROR, + listener -> listener.onPlayerErrorChanged(newState.playerError)); + if (newState.playerError != null) { + listeners.queueEvent( + Player.EVENT_PLAYER_ERROR, + listener -> listener.onPlayerError(castNonNull(newState.playerError))); + } + } + if (!previousState.trackSelectionParameters.equals(newState.trackSelectionParameters)) { + listeners.queueEvent( + Player.EVENT_TRACK_SELECTION_PARAMETERS_CHANGED, + listener -> + listener.onTrackSelectionParametersChanged(newState.trackSelectionParameters)); + } + if (!previousTracks.equals(newTracks)) { + listeners.queueEvent( + Player.EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(newTracks)); + } + if (!previousMediaMetadata.equals(newMediaMetadata)) { + listeners.queueEvent( + EVENT_MEDIA_METADATA_CHANGED, + listener -> listener.onMediaMetadataChanged(newMediaMetadata)); + } + if (previousState.isLoading != newState.isLoading) { + listeners.queueEvent( + Player.EVENT_IS_LOADING_CHANGED, + listener -> { + listener.onLoadingChanged(newState.isLoading); + listener.onIsLoadingChanged(newState.isLoading); + }); + } + if (playWhenReadyChanged || playbackStateChanged) { listeners.queueEvent( /* eventFlag= */ C.INDEX_UNSET, listener -> - listener.onPlayerStateChanged(newState.playWhenReady, /* TODO */ Player.STATE_IDLE)); + listener.onPlayerStateChanged(newState.playWhenReady, newState.playbackState)); + } + if (playbackStateChanged) { + listeners.queueEvent( + Player.EVENT_PLAYBACK_STATE_CHANGED, + listener -> listener.onPlaybackStateChanged(newState.playbackState)); } if (playWhenReadyChanged || previousState.playWhenReadyChangeReason != newState.playWhenReadyChangeReason) { @@ -734,11 +3240,105 @@ public abstract class SimpleBasePlayer extends BasePlayer { listener.onPlayWhenReadyChanged( newState.playWhenReady, newState.playWhenReadyChangeReason)); } + if (previousState.playbackSuppressionReason != newState.playbackSuppressionReason) { + listeners.queueEvent( + Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED, + listener -> + listener.onPlaybackSuppressionReasonChanged(newState.playbackSuppressionReason)); + } if (isPlaying(previousState) != isPlaying(newState)) { listeners.queueEvent( Player.EVENT_IS_PLAYING_CHANGED, listener -> listener.onIsPlayingChanged(isPlaying(newState))); } + if (!previousState.playbackParameters.equals(newState.playbackParameters)) { + listeners.queueEvent( + Player.EVENT_PLAYBACK_PARAMETERS_CHANGED, + listener -> listener.onPlaybackParametersChanged(newState.playbackParameters)); + } + if (previousState.repeatMode != newState.repeatMode) { + listeners.queueEvent( + Player.EVENT_REPEAT_MODE_CHANGED, + listener -> listener.onRepeatModeChanged(newState.repeatMode)); + } + if (previousState.shuffleModeEnabled != newState.shuffleModeEnabled) { + listeners.queueEvent( + Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED, + listener -> listener.onShuffleModeEnabledChanged(newState.shuffleModeEnabled)); + } + if (previousState.seekBackIncrementMs != newState.seekBackIncrementMs) { + listeners.queueEvent( + Player.EVENT_SEEK_BACK_INCREMENT_CHANGED, + listener -> listener.onSeekBackIncrementChanged(newState.seekBackIncrementMs)); + } + if (previousState.seekForwardIncrementMs != newState.seekForwardIncrementMs) { + listeners.queueEvent( + Player.EVENT_SEEK_FORWARD_INCREMENT_CHANGED, + listener -> listener.onSeekForwardIncrementChanged(newState.seekForwardIncrementMs)); + } + if (previousState.maxSeekToPreviousPositionMs != newState.maxSeekToPreviousPositionMs) { + listeners.queueEvent( + Player.EVENT_MAX_SEEK_TO_PREVIOUS_POSITION_CHANGED, + listener -> + listener.onMaxSeekToPreviousPositionChanged(newState.maxSeekToPreviousPositionMs)); + } + if (!previousState.audioAttributes.equals(newState.audioAttributes)) { + listeners.queueEvent( + Player.EVENT_AUDIO_ATTRIBUTES_CHANGED, + listener -> listener.onAudioAttributesChanged(newState.audioAttributes)); + } + if (!previousState.videoSize.equals(newState.videoSize)) { + listeners.queueEvent( + Player.EVENT_VIDEO_SIZE_CHANGED, + listener -> listener.onVideoSizeChanged(newState.videoSize)); + } + if (!previousState.deviceInfo.equals(newState.deviceInfo)) { + listeners.queueEvent( + Player.EVENT_DEVICE_INFO_CHANGED, + listener -> listener.onDeviceInfoChanged(newState.deviceInfo)); + } + if (!previousState.playlistMetadata.equals(newState.playlistMetadata)) { + listeners.queueEvent( + Player.EVENT_PLAYLIST_METADATA_CHANGED, + listener -> listener.onPlaylistMetadataChanged(newState.playlistMetadata)); + } + if (newState.newlyRenderedFirstFrame) { + listeners.queueEvent(Player.EVENT_RENDERED_FIRST_FRAME, Listener::onRenderedFirstFrame); + } + if (!previousState.surfaceSize.equals(newState.surfaceSize)) { + listeners.queueEvent( + Player.EVENT_SURFACE_SIZE_CHANGED, + listener -> + listener.onSurfaceSizeChanged( + newState.surfaceSize.getWidth(), newState.surfaceSize.getHeight())); + } + if (previousState.volume != newState.volume) { + listeners.queueEvent( + Player.EVENT_VOLUME_CHANGED, listener -> listener.onVolumeChanged(newState.volume)); + } + if (previousState.deviceVolume != newState.deviceVolume + || previousState.isDeviceMuted != newState.isDeviceMuted) { + listeners.queueEvent( + Player.EVENT_DEVICE_VOLUME_CHANGED, + listener -> + listener.onDeviceVolumeChanged(newState.deviceVolume, newState.isDeviceMuted)); + } + if (!previousState.currentCues.equals(newState.currentCues)) { + listeners.queueEvent( + Player.EVENT_CUES, + listener -> { + listener.onCues(newState.currentCues.cues); + listener.onCues(newState.currentCues); + }); + } + if (!previousState.timedMetadata.equals(newState.timedMetadata) + && newState.timedMetadata.presentationTimeUs != C.TIME_UNSET) { + listeners.queueEvent( + Player.EVENT_METADATA, listener -> listener.onMetadata(newState.timedMetadata)); + } + if (positionDiscontinuityReason == Player.DISCONTINUITY_REASON_SEEK) { + listeners.queueEvent(/* eventFlag= */ C.INDEX_UNSET, Listener::onSeekProcessed); + } if (!previousState.availableCommands.equals(newState.availableCommands)) { listeners.queueEvent( Player.EVENT_AVAILABLE_COMMANDS_CHANGED, @@ -768,18 +3368,33 @@ public abstract class SimpleBasePlayer extends BasePlayer { @RequiresNonNull("state") private void updateStateForPendingOperation( ListenableFuture pendingOperation, Supplier placeholderStateSupplier) { + updateStateForPendingOperation( + pendingOperation, + placeholderStateSupplier, + /* seeked= */ false, + /* isRepeatingCurrentItem= */ false); + } + + @RequiresNonNull("state") + private void updateStateForPendingOperation( + ListenableFuture pendingOperation, + Supplier placeholderStateSupplier, + boolean seeked, + boolean isRepeatingCurrentItem) { if (pendingOperation.isDone() && pendingOperations.isEmpty()) { - updateStateAndInformListeners(getState()); + updateStateAndInformListeners(getState(), seeked, isRepeatingCurrentItem); } else { pendingOperations.add(pendingOperation); State suggestedPlaceholderState = placeholderStateSupplier.get(); - updateStateAndInformListeners(getPlaceholderState(suggestedPlaceholderState)); + updateStateAndInformListeners( + getPlaceholderState(suggestedPlaceholderState), seeked, isRepeatingCurrentItem); pendingOperation.addListener( () -> { - castNonNull(state); // Already check by method @RequiresNonNull pre-condition. + castNonNull(state); // Already checked by method @RequiresNonNull pre-condition. pendingOperations.remove(pendingOperation); - if (pendingOperations.isEmpty()) { - updateStateAndInformListeners(getState()); + if (pendingOperations.isEmpty() && !released) { + updateStateAndInformListeners( + getState(), /* seeked= */ false, /* isRepeatingCurrentItem= */ false); } }, this::postOrRunOnApplicationHandler); @@ -795,8 +3410,391 @@ public abstract class SimpleBasePlayer extends BasePlayer { } private static boolean isPlaying(State state) { - return state.playWhenReady && false; - // TODO: && state.playbackState == Player.STATE_READY - // && state.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE + return state.playWhenReady + && state.playbackState == Player.STATE_READY + && state.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE; } + + private static Tracks getCurrentTracksInternal(State state) { + return state.playlist.isEmpty() + ? Tracks.EMPTY + : state.playlist.get(getCurrentMediaItemIndexInternal(state)).tracks; + } + + private static MediaMetadata getMediaMetadataInternal(State state) { + return state.playlist.isEmpty() + ? MediaMetadata.EMPTY + : state.playlist.get(getCurrentMediaItemIndexInternal(state)).combinedMediaMetadata; + } + + private static int getCurrentMediaItemIndexInternal(State state) { + if (state.currentMediaItemIndex != C.INDEX_UNSET) { + return state.currentMediaItemIndex; + } + return 0; // TODO: Use shuffle order to get first item if playlist is not empty. + } + + private static long getContentPositionMsInternal(State state) { + return getPositionOrDefaultInMediaItem(state.contentPositionMsSupplier.get(), state); + } + + private static long getContentBufferedPositionMsInternal(State state) { + return getPositionOrDefaultInMediaItem(state.contentBufferedPositionMsSupplier.get(), state); + } + + private static long getPositionOrDefaultInMediaItem(long positionMs, State state) { + if (positionMs != C.TIME_UNSET) { + return positionMs; + } + if (state.playlist.isEmpty()) { + return 0; + } + return usToMs(state.playlist.get(getCurrentMediaItemIndexInternal(state)).defaultPositionUs); + } + + private static int getCurrentPeriodIndexInternal( + State state, Timeline.Window window, Timeline.Period period) { + int currentMediaItemIndex = getCurrentMediaItemIndexInternal(state); + if (state.timeline.isEmpty()) { + return currentMediaItemIndex; + } + return getPeriodIndexFromWindowPosition( + state.timeline, currentMediaItemIndex, getContentPositionMsInternal(state), window, period); + } + + private static int getPeriodIndexFromWindowPosition( + Timeline timeline, + int windowIndex, + long windowPositionMs, + Timeline.Window window, + Timeline.Period period) { + Object periodUid = + timeline.getPeriodPositionUs(window, period, windowIndex, msToUs(windowPositionMs)).first; + return timeline.getIndexOfPeriod(periodUid); + } + + private static @Player.TimelineChangeReason int getTimelineChangeReason( + List previousPlaylist, List newPlaylist) { + if (previousPlaylist.size() != newPlaylist.size()) { + return Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; + } + for (int i = 0; i < previousPlaylist.size(); i++) { + Object previousUid = previousPlaylist.get(i).uid; + Object newUid = newPlaylist.get(i).uid; + boolean resolvedAutoGeneratedPlaceholder = + previousUid instanceof PlaceholderUid && !(newUid instanceof PlaceholderUid); + if (!previousUid.equals(newUid) && !resolvedAutoGeneratedPlaceholder) { + return Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; + } + } + return Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE; + } + + private static int getPositionDiscontinuityReason( + State previousState, + State newState, + boolean seeked, + Timeline.Window window, + Timeline.Period period) { + if (newState.hasPositionDiscontinuity) { + // We were asked to report a discontinuity. + return newState.positionDiscontinuityReason; + } + if (seeked) { + return Player.DISCONTINUITY_REASON_SEEK; + } + if (previousState.playlist.isEmpty()) { + // First change from an empty playlist is not reported as a discontinuity. + return C.INDEX_UNSET; + } + if (newState.playlist.isEmpty()) { + // The playlist became empty. + return Player.DISCONTINUITY_REASON_REMOVE; + } + Object previousPeriodUid = + previousState.timeline.getUidOfPeriod( + getCurrentPeriodIndexInternal(previousState, window, period)); + Object newPeriodUid = + newState.timeline.getUidOfPeriod(getCurrentPeriodIndexInternal(newState, window, period)); + if (previousPeriodUid instanceof PlaceholderUid && !(newPeriodUid instanceof PlaceholderUid)) { + // An auto-generated placeholder was resolved to a real item. + return C.INDEX_UNSET; + } + if (!newPeriodUid.equals(previousPeriodUid) + || previousState.currentAdGroupIndex != newState.currentAdGroupIndex + || previousState.currentAdIndexInAdGroup != newState.currentAdIndexInAdGroup) { + // The current period or ad inside a period changed. + if (newState.timeline.getIndexOfPeriod(previousPeriodUid) == C.INDEX_UNSET) { + // The previous period no longer exists. + return Player.DISCONTINUITY_REASON_REMOVE; + } + // Check if reached the previous period's or ad's duration to assume an auto-transition. + long previousPositionMs = + getCurrentPeriodOrAdPositionMs(previousState, previousPeriodUid, period); + long previousDurationMs = getPeriodOrAdDurationMs(previousState, previousPeriodUid, period); + return previousDurationMs != C.TIME_UNSET && previousPositionMs >= previousDurationMs + ? Player.DISCONTINUITY_REASON_AUTO_TRANSITION + : Player.DISCONTINUITY_REASON_SKIP; + } + // We are in the same content period or ad. Check if the position deviates more than a + // reasonable threshold from the previous one. + long previousPositionMs = + getCurrentPeriodOrAdPositionMs(previousState, previousPeriodUid, period); + long newPositionMs = getCurrentPeriodOrAdPositionMs(newState, newPeriodUid, period); + if (Math.abs(previousPositionMs - newPositionMs) < POSITION_DISCONTINUITY_THRESHOLD_MS) { + return C.INDEX_UNSET; + } + // Check if we previously reached the end of the item to assume an auto-repetition. + long previousDurationMs = getPeriodOrAdDurationMs(previousState, previousPeriodUid, period); + return previousDurationMs != C.TIME_UNSET && previousPositionMs >= previousDurationMs + ? Player.DISCONTINUITY_REASON_AUTO_TRANSITION + : Player.DISCONTINUITY_REASON_INTERNAL; + } + + private static long getCurrentPeriodOrAdPositionMs( + State state, Object currentPeriodUid, Timeline.Period period) { + return state.currentAdGroupIndex != C.INDEX_UNSET + ? state.adPositionMsSupplier.get() + : getContentPositionMsInternal(state) + - state.timeline.getPeriodByUid(currentPeriodUid, period).getPositionInWindowMs(); + } + + private static long getPeriodOrAdDurationMs( + State state, Object currentPeriodUid, Timeline.Period period) { + state.timeline.getPeriodByUid(currentPeriodUid, period); + long periodOrAdDurationUs = + state.currentAdGroupIndex == C.INDEX_UNSET + ? period.durationUs + : period.getAdDurationUs(state.currentAdGroupIndex, state.currentAdIndexInAdGroup); + return usToMs(periodOrAdDurationUs); + } + + private static PositionInfo getPositionInfo( + State state, + boolean useDiscontinuityPosition, + Timeline.Window window, + Timeline.Period period) { + @Nullable Object windowUid = null; + @Nullable Object periodUid = null; + int mediaItemIndex = getCurrentMediaItemIndexInternal(state); + int periodIndex = C.INDEX_UNSET; + @Nullable MediaItem mediaItem = null; + if (!state.timeline.isEmpty()) { + periodIndex = getCurrentPeriodIndexInternal(state, window, period); + periodUid = state.timeline.getPeriod(periodIndex, period, /* setIds= */ true).uid; + windowUid = state.timeline.getWindow(mediaItemIndex, window).uid; + mediaItem = window.mediaItem; + } + long contentPositionMs; + long positionMs; + if (useDiscontinuityPosition) { + positionMs = state.discontinuityPositionMs; + contentPositionMs = + state.currentAdGroupIndex == C.INDEX_UNSET + ? positionMs + : getContentPositionMsInternal(state); + } else { + contentPositionMs = getContentPositionMsInternal(state); + positionMs = + state.currentAdGroupIndex != C.INDEX_UNSET + ? state.adPositionMsSupplier.get() + : contentPositionMs; + } + return new PositionInfo( + windowUid, + mediaItemIndex, + mediaItem, + periodUid, + periodIndex, + positionMs, + contentPositionMs, + state.currentAdGroupIndex, + state.currentAdIndexInAdGroup); + } + + private static int getMediaItemTransitionReason( + State previousState, + State newState, + int positionDiscontinuityReason, + boolean isRepeatingCurrentItem, + Timeline.Window window) { + Timeline previousTimeline = previousState.timeline; + Timeline newTimeline = newState.timeline; + if (newTimeline.isEmpty() && previousTimeline.isEmpty()) { + return C.INDEX_UNSET; + } else if (newTimeline.isEmpty() != previousTimeline.isEmpty()) { + return MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; + } + Object previousWindowUid = + previousState.timeline.getWindow(getCurrentMediaItemIndexInternal(previousState), window) + .uid; + Object newWindowUid = + newState.timeline.getWindow(getCurrentMediaItemIndexInternal(newState), window).uid; + if (previousWindowUid instanceof PlaceholderUid && !(newWindowUid instanceof PlaceholderUid)) { + // An auto-generated placeholder was resolved to a real item. + return C.INDEX_UNSET; + } + if (!previousWindowUid.equals(newWindowUid)) { + if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION) { + return MEDIA_ITEM_TRANSITION_REASON_AUTO; + } else if (positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK) { + return MEDIA_ITEM_TRANSITION_REASON_SEEK; + } else { + return MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; + } + } + // Only mark changes within the current item as a transition if we are repeating automatically + // or via a seek to next/previous. + if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION + && getContentPositionMsInternal(previousState) > getContentPositionMsInternal(newState)) { + return MEDIA_ITEM_TRANSITION_REASON_REPEAT; + } + if (positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK && isRepeatingCurrentItem) { + return MEDIA_ITEM_TRANSITION_REASON_SEEK; + } + return C.INDEX_UNSET; + } + + private static Size getSurfaceHolderSize(SurfaceHolder surfaceHolder) { + if (!surfaceHolder.getSurface().isValid()) { + return Size.ZERO; + } + Rect surfaceFrame = surfaceHolder.getSurfaceFrame(); + return new Size(surfaceFrame.width(), surfaceFrame.height()); + } + + private static int getMediaItemIndexInNewPlaylist( + List oldPlaylist, + Timeline newPlaylistTimeline, + int oldMediaItemIndex, + Timeline.Period period) { + if (oldPlaylist.isEmpty()) { + return oldMediaItemIndex < newPlaylistTimeline.getWindowCount() + ? oldMediaItemIndex + : C.INDEX_UNSET; + } + Object oldFirstPeriodUid = + oldPlaylist.get(oldMediaItemIndex).getPeriodUid(/* periodIndexInMediaItem= */ 0); + if (newPlaylistTimeline.getIndexOfPeriod(oldFirstPeriodUid) == C.INDEX_UNSET) { + return C.INDEX_UNSET; + } + return newPlaylistTimeline.getPeriodByUid(oldFirstPeriodUid, period).windowIndex; + } + + private static State getStateWithNewPlaylist( + State oldState, List newPlaylist, Timeline.Period period) { + State.Builder stateBuilder = oldState.buildUpon(); + stateBuilder.setPlaylist(newPlaylist); + Timeline newTimeline = stateBuilder.timeline; + long oldPositionMs = oldState.contentPositionMsSupplier.get(); + int oldIndex = getCurrentMediaItemIndexInternal(oldState); + int newIndex = getMediaItemIndexInNewPlaylist(oldState.playlist, newTimeline, oldIndex, period); + long newPositionMs = newIndex == C.INDEX_UNSET ? C.TIME_UNSET : oldPositionMs; + // If the current item no longer exists, try to find a matching subsequent item. + for (int i = oldIndex + 1; newIndex == C.INDEX_UNSET && i < oldState.playlist.size(); i++) { + // TODO: Use shuffle order to iterate. + newIndex = + getMediaItemIndexInNewPlaylist( + oldState.playlist, newTimeline, /* oldMediaItemIndex= */ i, period); + } + // If this fails, transition to ENDED state. + if (oldState.playbackState != Player.STATE_IDLE && newIndex == C.INDEX_UNSET) { + stateBuilder.setPlaybackState(Player.STATE_ENDED).setIsLoading(false); + } + return buildStateForNewPosition( + stateBuilder, + oldState, + oldPositionMs, + newPlaylist, + newIndex, + newPositionMs, + /* keepAds= */ true); + } + + private static State getStateWithNewPlaylistAndPosition( + State oldState, List newPlaylist, int newIndex, long newPositionMs) { + State.Builder stateBuilder = oldState.buildUpon(); + stateBuilder.setPlaylist(newPlaylist); + if (oldState.playbackState != Player.STATE_IDLE) { + if (newPlaylist.isEmpty()) { + stateBuilder.setPlaybackState(Player.STATE_ENDED).setIsLoading(false); + } else { + stateBuilder.setPlaybackState(Player.STATE_BUFFERING); + } + } + long oldPositionMs = oldState.contentPositionMsSupplier.get(); + return buildStateForNewPosition( + stateBuilder, + oldState, + oldPositionMs, + newPlaylist, + newIndex, + newPositionMs, + /* keepAds= */ false); + } + + private static State buildStateForNewPosition( + State.Builder stateBuilder, + State oldState, + long oldPositionMs, + List newPlaylist, + int newIndex, + long newPositionMs, + boolean keepAds) { + // Resolve unset or invalid index and position. + oldPositionMs = getPositionOrDefaultInMediaItem(oldPositionMs, oldState); + if (!newPlaylist.isEmpty() && (newIndex == C.INDEX_UNSET || newIndex >= newPlaylist.size())) { + newIndex = 0; // TODO: Use shuffle order to get first index. + newPositionMs = C.TIME_UNSET; + } + if (!newPlaylist.isEmpty() && newPositionMs == C.TIME_UNSET) { + newPositionMs = usToMs(newPlaylist.get(newIndex).defaultPositionUs); + } + boolean oldOrNewPlaylistEmpty = oldState.playlist.isEmpty() || newPlaylist.isEmpty(); + boolean mediaItemChanged = + !oldOrNewPlaylistEmpty + && !oldState + .playlist + .get(getCurrentMediaItemIndexInternal(oldState)) + .uid + .equals(newPlaylist.get(newIndex).uid); + if (oldOrNewPlaylistEmpty || mediaItemChanged || newPositionMs < oldPositionMs) { + // New item or seeking back. Assume no buffer and no ad playback persists. + stateBuilder + .setCurrentMediaItemIndex(newIndex) + .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET) + .setContentPositionMs(newPositionMs) + .setContentBufferedPositionMs(PositionSupplier.getConstant(newPositionMs)) + .setTotalBufferedDurationMs(PositionSupplier.ZERO); + } else if (newPositionMs == oldPositionMs) { + // Unchanged position. Assume ad playback and buffer in current item persists. + stateBuilder.setCurrentMediaItemIndex(newIndex); + if (oldState.currentAdGroupIndex != C.INDEX_UNSET && keepAds) { + stateBuilder.setTotalBufferedDurationMs( + PositionSupplier.getConstant( + oldState.adBufferedPositionMsSupplier.get() - oldState.adPositionMsSupplier.get())); + } else { + stateBuilder + .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET) + .setTotalBufferedDurationMs( + PositionSupplier.getConstant( + getContentBufferedPositionMsInternal(oldState) - oldPositionMs)); + } + } else { + // Seeking forward. Assume remaining buffer in current item persist, but no ad playback. + long contentBufferedDurationMs = + max(getContentBufferedPositionMsInternal(oldState), newPositionMs); + long totalBufferedDurationMs = + max(0, oldState.totalBufferedDurationMsSupplier.get() - (newPositionMs - oldPositionMs)); + stateBuilder + .setCurrentMediaItemIndex(newIndex) + .setCurrentAd(C.INDEX_UNSET, C.INDEX_UNSET) + .setContentPositionMs(newPositionMs) + .setContentBufferedPositionMs(PositionSupplier.getConstant(contentBufferedDurationMs)) + .setTotalBufferedDurationMs(PositionSupplier.getConstant(totalBufferedDurationMs)); + } + return stateBuilder.build(); + } + + private static final class PlaceholderUid {} } diff --git a/libraries/common/src/main/java/androidx/media3/common/StarRating.java b/libraries/common/src/main/java/androidx/media3/common/StarRating.java index 2c38f7cb75..70aba78bfd 100644 --- a/libraries/common/src/main/java/androidx/media3/common/StarRating.java +++ b/libraries/common/src/main/java/androidx/media3/common/StarRating.java @@ -16,19 +16,14 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; import androidx.annotation.FloatRange; -import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.Objects; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** A rating expressed as a fractional number of stars. */ public final class StarRating extends Rating { @@ -106,22 +101,16 @@ public final class StarRating extends Rating { private static final @RatingType int TYPE = RATING_TYPE_STAR; private static final int MAX_STARS_DEFAULT = 5; - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_RATING_TYPE, FIELD_MAX_STARS, FIELD_STAR_RATING}) - private @interface FieldNumber {} - - private static final int FIELD_MAX_STARS = 1; - private static final int FIELD_STAR_RATING = 2; + private static final String FIELD_MAX_STARS = Util.intToStringMaxRadix(1); + private static final String FIELD_STAR_RATING = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_RATING_TYPE), TYPE); - bundle.putInt(keyForField(FIELD_MAX_STARS), maxStars); - bundle.putFloat(keyForField(FIELD_STAR_RATING), starRating); + bundle.putInt(FIELD_RATING_TYPE, TYPE); + bundle.putInt(FIELD_MAX_STARS, maxStars); + bundle.putFloat(FIELD_STAR_RATING, starRating); return bundle; } @@ -129,19 +118,11 @@ public final class StarRating extends Rating { @UnstableApi public static final Creator CREATOR = StarRating::fromBundle; private static StarRating fromBundle(Bundle bundle) { - checkArgument( - bundle.getInt(keyForField(FIELD_RATING_TYPE), /* defaultValue= */ RATING_TYPE_UNSET) - == TYPE); - int maxStars = - bundle.getInt(keyForField(FIELD_MAX_STARS), /* defaultValue= */ MAX_STARS_DEFAULT); - float starRating = - bundle.getFloat(keyForField(FIELD_STAR_RATING), /* defaultValue= */ RATING_UNSET); + checkArgument(bundle.getInt(FIELD_RATING_TYPE, /* defaultValue= */ RATING_TYPE_UNSET) == TYPE); + int maxStars = bundle.getInt(FIELD_MAX_STARS, /* defaultValue= */ MAX_STARS_DEFAULT); + float starRating = bundle.getFloat(FIELD_STAR_RATING, /* defaultValue= */ RATING_UNSET); return starRating == RATING_UNSET ? new StarRating(maxStars) : new StarRating(maxStars, starRating); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/ThumbRating.java b/libraries/common/src/main/java/androidx/media3/common/ThumbRating.java index cd4ad73473..b6e0cb0687 100644 --- a/libraries/common/src/main/java/androidx/media3/common/ThumbRating.java +++ b/libraries/common/src/main/java/androidx/media3/common/ThumbRating.java @@ -16,17 +16,12 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.Objects; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; /** A rating expressed as "thumbs up" or "thumbs down". */ public final class ThumbRating extends Rating { @@ -78,22 +73,16 @@ public final class ThumbRating extends Rating { private static final @RatingType int TYPE = RATING_TYPE_THUMB; - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_RATING_TYPE, FIELD_RATED, FIELD_IS_THUMBS_UP}) - private @interface FieldNumber {} - - private static final int FIELD_RATED = 1; - private static final int FIELD_IS_THUMBS_UP = 2; + private static final String FIELD_RATED = Util.intToStringMaxRadix(1); + private static final String FIELD_IS_THUMBS_UP = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_RATING_TYPE), TYPE); - bundle.putBoolean(keyForField(FIELD_RATED), rated); - bundle.putBoolean(keyForField(FIELD_IS_THUMBS_UP), isThumbsUp); + bundle.putInt(FIELD_RATING_TYPE, TYPE); + bundle.putBoolean(FIELD_RATED, rated); + bundle.putBoolean(FIELD_IS_THUMBS_UP, isThumbsUp); return bundle; } @@ -101,17 +90,10 @@ public final class ThumbRating extends Rating { @UnstableApi public static final Creator CREATOR = ThumbRating::fromBundle; private static ThumbRating fromBundle(Bundle bundle) { - checkArgument( - bundle.getInt(keyForField(FIELD_RATING_TYPE), /* defaultValue= */ RATING_TYPE_UNSET) - == TYPE); - boolean rated = bundle.getBoolean(keyForField(FIELD_RATED), /* defaultValue= */ false); + checkArgument(bundle.getInt(FIELD_RATING_TYPE, /* defaultValue= */ RATING_TYPE_UNSET) == TYPE); + boolean rated = bundle.getBoolean(FIELD_RATED, /* defaultValue= */ false); return rated - ? new ThumbRating( - bundle.getBoolean(keyForField(FIELD_IS_THUMBS_UP), /* defaultValue= */ false)) + ? new ThumbRating(bundle.getBoolean(FIELD_IS_THUMBS_UP, /* defaultValue= */ false)) : new ThumbRating(); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/Timeline.java b/libraries/common/src/main/java/androidx/media3/common/Timeline.java index 43dc1aed11..d470b37b52 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Timeline.java +++ b/libraries/common/src/main/java/androidx/media3/common/Timeline.java @@ -20,14 +20,12 @@ import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkState; import static java.lang.Math.max; import static java.lang.Math.min; -import static java.lang.annotation.ElementType.TYPE_USE; import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.os.SystemClock; import android.util.Pair; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.BundleUtil; @@ -36,10 +34,6 @@ import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.InlineMe; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.List; @@ -158,7 +152,7 @@ public abstract class Timeline implements Bundleable { private static final Object FAKE_WINDOW_UID = new Object(); - private static final MediaItem EMPTY_MEDIA_ITEM = + private static final MediaItem PLACEHOLDER_MEDIA_ITEM = new MediaItem.Builder() .setMediaId("androidx.media3.common.Timeline") .setUri(Uri.EMPTY) @@ -258,7 +252,7 @@ public abstract class Timeline implements Bundleable { /** Creates window. */ public Window() { uid = SINGLE_WINDOW_UID; - mediaItem = EMPTY_MEDIA_ITEM; + mediaItem = PLACEHOLDER_MEDIA_ITEM; } /** Sets the data held by this window. */ @@ -281,7 +275,7 @@ public abstract class Timeline implements Bundleable { int lastPeriodIndex, long positionInFirstPeriodUs) { this.uid = uid; - this.mediaItem = mediaItem != null ? mediaItem : EMPTY_MEDIA_ITEM; + this.mediaItem = mediaItem != null ? mediaItem : PLACEHOLDER_MEDIA_ITEM; this.tag = mediaItem != null && mediaItem.localConfiguration != null ? mediaItem.localConfiguration.tag @@ -420,63 +414,20 @@ public abstract class Timeline implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_MEDIA_ITEM, - FIELD_PRESENTATION_START_TIME_MS, - FIELD_WINDOW_START_TIME_MS, - FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS, - FIELD_IS_SEEKABLE, - FIELD_IS_DYNAMIC, - FIELD_LIVE_CONFIGURATION, - FIELD_IS_PLACEHOLDER, - FIELD_DEFAULT_POSITION_US, - FIELD_DURATION_US, - FIELD_FIRST_PERIOD_INDEX, - FIELD_LAST_PERIOD_INDEX, - FIELD_POSITION_IN_FIRST_PERIOD_US, - }) - private @interface FieldNumber {} - - private static final int FIELD_MEDIA_ITEM = 1; - private static final int FIELD_PRESENTATION_START_TIME_MS = 2; - private static final int FIELD_WINDOW_START_TIME_MS = 3; - private static final int FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS = 4; - private static final int FIELD_IS_SEEKABLE = 5; - private static final int FIELD_IS_DYNAMIC = 6; - private static final int FIELD_LIVE_CONFIGURATION = 7; - private static final int FIELD_IS_PLACEHOLDER = 8; - private static final int FIELD_DEFAULT_POSITION_US = 9; - private static final int FIELD_DURATION_US = 10; - private static final int FIELD_FIRST_PERIOD_INDEX = 11; - private static final int FIELD_LAST_PERIOD_INDEX = 12; - private static final int FIELD_POSITION_IN_FIRST_PERIOD_US = 13; - - private final Bundle toBundle(boolean excludeMediaItem) { - Bundle bundle = new Bundle(); - bundle.putBundle( - keyForField(FIELD_MEDIA_ITEM), - excludeMediaItem ? MediaItem.EMPTY.toBundle() : mediaItem.toBundle()); - bundle.putLong(keyForField(FIELD_PRESENTATION_START_TIME_MS), presentationStartTimeMs); - bundle.putLong(keyForField(FIELD_WINDOW_START_TIME_MS), windowStartTimeMs); - bundle.putLong( - keyForField(FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS), elapsedRealtimeEpochOffsetMs); - bundle.putBoolean(keyForField(FIELD_IS_SEEKABLE), isSeekable); - bundle.putBoolean(keyForField(FIELD_IS_DYNAMIC), isDynamic); - @Nullable MediaItem.LiveConfiguration liveConfiguration = this.liveConfiguration; - if (liveConfiguration != null) { - bundle.putBundle(keyForField(FIELD_LIVE_CONFIGURATION), liveConfiguration.toBundle()); - } - bundle.putBoolean(keyForField(FIELD_IS_PLACEHOLDER), isPlaceholder); - bundle.putLong(keyForField(FIELD_DEFAULT_POSITION_US), defaultPositionUs); - bundle.putLong(keyForField(FIELD_DURATION_US), durationUs); - bundle.putInt(keyForField(FIELD_FIRST_PERIOD_INDEX), firstPeriodIndex); - bundle.putInt(keyForField(FIELD_LAST_PERIOD_INDEX), lastPeriodIndex); - bundle.putLong(keyForField(FIELD_POSITION_IN_FIRST_PERIOD_US), positionInFirstPeriodUs); - return bundle; - } + private static final String FIELD_MEDIA_ITEM = Util.intToStringMaxRadix(1); + private static final String FIELD_PRESENTATION_START_TIME_MS = Util.intToStringMaxRadix(2); + private static final String FIELD_WINDOW_START_TIME_MS = Util.intToStringMaxRadix(3); + private static final String FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS = + Util.intToStringMaxRadix(4); + private static final String FIELD_IS_SEEKABLE = Util.intToStringMaxRadix(5); + private static final String FIELD_IS_DYNAMIC = Util.intToStringMaxRadix(6); + private static final String FIELD_LIVE_CONFIGURATION = Util.intToStringMaxRadix(7); + private static final String FIELD_IS_PLACEHOLDER = Util.intToStringMaxRadix(8); + private static final String FIELD_DEFAULT_POSITION_US = Util.intToStringMaxRadix(9); + private static final String FIELD_DURATION_US = Util.intToStringMaxRadix(10); + private static final String FIELD_FIRST_PERIOD_INDEX = Util.intToStringMaxRadix(11); + private static final String FIELD_LAST_PERIOD_INDEX = Util.intToStringMaxRadix(12); + private static final String FIELD_POSITION_IN_FIRST_PERIOD_US = Util.intToStringMaxRadix(13); /** * {@inheritDoc} @@ -485,11 +436,52 @@ public abstract class Timeline implements Bundleable { * restored by {@link #CREATOR} will be a fake {@link Object} and the {@link #manifest} of the * instance will be {@code null}. */ - // TODO(b/166765820): See if missing fields would be okay and add them to the Bundle otherwise. @UnstableApi @Override public Bundle toBundle() { - return toBundle(/* excludeMediaItem= */ false); + Bundle bundle = new Bundle(); + if (!MediaItem.EMPTY.equals(mediaItem)) { + bundle.putBundle(FIELD_MEDIA_ITEM, mediaItem.toBundle()); + } + if (presentationStartTimeMs != C.TIME_UNSET) { + bundle.putLong(FIELD_PRESENTATION_START_TIME_MS, presentationStartTimeMs); + } + if (windowStartTimeMs != C.TIME_UNSET) { + bundle.putLong(FIELD_WINDOW_START_TIME_MS, windowStartTimeMs); + } + if (elapsedRealtimeEpochOffsetMs != C.TIME_UNSET) { + bundle.putLong(FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS, elapsedRealtimeEpochOffsetMs); + } + if (isSeekable) { + bundle.putBoolean(FIELD_IS_SEEKABLE, isSeekable); + } + if (isDynamic) { + bundle.putBoolean(FIELD_IS_DYNAMIC, isDynamic); + } + + @Nullable MediaItem.LiveConfiguration liveConfiguration = this.liveConfiguration; + if (liveConfiguration != null) { + bundle.putBundle(FIELD_LIVE_CONFIGURATION, liveConfiguration.toBundle()); + } + if (isPlaceholder) { + bundle.putBoolean(FIELD_IS_PLACEHOLDER, isPlaceholder); + } + if (defaultPositionUs != 0) { + bundle.putLong(FIELD_DEFAULT_POSITION_US, defaultPositionUs); + } + if (durationUs != C.TIME_UNSET) { + bundle.putLong(FIELD_DURATION_US, durationUs); + } + if (firstPeriodIndex != 0) { + bundle.putInt(FIELD_FIRST_PERIOD_INDEX, firstPeriodIndex); + } + if (lastPeriodIndex != 0) { + bundle.putInt(FIELD_LAST_PERIOD_INDEX, lastPeriodIndex); + } + if (positionInFirstPeriodUs != 0) { + bundle.putLong(FIELD_POSITION_IN_FIRST_PERIOD_US, positionInFirstPeriodUs); + } + return bundle; } /** @@ -501,42 +493,31 @@ public abstract class Timeline implements Bundleable { @UnstableApi public static final Creator CREATOR = Window::fromBundle; private static Window fromBundle(Bundle bundle) { - @Nullable Bundle mediaItemBundle = bundle.getBundle(keyForField(FIELD_MEDIA_ITEM)); + @Nullable Bundle mediaItemBundle = bundle.getBundle(FIELD_MEDIA_ITEM); @Nullable MediaItem mediaItem = - mediaItemBundle != null ? MediaItem.CREATOR.fromBundle(mediaItemBundle) : null; + mediaItemBundle != null ? MediaItem.CREATOR.fromBundle(mediaItemBundle) : MediaItem.EMPTY; long presentationStartTimeMs = - bundle.getLong( - keyForField(FIELD_PRESENTATION_START_TIME_MS), /* defaultValue= */ C.TIME_UNSET); + bundle.getLong(FIELD_PRESENTATION_START_TIME_MS, /* defaultValue= */ C.TIME_UNSET); long windowStartTimeMs = - bundle.getLong(keyForField(FIELD_WINDOW_START_TIME_MS), /* defaultValue= */ C.TIME_UNSET); + bundle.getLong(FIELD_WINDOW_START_TIME_MS, /* defaultValue= */ C.TIME_UNSET); long elapsedRealtimeEpochOffsetMs = - bundle.getLong( - keyForField(FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS), - /* defaultValue= */ C.TIME_UNSET); - boolean isSeekable = - bundle.getBoolean(keyForField(FIELD_IS_SEEKABLE), /* defaultValue= */ false); - boolean isDynamic = - bundle.getBoolean(keyForField(FIELD_IS_DYNAMIC), /* defaultValue= */ false); - @Nullable - Bundle liveConfigurationBundle = bundle.getBundle(keyForField(FIELD_LIVE_CONFIGURATION)); + bundle.getLong(FIELD_ELAPSED_REALTIME_EPOCH_OFFSET_MS, /* defaultValue= */ C.TIME_UNSET); + boolean isSeekable = bundle.getBoolean(FIELD_IS_SEEKABLE, /* defaultValue= */ false); + boolean isDynamic = bundle.getBoolean(FIELD_IS_DYNAMIC, /* defaultValue= */ false); + @Nullable Bundle liveConfigurationBundle = bundle.getBundle(FIELD_LIVE_CONFIGURATION); @Nullable MediaItem.LiveConfiguration liveConfiguration = liveConfigurationBundle != null ? MediaItem.LiveConfiguration.CREATOR.fromBundle(liveConfigurationBundle) : null; - boolean isPlaceHolder = - bundle.getBoolean(keyForField(FIELD_IS_PLACEHOLDER), /* defaultValue= */ false); - long defaultPositionUs = - bundle.getLong(keyForField(FIELD_DEFAULT_POSITION_US), /* defaultValue= */ 0); - long durationUs = - bundle.getLong(keyForField(FIELD_DURATION_US), /* defaultValue= */ C.TIME_UNSET); - int firstPeriodIndex = - bundle.getInt(keyForField(FIELD_FIRST_PERIOD_INDEX), /* defaultValue= */ 0); - int lastPeriodIndex = - bundle.getInt(keyForField(FIELD_LAST_PERIOD_INDEX), /* defaultValue= */ 0); + boolean isPlaceHolder = bundle.getBoolean(FIELD_IS_PLACEHOLDER, /* defaultValue= */ false); + long defaultPositionUs = bundle.getLong(FIELD_DEFAULT_POSITION_US, /* defaultValue= */ 0); + long durationUs = bundle.getLong(FIELD_DURATION_US, /* defaultValue= */ C.TIME_UNSET); + int firstPeriodIndex = bundle.getInt(FIELD_FIRST_PERIOD_INDEX, /* defaultValue= */ 0); + int lastPeriodIndex = bundle.getInt(FIELD_LAST_PERIOD_INDEX, /* defaultValue= */ 0); long positionInFirstPeriodUs = - bundle.getLong(keyForField(FIELD_POSITION_IN_FIRST_PERIOD_US), /* defaultValue= */ 0); + bundle.getLong(FIELD_POSITION_IN_FIRST_PERIOD_US, /* defaultValue= */ 0); Window window = new Window(); window.set( @@ -557,10 +538,6 @@ public abstract class Timeline implements Bundleable { window.isPlaceholder = isPlaceHolder; return window; } - - private static String keyForField(@Window.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** @@ -912,23 +889,11 @@ public abstract class Timeline implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_WINDOW_INDEX, - FIELD_DURATION_US, - FIELD_POSITION_IN_WINDOW_US, - FIELD_PLACEHOLDER, - FIELD_AD_PLAYBACK_STATE - }) - private @interface FieldNumber {} - - private static final int FIELD_WINDOW_INDEX = 0; - private static final int FIELD_DURATION_US = 1; - private static final int FIELD_POSITION_IN_WINDOW_US = 2; - private static final int FIELD_PLACEHOLDER = 3; - private static final int FIELD_AD_PLAYBACK_STATE = 4; + private static final String FIELD_WINDOW_INDEX = Util.intToStringMaxRadix(0); + private static final String FIELD_DURATION_US = Util.intToStringMaxRadix(1); + private static final String FIELD_POSITION_IN_WINDOW_US = Util.intToStringMaxRadix(2); + private static final String FIELD_PLACEHOLDER = Util.intToStringMaxRadix(3); + private static final String FIELD_AD_PLAYBACK_STATE = Util.intToStringMaxRadix(4); /** * {@inheritDoc} @@ -936,16 +901,25 @@ public abstract class Timeline implements Bundleable { *

    It omits the {@link #id} and {@link #uid} fields so these fields of an instance restored * by {@link #CREATOR} will always be {@code null}. */ - // TODO(b/166765820): See if missing fields would be okay and add them to the Bundle otherwise. @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_WINDOW_INDEX), windowIndex); - bundle.putLong(keyForField(FIELD_DURATION_US), durationUs); - bundle.putLong(keyForField(FIELD_POSITION_IN_WINDOW_US), positionInWindowUs); - bundle.putBoolean(keyForField(FIELD_PLACEHOLDER), isPlaceholder); - bundle.putBundle(keyForField(FIELD_AD_PLAYBACK_STATE), adPlaybackState.toBundle()); + if (windowIndex != 0) { + bundle.putInt(FIELD_WINDOW_INDEX, windowIndex); + } + if (durationUs != C.TIME_UNSET) { + bundle.putLong(FIELD_DURATION_US, durationUs); + } + if (positionInWindowUs != 0) { + bundle.putLong(FIELD_POSITION_IN_WINDOW_US, positionInWindowUs); + } + if (isPlaceholder) { + bundle.putBoolean(FIELD_PLACEHOLDER, isPlaceholder); + } + if (!adPlaybackState.equals(AdPlaybackState.NONE)) { + bundle.putBundle(FIELD_AD_PLAYBACK_STATE, adPlaybackState.toBundle()); + } return bundle; } @@ -957,14 +931,11 @@ public abstract class Timeline implements Bundleable { @UnstableApi public static final Creator CREATOR = Period::fromBundle; private static Period fromBundle(Bundle bundle) { - int windowIndex = bundle.getInt(keyForField(FIELD_WINDOW_INDEX), /* defaultValue= */ 0); - long durationUs = - bundle.getLong(keyForField(FIELD_DURATION_US), /* defaultValue= */ C.TIME_UNSET); - long positionInWindowUs = - bundle.getLong(keyForField(FIELD_POSITION_IN_WINDOW_US), /* defaultValue= */ 0); - boolean isPlaceholder = bundle.getBoolean(keyForField(FIELD_PLACEHOLDER)); - @Nullable - Bundle adPlaybackStateBundle = bundle.getBundle(keyForField(FIELD_AD_PLAYBACK_STATE)); + int windowIndex = bundle.getInt(FIELD_WINDOW_INDEX, /* defaultValue= */ 0); + long durationUs = bundle.getLong(FIELD_DURATION_US, /* defaultValue= */ C.TIME_UNSET); + long positionInWindowUs = bundle.getLong(FIELD_POSITION_IN_WINDOW_US, /* defaultValue= */ 0); + boolean isPlaceholder = bundle.getBoolean(FIELD_PLACEHOLDER, /* defaultValue= */ false); + @Nullable Bundle adPlaybackStateBundle = bundle.getBundle(FIELD_AD_PLAYBACK_STATE); AdPlaybackState adPlaybackState = adPlaybackStateBundle != null ? AdPlaybackState.CREATOR.fromBundle(adPlaybackStateBundle) @@ -981,10 +952,6 @@ public abstract class Timeline implements Bundleable { isPlaceholder); return period; } - - private static String keyForField(@Period.FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** An empty timeline. */ @@ -1404,19 +1371,9 @@ public abstract class Timeline implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_WINDOWS, - FIELD_PERIODS, - FIELD_SHUFFLED_WINDOW_INDICES, - }) - private @interface FieldNumber {} - - private static final int FIELD_WINDOWS = 0; - private static final int FIELD_PERIODS = 1; - private static final int FIELD_SHUFFLED_WINDOW_INDICES = 2; + private static final String FIELD_WINDOWS = Util.intToStringMaxRadix(0); + private static final String FIELD_PERIODS = Util.intToStringMaxRadix(1); + private static final String FIELD_SHUFFLED_WINDOW_INDICES = Util.intToStringMaxRadix(2); /** * {@inheritDoc} @@ -1424,18 +1381,15 @@ public abstract class Timeline implements Bundleable { *

    The {@link #getWindow(int, Window)} windows} and {@link #getPeriod(int, Period) periods} of * an instance restored by {@link #CREATOR} may have missing fields as described in {@link * Window#toBundle()} and {@link Period#toBundle()}. - * - * @param excludeMediaItems Whether to exclude all {@link Window#mediaItem media items} of windows - * in the timeline. */ @UnstableApi - public final Bundle toBundle(boolean excludeMediaItems) { + @Override + public final Bundle toBundle() { List windowBundles = new ArrayList<>(); int windowCount = getWindowCount(); Window window = new Window(); for (int i = 0; i < windowCount; i++) { - windowBundles.add( - getWindow(i, window, /* defaultPositionProjectionUs= */ 0).toBundle(excludeMediaItems)); + windowBundles.add(getWindow(i, window, /* defaultPositionProjectionUs= */ 0).toBundle()); } List periodBundles = new ArrayList<>(); @@ -1456,25 +1410,43 @@ public abstract class Timeline implements Bundleable { } Bundle bundle = new Bundle(); - BundleUtil.putBinder( - bundle, keyForField(FIELD_WINDOWS), new BundleListRetriever(windowBundles)); - BundleUtil.putBinder( - bundle, keyForField(FIELD_PERIODS), new BundleListRetriever(periodBundles)); - bundle.putIntArray(keyForField(FIELD_SHUFFLED_WINDOW_INDICES), shuffledWindowIndices); + BundleUtil.putBinder(bundle, FIELD_WINDOWS, new BundleListRetriever(windowBundles)); + BundleUtil.putBinder(bundle, FIELD_PERIODS, new BundleListRetriever(periodBundles)); + bundle.putIntArray(FIELD_SHUFFLED_WINDOW_INDICES, shuffledWindowIndices); return bundle; } /** - * {@inheritDoc} + * Returns a {@link Bundle} containing just the specified {@link Window}. * *

    The {@link #getWindow(int, Window)} windows} and {@link #getPeriod(int, Period) periods} of * an instance restored by {@link #CREATOR} may have missing fields as described in {@link * Window#toBundle()} and {@link Period#toBundle()}. + * + * @param windowIndex The index of the {@link Window} to include in the {@link Bundle}. */ @UnstableApi - @Override - public final Bundle toBundle() { - return toBundle(/* excludeMediaItems= */ false); + public final Bundle toBundleWithOneWindowOnly(int windowIndex) { + Window window = getWindow(windowIndex, new Window(), /* defaultPositionProjectionUs= */ 0); + + List periodBundles = new ArrayList<>(); + Period period = new Period(); + for (int i = window.firstPeriodIndex; i <= window.lastPeriodIndex; i++) { + getPeriod(i, period, /* setIds= */ false); + period.windowIndex = 0; + periodBundles.add(period.toBundle()); + } + + window.lastPeriodIndex = window.lastPeriodIndex - window.firstPeriodIndex; + window.firstPeriodIndex = 0; + Bundle windowBundle = window.toBundle(); + + Bundle bundle = new Bundle(); + BundleUtil.putBinder( + bundle, FIELD_WINDOWS, new BundleListRetriever(ImmutableList.of(windowBundle))); + BundleUtil.putBinder(bundle, FIELD_PERIODS, new BundleListRetriever(periodBundles)); + bundle.putIntArray(FIELD_SHUFFLED_WINDOW_INDICES, new int[] {0}); + return bundle; } /** @@ -1488,13 +1460,10 @@ public abstract class Timeline implements Bundleable { private static Timeline fromBundle(Bundle bundle) { ImmutableList windows = - fromBundleListRetriever( - Window.CREATOR, BundleUtil.getBinder(bundle, keyForField(FIELD_WINDOWS))); + fromBundleListRetriever(Window.CREATOR, BundleUtil.getBinder(bundle, FIELD_WINDOWS)); ImmutableList periods = - fromBundleListRetriever( - Period.CREATOR, BundleUtil.getBinder(bundle, keyForField(FIELD_PERIODS))); - @Nullable - int[] shuffledWindowIndices = bundle.getIntArray(keyForField(FIELD_SHUFFLED_WINDOW_INDICES)); + fromBundleListRetriever(Period.CREATOR, BundleUtil.getBinder(bundle, FIELD_PERIODS)); + @Nullable int[] shuffledWindowIndices = bundle.getIntArray(FIELD_SHUFFLED_WINDOW_INDICES); return new RemotableTimeline( windows, periods, @@ -1516,10 +1485,6 @@ public abstract class Timeline implements Bundleable { return builder.build(); } - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - private static int[] generateUnshuffledIndices(int n) { int[] indices = new int[n]; for (int i = 0; i < n; i++) { diff --git a/libraries/common/src/main/java/androidx/media3/common/TrackGroup.java b/libraries/common/src/main/java/androidx/media3/common/TrackGroup.java index ce934111d5..f13d3dca4a 100644 --- a/libraries/common/src/main/java/androidx/media3/common/TrackGroup.java +++ b/libraries/common/src/main/java/androidx/media3/common/TrackGroup.java @@ -16,20 +16,15 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; import androidx.annotation.CheckResult; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -165,15 +160,8 @@ public final class TrackGroup implements Bundleable { } // Bundleable implementation. - - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_FORMATS, FIELD_ID}) - private @interface FieldNumber {} - - private static final int FIELD_FORMATS = 0; - private static final int FIELD_ID = 1; + private static final String FIELD_FORMATS = Util.intToStringMaxRadix(0); + private static final String FIELD_ID = Util.intToStringMaxRadix(1); @UnstableApi @Override @@ -183,8 +171,8 @@ public final class TrackGroup implements Bundleable { for (Format format : formats) { arrayList.add(format.toBundle(/* excludeMetadata= */ true)); } - bundle.putParcelableArrayList(keyForField(FIELD_FORMATS), arrayList); - bundle.putString(keyForField(FIELD_ID), id); + bundle.putParcelableArrayList(FIELD_FORMATS, arrayList); + bundle.putString(FIELD_ID, id); return bundle; } @@ -192,20 +180,15 @@ public final class TrackGroup implements Bundleable { @UnstableApi public static final Creator CREATOR = bundle -> { - @Nullable - List formatBundles = bundle.getParcelableArrayList(keyForField(FIELD_FORMATS)); + @Nullable List formatBundles = bundle.getParcelableArrayList(FIELD_FORMATS); List formats = formatBundles == null ? ImmutableList.of() : BundleableUtil.fromBundleList(Format.CREATOR, formatBundles); - String id = bundle.getString(keyForField(FIELD_ID), /* defaultValue= */ ""); + String id = bundle.getString(FIELD_ID, /* defaultValue= */ ""); return new TrackGroup(id, formats.toArray(new Format[0])); }; - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - private void verifyCorrectness() { // TrackGroups should only contain tracks with exactly the same content (but in different // qualities). We only log an error instead of throwing to not break backwards-compatibility for diff --git a/libraries/common/src/main/java/androidx/media3/common/TrackSelectionOverride.java b/libraries/common/src/main/java/androidx/media3/common/TrackSelectionOverride.java index a673e95bd8..c40e88b654 100644 --- a/libraries/common/src/main/java/androidx/media3/common/TrackSelectionOverride.java +++ b/libraries/common/src/main/java/androidx/media3/common/TrackSelectionOverride.java @@ -20,14 +20,11 @@ import static java.util.Collections.max; import static java.util.Collections.min; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Ints; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; import java.util.List; /** @@ -54,16 +51,8 @@ public final class TrackSelectionOverride implements Bundleable { /** The indices of tracks in a {@link TrackGroup} to be selected. */ public final ImmutableList trackIndices; - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - FIELD_TRACK_GROUP, - FIELD_TRACKS, - }) - private @interface FieldNumber {} - - private static final int FIELD_TRACK_GROUP = 0; - private static final int FIELD_TRACKS = 1; + private static final String FIELD_TRACK_GROUP = Util.intToStringMaxRadix(0); + private static final String FIELD_TRACKS = Util.intToStringMaxRadix(1); /** * Constructs an instance to force {@code trackIndex} in {@code trackGroup} to be selected. @@ -119,8 +108,8 @@ public final class TrackSelectionOverride implements Bundleable { @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putBundle(keyForField(FIELD_TRACK_GROUP), mediaTrackGroup.toBundle()); - bundle.putIntArray(keyForField(FIELD_TRACKS), Ints.toArray(trackIndices)); + bundle.putBundle(FIELD_TRACK_GROUP, mediaTrackGroup.toBundle()); + bundle.putIntArray(FIELD_TRACKS, Ints.toArray(trackIndices)); return bundle; } @@ -128,13 +117,9 @@ public final class TrackSelectionOverride implements Bundleable { @UnstableApi public static final Creator CREATOR = bundle -> { - Bundle trackGroupBundle = checkNotNull(bundle.getBundle(keyForField(FIELD_TRACK_GROUP))); + Bundle trackGroupBundle = checkNotNull(bundle.getBundle(FIELD_TRACK_GROUP)); TrackGroup mediaTrackGroup = TrackGroup.CREATOR.fromBundle(trackGroupBundle); - int[] tracks = checkNotNull(bundle.getIntArray(keyForField(FIELD_TRACKS))); + int[] tracks = checkNotNull(bundle.getIntArray(FIELD_TRACKS)); return new TrackSelectionOverride(mediaTrackGroup, Ints.asList(tracks)); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/TrackSelectionParameters.java b/libraries/common/src/main/java/androidx/media3/common/TrackSelectionParameters.java index 1c2f7a633a..b65bc9400a 100644 --- a/libraries/common/src/main/java/androidx/media3/common/TrackSelectionParameters.java +++ b/libraries/common/src/main/java/androidx/media3/common/TrackSelectionParameters.java @@ -158,95 +158,71 @@ public class TrackSelectionParameters implements Bundleable { @UnstableApi protected Builder(Bundle bundle) { // Video - maxVideoWidth = - bundle.getInt(keyForField(FIELD_MAX_VIDEO_WIDTH), DEFAULT_WITHOUT_CONTEXT.maxVideoWidth); + maxVideoWidth = bundle.getInt(FIELD_MAX_VIDEO_WIDTH, DEFAULT_WITHOUT_CONTEXT.maxVideoWidth); maxVideoHeight = - bundle.getInt( - keyForField(FIELD_MAX_VIDEO_HEIGHT), DEFAULT_WITHOUT_CONTEXT.maxVideoHeight); + bundle.getInt(FIELD_MAX_VIDEO_HEIGHT, DEFAULT_WITHOUT_CONTEXT.maxVideoHeight); maxVideoFrameRate = - bundle.getInt( - keyForField(FIELD_MAX_VIDEO_FRAMERATE), DEFAULT_WITHOUT_CONTEXT.maxVideoFrameRate); + bundle.getInt(FIELD_MAX_VIDEO_FRAMERATE, DEFAULT_WITHOUT_CONTEXT.maxVideoFrameRate); maxVideoBitrate = - bundle.getInt( - keyForField(FIELD_MAX_VIDEO_BITRATE), DEFAULT_WITHOUT_CONTEXT.maxVideoBitrate); - minVideoWidth = - bundle.getInt(keyForField(FIELD_MIN_VIDEO_WIDTH), DEFAULT_WITHOUT_CONTEXT.minVideoWidth); + bundle.getInt(FIELD_MAX_VIDEO_BITRATE, DEFAULT_WITHOUT_CONTEXT.maxVideoBitrate); + minVideoWidth = bundle.getInt(FIELD_MIN_VIDEO_WIDTH, DEFAULT_WITHOUT_CONTEXT.minVideoWidth); minVideoHeight = - bundle.getInt( - keyForField(FIELD_MIN_VIDEO_HEIGHT), DEFAULT_WITHOUT_CONTEXT.minVideoHeight); + bundle.getInt(FIELD_MIN_VIDEO_HEIGHT, DEFAULT_WITHOUT_CONTEXT.minVideoHeight); minVideoFrameRate = - bundle.getInt( - keyForField(FIELD_MIN_VIDEO_FRAMERATE), DEFAULT_WITHOUT_CONTEXT.minVideoFrameRate); + bundle.getInt(FIELD_MIN_VIDEO_FRAMERATE, DEFAULT_WITHOUT_CONTEXT.minVideoFrameRate); minVideoBitrate = - bundle.getInt( - keyForField(FIELD_MIN_VIDEO_BITRATE), DEFAULT_WITHOUT_CONTEXT.minVideoBitrate); - viewportWidth = - bundle.getInt(keyForField(FIELD_VIEWPORT_WIDTH), DEFAULT_WITHOUT_CONTEXT.viewportWidth); - viewportHeight = - bundle.getInt(keyForField(FIELD_VIEWPORT_HEIGHT), DEFAULT_WITHOUT_CONTEXT.viewportHeight); + bundle.getInt(FIELD_MIN_VIDEO_BITRATE, DEFAULT_WITHOUT_CONTEXT.minVideoBitrate); + viewportWidth = bundle.getInt(FIELD_VIEWPORT_WIDTH, DEFAULT_WITHOUT_CONTEXT.viewportWidth); + viewportHeight = bundle.getInt(FIELD_VIEWPORT_HEIGHT, DEFAULT_WITHOUT_CONTEXT.viewportHeight); viewportOrientationMayChange = bundle.getBoolean( - keyForField(FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE), + FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE, DEFAULT_WITHOUT_CONTEXT.viewportOrientationMayChange); preferredVideoMimeTypes = ImmutableList.copyOf( - firstNonNull( - bundle.getStringArray(keyForField(FIELD_PREFERRED_VIDEO_MIMETYPES)), - new String[0])); + firstNonNull(bundle.getStringArray(FIELD_PREFERRED_VIDEO_MIMETYPES), new String[0])); preferredVideoRoleFlags = bundle.getInt( - keyForField(FIELD_PREFERRED_VIDEO_ROLE_FLAGS), - DEFAULT_WITHOUT_CONTEXT.preferredVideoRoleFlags); + FIELD_PREFERRED_VIDEO_ROLE_FLAGS, DEFAULT_WITHOUT_CONTEXT.preferredVideoRoleFlags); // Audio String[] preferredAudioLanguages1 = - firstNonNull( - bundle.getStringArray(keyForField(FIELD_PREFERRED_AUDIO_LANGUAGES)), new String[0]); + firstNonNull(bundle.getStringArray(FIELD_PREFERRED_AUDIO_LANGUAGES), new String[0]); preferredAudioLanguages = normalizeLanguageCodes(preferredAudioLanguages1); preferredAudioRoleFlags = bundle.getInt( - keyForField(FIELD_PREFERRED_AUDIO_ROLE_FLAGS), - DEFAULT_WITHOUT_CONTEXT.preferredAudioRoleFlags); + FIELD_PREFERRED_AUDIO_ROLE_FLAGS, DEFAULT_WITHOUT_CONTEXT.preferredAudioRoleFlags); maxAudioChannelCount = bundle.getInt( - keyForField(FIELD_MAX_AUDIO_CHANNEL_COUNT), - DEFAULT_WITHOUT_CONTEXT.maxAudioChannelCount); + FIELD_MAX_AUDIO_CHANNEL_COUNT, DEFAULT_WITHOUT_CONTEXT.maxAudioChannelCount); maxAudioBitrate = - bundle.getInt( - keyForField(FIELD_MAX_AUDIO_BITRATE), DEFAULT_WITHOUT_CONTEXT.maxAudioBitrate); + bundle.getInt(FIELD_MAX_AUDIO_BITRATE, DEFAULT_WITHOUT_CONTEXT.maxAudioBitrate); preferredAudioMimeTypes = ImmutableList.copyOf( - firstNonNull( - bundle.getStringArray(keyForField(FIELD_PREFERRED_AUDIO_MIME_TYPES)), - new String[0])); + firstNonNull(bundle.getStringArray(FIELD_PREFERRED_AUDIO_MIME_TYPES), new String[0])); // Text preferredTextLanguages = normalizeLanguageCodes( - firstNonNull( - bundle.getStringArray(keyForField(FIELD_PREFERRED_TEXT_LANGUAGES)), - new String[0])); + firstNonNull(bundle.getStringArray(FIELD_PREFERRED_TEXT_LANGUAGES), new String[0])); preferredTextRoleFlags = bundle.getInt( - keyForField(FIELD_PREFERRED_TEXT_ROLE_FLAGS), - DEFAULT_WITHOUT_CONTEXT.preferredTextRoleFlags); + FIELD_PREFERRED_TEXT_ROLE_FLAGS, DEFAULT_WITHOUT_CONTEXT.preferredTextRoleFlags); ignoredTextSelectionFlags = bundle.getInt( - keyForField(FIELD_IGNORED_TEXT_SELECTION_FLAGS), + FIELD_IGNORED_TEXT_SELECTION_FLAGS, DEFAULT_WITHOUT_CONTEXT.ignoredTextSelectionFlags); selectUndeterminedTextLanguage = bundle.getBoolean( - keyForField(FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE), + FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE, DEFAULT_WITHOUT_CONTEXT.selectUndeterminedTextLanguage); // General forceLowestBitrate = - bundle.getBoolean( - keyForField(FIELD_FORCE_LOWEST_BITRATE), DEFAULT_WITHOUT_CONTEXT.forceLowestBitrate); + bundle.getBoolean(FIELD_FORCE_LOWEST_BITRATE, DEFAULT_WITHOUT_CONTEXT.forceLowestBitrate); forceHighestSupportedBitrate = bundle.getBoolean( - keyForField(FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE), + FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE, DEFAULT_WITHOUT_CONTEXT.forceHighestSupportedBitrate); @Nullable - List overrideBundleList = - bundle.getParcelableArrayList(keyForField(FIELD_SELECTION_OVERRIDES)); + List overrideBundleList = bundle.getParcelableArrayList(FIELD_SELECTION_OVERRIDES); List overrideList = overrideBundleList == null ? ImmutableList.of() @@ -257,7 +233,7 @@ public class TrackSelectionParameters implements Bundleable { overrides.put(override.mediaTrackGroup, override); } int[] disabledTrackTypeArray = - firstNonNull(bundle.getIntArray(keyForField(FIELD_DISABLED_TRACK_TYPE)), new int[0]); + firstNonNull(bundle.getIntArray(FIELD_DISABLED_TRACK_TYPE), new int[0]); disabledTrackTypes = new HashSet<>(); for (@C.TrackType int disabledTrackType : disabledTrackTypeArray) { disabledTrackTypes.add(disabledTrackType); @@ -1103,39 +1079,40 @@ public class TrackSelectionParameters implements Bundleable { // Bundleable implementation - private static final int FIELD_PREFERRED_AUDIO_LANGUAGES = 1; - private static final int FIELD_PREFERRED_AUDIO_ROLE_FLAGS = 2; - private static final int FIELD_PREFERRED_TEXT_LANGUAGES = 3; - private static final int FIELD_PREFERRED_TEXT_ROLE_FLAGS = 4; - private static final int FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE = 5; - private static final int FIELD_MAX_VIDEO_WIDTH = 6; - private static final int FIELD_MAX_VIDEO_HEIGHT = 7; - private static final int FIELD_MAX_VIDEO_FRAMERATE = 8; - private static final int FIELD_MAX_VIDEO_BITRATE = 9; - private static final int FIELD_MIN_VIDEO_WIDTH = 10; - private static final int FIELD_MIN_VIDEO_HEIGHT = 11; - private static final int FIELD_MIN_VIDEO_FRAMERATE = 12; - private static final int FIELD_MIN_VIDEO_BITRATE = 13; - private static final int FIELD_VIEWPORT_WIDTH = 14; - private static final int FIELD_VIEWPORT_HEIGHT = 15; - private static final int FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE = 16; - private static final int FIELD_PREFERRED_VIDEO_MIMETYPES = 17; - private static final int FIELD_MAX_AUDIO_CHANNEL_COUNT = 18; - private static final int FIELD_MAX_AUDIO_BITRATE = 19; - private static final int FIELD_PREFERRED_AUDIO_MIME_TYPES = 20; - private static final int FIELD_FORCE_LOWEST_BITRATE = 21; - private static final int FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE = 22; - private static final int FIELD_SELECTION_OVERRIDES = 23; - private static final int FIELD_DISABLED_TRACK_TYPE = 24; - private static final int FIELD_PREFERRED_VIDEO_ROLE_FLAGS = 25; - private static final int FIELD_IGNORED_TEXT_SELECTION_FLAGS = 26; + private static final String FIELD_PREFERRED_AUDIO_LANGUAGES = Util.intToStringMaxRadix(1); + private static final String FIELD_PREFERRED_AUDIO_ROLE_FLAGS = Util.intToStringMaxRadix(2); + private static final String FIELD_PREFERRED_TEXT_LANGUAGES = Util.intToStringMaxRadix(3); + private static final String FIELD_PREFERRED_TEXT_ROLE_FLAGS = Util.intToStringMaxRadix(4); + private static final String FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE = Util.intToStringMaxRadix(5); + private static final String FIELD_MAX_VIDEO_WIDTH = Util.intToStringMaxRadix(6); + private static final String FIELD_MAX_VIDEO_HEIGHT = Util.intToStringMaxRadix(7); + private static final String FIELD_MAX_VIDEO_FRAMERATE = Util.intToStringMaxRadix(8); + private static final String FIELD_MAX_VIDEO_BITRATE = Util.intToStringMaxRadix(9); + private static final String FIELD_MIN_VIDEO_WIDTH = Util.intToStringMaxRadix(10); + private static final String FIELD_MIN_VIDEO_HEIGHT = Util.intToStringMaxRadix(11); + private static final String FIELD_MIN_VIDEO_FRAMERATE = Util.intToStringMaxRadix(12); + private static final String FIELD_MIN_VIDEO_BITRATE = Util.intToStringMaxRadix(13); + private static final String FIELD_VIEWPORT_WIDTH = Util.intToStringMaxRadix(14); + private static final String FIELD_VIEWPORT_HEIGHT = Util.intToStringMaxRadix(15); + private static final String FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE = Util.intToStringMaxRadix(16); + private static final String FIELD_PREFERRED_VIDEO_MIMETYPES = Util.intToStringMaxRadix(17); + private static final String FIELD_MAX_AUDIO_CHANNEL_COUNT = Util.intToStringMaxRadix(18); + private static final String FIELD_MAX_AUDIO_BITRATE = Util.intToStringMaxRadix(19); + private static final String FIELD_PREFERRED_AUDIO_MIME_TYPES = Util.intToStringMaxRadix(20); + private static final String FIELD_FORCE_LOWEST_BITRATE = Util.intToStringMaxRadix(21); + private static final String FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE = Util.intToStringMaxRadix(22); + private static final String FIELD_SELECTION_OVERRIDES = Util.intToStringMaxRadix(23); + private static final String FIELD_DISABLED_TRACK_TYPE = Util.intToStringMaxRadix(24); + private static final String FIELD_PREFERRED_VIDEO_ROLE_FLAGS = Util.intToStringMaxRadix(25); + private static final String FIELD_IGNORED_TEXT_SELECTION_FLAGS = Util.intToStringMaxRadix(26); /** * Defines a minimum field ID value for subclasses to use when implementing {@link #toBundle()} * and {@link Bundleable.Creator}. * *

    Subclasses should obtain keys for their {@link Bundle} representation by applying a - * non-negative offset on this constant and passing the result to {@link #keyForField(int)}. + * non-negative offset on this constant and passing the result to {@link + * Util#intToStringMaxRadix(int)}. */ @UnstableApi protected static final int FIELD_CUSTOM_ID_BASE = 1000; @@ -1144,46 +1121,39 @@ public class TrackSelectionParameters implements Bundleable { Bundle bundle = new Bundle(); // Video - bundle.putInt(keyForField(FIELD_MAX_VIDEO_WIDTH), maxVideoWidth); - bundle.putInt(keyForField(FIELD_MAX_VIDEO_HEIGHT), maxVideoHeight); - bundle.putInt(keyForField(FIELD_MAX_VIDEO_FRAMERATE), maxVideoFrameRate); - bundle.putInt(keyForField(FIELD_MAX_VIDEO_BITRATE), maxVideoBitrate); - bundle.putInt(keyForField(FIELD_MIN_VIDEO_WIDTH), minVideoWidth); - bundle.putInt(keyForField(FIELD_MIN_VIDEO_HEIGHT), minVideoHeight); - bundle.putInt(keyForField(FIELD_MIN_VIDEO_FRAMERATE), minVideoFrameRate); - bundle.putInt(keyForField(FIELD_MIN_VIDEO_BITRATE), minVideoBitrate); - bundle.putInt(keyForField(FIELD_VIEWPORT_WIDTH), viewportWidth); - bundle.putInt(keyForField(FIELD_VIEWPORT_HEIGHT), viewportHeight); - bundle.putBoolean( - keyForField(FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE), viewportOrientationMayChange); + bundle.putInt(FIELD_MAX_VIDEO_WIDTH, maxVideoWidth); + bundle.putInt(FIELD_MAX_VIDEO_HEIGHT, maxVideoHeight); + bundle.putInt(FIELD_MAX_VIDEO_FRAMERATE, maxVideoFrameRate); + bundle.putInt(FIELD_MAX_VIDEO_BITRATE, maxVideoBitrate); + bundle.putInt(FIELD_MIN_VIDEO_WIDTH, minVideoWidth); + bundle.putInt(FIELD_MIN_VIDEO_HEIGHT, minVideoHeight); + bundle.putInt(FIELD_MIN_VIDEO_FRAMERATE, minVideoFrameRate); + bundle.putInt(FIELD_MIN_VIDEO_BITRATE, minVideoBitrate); + bundle.putInt(FIELD_VIEWPORT_WIDTH, viewportWidth); + bundle.putInt(FIELD_VIEWPORT_HEIGHT, viewportHeight); + bundle.putBoolean(FIELD_VIEWPORT_ORIENTATION_MAY_CHANGE, viewportOrientationMayChange); bundle.putStringArray( - keyForField(FIELD_PREFERRED_VIDEO_MIMETYPES), - preferredVideoMimeTypes.toArray(new String[0])); - bundle.putInt(keyForField(FIELD_PREFERRED_VIDEO_ROLE_FLAGS), preferredVideoRoleFlags); + FIELD_PREFERRED_VIDEO_MIMETYPES, preferredVideoMimeTypes.toArray(new String[0])); + bundle.putInt(FIELD_PREFERRED_VIDEO_ROLE_FLAGS, preferredVideoRoleFlags); // Audio bundle.putStringArray( - keyForField(FIELD_PREFERRED_AUDIO_LANGUAGES), - preferredAudioLanguages.toArray(new String[0])); - bundle.putInt(keyForField(FIELD_PREFERRED_AUDIO_ROLE_FLAGS), preferredAudioRoleFlags); - bundle.putInt(keyForField(FIELD_MAX_AUDIO_CHANNEL_COUNT), maxAudioChannelCount); - bundle.putInt(keyForField(FIELD_MAX_AUDIO_BITRATE), maxAudioBitrate); + FIELD_PREFERRED_AUDIO_LANGUAGES, preferredAudioLanguages.toArray(new String[0])); + bundle.putInt(FIELD_PREFERRED_AUDIO_ROLE_FLAGS, preferredAudioRoleFlags); + bundle.putInt(FIELD_MAX_AUDIO_CHANNEL_COUNT, maxAudioChannelCount); + bundle.putInt(FIELD_MAX_AUDIO_BITRATE, maxAudioBitrate); bundle.putStringArray( - keyForField(FIELD_PREFERRED_AUDIO_MIME_TYPES), - preferredAudioMimeTypes.toArray(new String[0])); + FIELD_PREFERRED_AUDIO_MIME_TYPES, preferredAudioMimeTypes.toArray(new String[0])); // Text bundle.putStringArray( - keyForField(FIELD_PREFERRED_TEXT_LANGUAGES), preferredTextLanguages.toArray(new String[0])); - bundle.putInt(keyForField(FIELD_PREFERRED_TEXT_ROLE_FLAGS), preferredTextRoleFlags); - bundle.putInt(keyForField(FIELD_IGNORED_TEXT_SELECTION_FLAGS), ignoredTextSelectionFlags); - bundle.putBoolean( - keyForField(FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE), selectUndeterminedTextLanguage); + FIELD_PREFERRED_TEXT_LANGUAGES, preferredTextLanguages.toArray(new String[0])); + bundle.putInt(FIELD_PREFERRED_TEXT_ROLE_FLAGS, preferredTextRoleFlags); + bundle.putInt(FIELD_IGNORED_TEXT_SELECTION_FLAGS, ignoredTextSelectionFlags); + bundle.putBoolean(FIELD_SELECT_UNDETERMINED_TEXT_LANGUAGE, selectUndeterminedTextLanguage); // General - bundle.putBoolean(keyForField(FIELD_FORCE_LOWEST_BITRATE), forceLowestBitrate); - bundle.putBoolean( - keyForField(FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE), forceHighestSupportedBitrate); - bundle.putParcelableArrayList( - keyForField(FIELD_SELECTION_OVERRIDES), toBundleArrayList(overrides.values())); - bundle.putIntArray(keyForField(FIELD_DISABLED_TRACK_TYPE), Ints.toArray(disabledTrackTypes)); + bundle.putBoolean(FIELD_FORCE_LOWEST_BITRATE, forceLowestBitrate); + bundle.putBoolean(FIELD_FORCE_HIGHEST_SUPPORTED_BITRATE, forceHighestSupportedBitrate); + bundle.putParcelableArrayList(FIELD_SELECTION_OVERRIDES, toBundleArrayList(overrides.values())); + bundle.putIntArray(FIELD_DISABLED_TRACK_TYPE, Ints.toArray(disabledTrackTypes)); return bundle; } @@ -1199,16 +1169,4 @@ public class TrackSelectionParameters implements Bundleable { @UnstableApi @Deprecated public static final Creator CREATOR = TrackSelectionParameters::fromBundle; - - /** - * Converts the given field number to a string which can be used as a field key when implementing - * {@link #toBundle()} and {@link Bundleable.Creator}. - * - *

    Subclasses should use {@code field} values greater than or equal to {@link - * #FIELD_CUSTOM_ID_BASE}. - */ - @UnstableApi - protected static String keyForField(int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/Tracks.java b/libraries/common/src/main/java/androidx/media3/common/Tracks.java index 6da0a9204c..28d2e89a97 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Tracks.java +++ b/libraries/common/src/main/java/androidx/media3/common/Tracks.java @@ -18,20 +18,15 @@ package androidx.media3.common; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.BundleableUtil.toBundleArrayList; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Booleans; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.Arrays; import java.util.List; @@ -221,29 +216,19 @@ public final class Tracks implements Bundleable { } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TRACK_GROUP, - FIELD_TRACK_SUPPORT, - FIELD_TRACK_SELECTED, - FIELD_ADAPTIVE_SUPPORTED, - }) - private @interface FieldNumber {} - private static final int FIELD_TRACK_GROUP = 0; - private static final int FIELD_TRACK_SUPPORT = 1; - private static final int FIELD_TRACK_SELECTED = 3; - private static final int FIELD_ADAPTIVE_SUPPORTED = 4; + private static final String FIELD_TRACK_GROUP = Util.intToStringMaxRadix(0); + private static final String FIELD_TRACK_SUPPORT = Util.intToStringMaxRadix(1); + private static final String FIELD_TRACK_SELECTED = Util.intToStringMaxRadix(3); + private static final String FIELD_ADAPTIVE_SUPPORTED = Util.intToStringMaxRadix(4); @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putBundle(keyForField(FIELD_TRACK_GROUP), mediaTrackGroup.toBundle()); - bundle.putIntArray(keyForField(FIELD_TRACK_SUPPORT), trackSupport); - bundle.putBooleanArray(keyForField(FIELD_TRACK_SELECTED), trackSelected); - bundle.putBoolean(keyForField(FIELD_ADAPTIVE_SUPPORTED), adaptiveSupported); + bundle.putBundle(FIELD_TRACK_GROUP, mediaTrackGroup.toBundle()); + bundle.putIntArray(FIELD_TRACK_SUPPORT, trackSupport); + bundle.putBooleanArray(FIELD_TRACK_SELECTED, trackSelected); + bundle.putBoolean(FIELD_ADAPTIVE_SUPPORTED, adaptiveSupported); return bundle; } @@ -253,23 +238,16 @@ public final class Tracks implements Bundleable { bundle -> { // Can't create a Tracks.Group without a TrackGroup TrackGroup trackGroup = - TrackGroup.CREATOR.fromBundle( - checkNotNull(bundle.getBundle(keyForField(FIELD_TRACK_GROUP)))); + TrackGroup.CREATOR.fromBundle(checkNotNull(bundle.getBundle(FIELD_TRACK_GROUP))); final @C.FormatSupport int[] trackSupport = MoreObjects.firstNonNull( - bundle.getIntArray(keyForField(FIELD_TRACK_SUPPORT)), new int[trackGroup.length]); + bundle.getIntArray(FIELD_TRACK_SUPPORT), new int[trackGroup.length]); boolean[] selected = MoreObjects.firstNonNull( - bundle.getBooleanArray(keyForField(FIELD_TRACK_SELECTED)), - new boolean[trackGroup.length]); - boolean adaptiveSupported = - bundle.getBoolean(keyForField(FIELD_ADAPTIVE_SUPPORTED), false); + bundle.getBooleanArray(FIELD_TRACK_SELECTED), new boolean[trackGroup.length]); + boolean adaptiveSupported = bundle.getBoolean(FIELD_ADAPTIVE_SUPPORTED, false); return new Group(trackGroup, adaptiveSupported, trackSupport, selected); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** Empty tracks. */ @@ -385,21 +363,13 @@ public final class Tracks implements Bundleable { } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TRACK_GROUPS, - }) - private @interface FieldNumber {} - - private static final int FIELD_TRACK_GROUPS = 0; + private static final String FIELD_TRACK_GROUPS = Util.intToStringMaxRadix(0); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(keyForField(FIELD_TRACK_GROUPS), toBundleArrayList(groups)); + bundle.putParcelableArrayList(FIELD_TRACK_GROUPS, toBundleArrayList(groups)); return bundle; } @@ -407,16 +377,11 @@ public final class Tracks implements Bundleable { @UnstableApi public static final Creator CREATOR = bundle -> { - @Nullable - List groupBundles = bundle.getParcelableArrayList(keyForField(FIELD_TRACK_GROUPS)); + @Nullable List groupBundles = bundle.getParcelableArrayList(FIELD_TRACK_GROUPS); List groups = groupBundles == null ? ImmutableList.of() : BundleableUtil.fromBundleList(Group.CREATOR, groupBundles); return new Tracks(groups); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/VideoSize.java b/libraries/common/src/main/java/androidx/media3/common/VideoSize.java index 9c25c257ae..fee94edbcd 100644 --- a/libraries/common/src/main/java/androidx/media3/common/VideoSize.java +++ b/libraries/common/src/main/java/androidx/media3/common/VideoSize.java @@ -15,18 +15,12 @@ */ package androidx.media3.common; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; import androidx.annotation.FloatRange; -import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import androidx.media3.common.util.Util; /** Represents the video size. */ public final class VideoSize implements Bundleable { @@ -132,48 +126,32 @@ public final class VideoSize implements Bundleable { } // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_WIDTH, - FIELD_HEIGHT, - FIELD_UNAPPLIED_ROTATION_DEGREES, - FIELD_PIXEL_WIDTH_HEIGHT_RATIO, - }) - private @interface FieldNumber {} - private static final int FIELD_WIDTH = 0; - private static final int FIELD_HEIGHT = 1; - private static final int FIELD_UNAPPLIED_ROTATION_DEGREES = 2; - private static final int FIELD_PIXEL_WIDTH_HEIGHT_RATIO = 3; + private static final String FIELD_WIDTH = Util.intToStringMaxRadix(0); + private static final String FIELD_HEIGHT = Util.intToStringMaxRadix(1); + private static final String FIELD_UNAPPLIED_ROTATION_DEGREES = Util.intToStringMaxRadix(2); + private static final String FIELD_PIXEL_WIDTH_HEIGHT_RATIO = Util.intToStringMaxRadix(3); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_WIDTH), width); - bundle.putInt(keyForField(FIELD_HEIGHT), height); - bundle.putInt(keyForField(FIELD_UNAPPLIED_ROTATION_DEGREES), unappliedRotationDegrees); - bundle.putFloat(keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), pixelWidthHeightRatio); + bundle.putInt(FIELD_WIDTH, width); + bundle.putInt(FIELD_HEIGHT, height); + bundle.putInt(FIELD_UNAPPLIED_ROTATION_DEGREES, unappliedRotationDegrees); + bundle.putFloat(FIELD_PIXEL_WIDTH_HEIGHT_RATIO, pixelWidthHeightRatio); return bundle; } @UnstableApi public static final Creator CREATOR = bundle -> { - int width = bundle.getInt(keyForField(FIELD_WIDTH), DEFAULT_WIDTH); - int height = bundle.getInt(keyForField(FIELD_HEIGHT), DEFAULT_HEIGHT); + int width = bundle.getInt(FIELD_WIDTH, DEFAULT_WIDTH); + int height = bundle.getInt(FIELD_HEIGHT, DEFAULT_HEIGHT); int unappliedRotationDegrees = - bundle.getInt( - keyForField(FIELD_UNAPPLIED_ROTATION_DEGREES), DEFAULT_UNAPPLIED_ROTATION_DEGREES); + bundle.getInt(FIELD_UNAPPLIED_ROTATION_DEGREES, DEFAULT_UNAPPLIED_ROTATION_DEGREES); float pixelWidthHeightRatio = - bundle.getFloat( - keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), DEFAULT_PIXEL_WIDTH_HEIGHT_RATIO); + bundle.getFloat(FIELD_PIXEL_WIDTH_HEIGHT_RATIO, DEFAULT_PIXEL_WIDTH_HEIGHT_RATIO); return new VideoSize(width, height, unappliedRotationDegrees, pixelWidthHeightRatio); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/text/Cue.java b/libraries/common/src/main/java/androidx/media3/common/text/Cue.java index 475a29f9d3..cb50d1005f 100644 --- a/libraries/common/src/main/java/androidx/media3/common/text/Cue.java +++ b/libraries/common/src/main/java/androidx/media3/common/text/Cue.java @@ -35,6 +35,7 @@ import androidx.annotation.Nullable; import androidx.media3.common.Bundleable; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.base.Objects; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.lang.annotation.Documented; @@ -977,69 +978,45 @@ public final class Cue implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TEXT, - FIELD_TEXT_ALIGNMENT, - FIELD_MULTI_ROW_ALIGNMENT, - FIELD_BITMAP, - FIELD_LINE, - FIELD_LINE_TYPE, - FIELD_LINE_ANCHOR, - FIELD_POSITION, - FIELD_POSITION_ANCHOR, - FIELD_TEXT_SIZE_TYPE, - FIELD_TEXT_SIZE, - FIELD_SIZE, - FIELD_BITMAP_HEIGHT, - FIELD_WINDOW_COLOR, - FIELD_WINDOW_COLOR_SET, - FIELD_VERTICAL_TYPE, - FIELD_SHEAR_DEGREES - }) - private @interface FieldNumber {} - - private static final int FIELD_TEXT = 0; - private static final int FIELD_TEXT_ALIGNMENT = 1; - private static final int FIELD_MULTI_ROW_ALIGNMENT = 2; - private static final int FIELD_BITMAP = 3; - private static final int FIELD_LINE = 4; - private static final int FIELD_LINE_TYPE = 5; - private static final int FIELD_LINE_ANCHOR = 6; - private static final int FIELD_POSITION = 7; - private static final int FIELD_POSITION_ANCHOR = 8; - private static final int FIELD_TEXT_SIZE_TYPE = 9; - private static final int FIELD_TEXT_SIZE = 10; - private static final int FIELD_SIZE = 11; - private static final int FIELD_BITMAP_HEIGHT = 12; - private static final int FIELD_WINDOW_COLOR = 13; - private static final int FIELD_WINDOW_COLOR_SET = 14; - private static final int FIELD_VERTICAL_TYPE = 15; - private static final int FIELD_SHEAR_DEGREES = 16; + private static final String FIELD_TEXT = Util.intToStringMaxRadix(0); + private static final String FIELD_TEXT_ALIGNMENT = Util.intToStringMaxRadix(1); + private static final String FIELD_MULTI_ROW_ALIGNMENT = Util.intToStringMaxRadix(2); + private static final String FIELD_BITMAP = Util.intToStringMaxRadix(3); + private static final String FIELD_LINE = Util.intToStringMaxRadix(4); + private static final String FIELD_LINE_TYPE = Util.intToStringMaxRadix(5); + private static final String FIELD_LINE_ANCHOR = Util.intToStringMaxRadix(6); + private static final String FIELD_POSITION = Util.intToStringMaxRadix(7); + private static final String FIELD_POSITION_ANCHOR = Util.intToStringMaxRadix(8); + private static final String FIELD_TEXT_SIZE_TYPE = Util.intToStringMaxRadix(9); + private static final String FIELD_TEXT_SIZE = Util.intToStringMaxRadix(10); + private static final String FIELD_SIZE = Util.intToStringMaxRadix(11); + private static final String FIELD_BITMAP_HEIGHT = Util.intToStringMaxRadix(12); + private static final String FIELD_WINDOW_COLOR = Util.intToStringMaxRadix(13); + private static final String FIELD_WINDOW_COLOR_SET = Util.intToStringMaxRadix(14); + private static final String FIELD_VERTICAL_TYPE = Util.intToStringMaxRadix(15); + private static final String FIELD_SHEAR_DEGREES = Util.intToStringMaxRadix(16); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putCharSequence(keyForField(FIELD_TEXT), text); - bundle.putSerializable(keyForField(FIELD_TEXT_ALIGNMENT), textAlignment); - bundle.putSerializable(keyForField(FIELD_MULTI_ROW_ALIGNMENT), multiRowAlignment); - bundle.putParcelable(keyForField(FIELD_BITMAP), bitmap); - bundle.putFloat(keyForField(FIELD_LINE), line); - bundle.putInt(keyForField(FIELD_LINE_TYPE), lineType); - bundle.putInt(keyForField(FIELD_LINE_ANCHOR), lineAnchor); - bundle.putFloat(keyForField(FIELD_POSITION), position); - bundle.putInt(keyForField(FIELD_POSITION_ANCHOR), positionAnchor); - bundle.putInt(keyForField(FIELD_TEXT_SIZE_TYPE), textSizeType); - bundle.putFloat(keyForField(FIELD_TEXT_SIZE), textSize); - bundle.putFloat(keyForField(FIELD_SIZE), size); - bundle.putFloat(keyForField(FIELD_BITMAP_HEIGHT), bitmapHeight); - bundle.putBoolean(keyForField(FIELD_WINDOW_COLOR_SET), windowColorSet); - bundle.putInt(keyForField(FIELD_WINDOW_COLOR), windowColor); - bundle.putInt(keyForField(FIELD_VERTICAL_TYPE), verticalType); - bundle.putFloat(keyForField(FIELD_SHEAR_DEGREES), shearDegrees); + bundle.putCharSequence(FIELD_TEXT, text); + bundle.putSerializable(FIELD_TEXT_ALIGNMENT, textAlignment); + bundle.putSerializable(FIELD_MULTI_ROW_ALIGNMENT, multiRowAlignment); + bundle.putParcelable(FIELD_BITMAP, bitmap); + bundle.putFloat(FIELD_LINE, line); + bundle.putInt(FIELD_LINE_TYPE, lineType); + bundle.putInt(FIELD_LINE_ANCHOR, lineAnchor); + bundle.putFloat(FIELD_POSITION, position); + bundle.putInt(FIELD_POSITION_ANCHOR, positionAnchor); + bundle.putInt(FIELD_TEXT_SIZE_TYPE, textSizeType); + bundle.putFloat(FIELD_TEXT_SIZE, textSize); + bundle.putFloat(FIELD_SIZE, size); + bundle.putFloat(FIELD_BITMAP_HEIGHT, bitmapHeight); + bundle.putBoolean(FIELD_WINDOW_COLOR_SET, windowColorSet); + bundle.putInt(FIELD_WINDOW_COLOR, windowColor); + bundle.putInt(FIELD_VERTICAL_TYPE, verticalType); + bundle.putFloat(FIELD_SHEAR_DEGREES, shearDegrees); return bundle; } @@ -1047,67 +1024,56 @@ public final class Cue implements Bundleable { private static final Cue fromBundle(Bundle bundle) { Builder builder = new Builder(); - @Nullable CharSequence text = bundle.getCharSequence(keyForField(FIELD_TEXT)); + @Nullable CharSequence text = bundle.getCharSequence(FIELD_TEXT); if (text != null) { builder.setText(text); } - @Nullable - Alignment textAlignment = (Alignment) bundle.getSerializable(keyForField(FIELD_TEXT_ALIGNMENT)); + @Nullable Alignment textAlignment = (Alignment) bundle.getSerializable(FIELD_TEXT_ALIGNMENT); if (textAlignment != null) { builder.setTextAlignment(textAlignment); } @Nullable - Alignment multiRowAlignment = - (Alignment) bundle.getSerializable(keyForField(FIELD_MULTI_ROW_ALIGNMENT)); + Alignment multiRowAlignment = (Alignment) bundle.getSerializable(FIELD_MULTI_ROW_ALIGNMENT); if (multiRowAlignment != null) { builder.setMultiRowAlignment(multiRowAlignment); } - @Nullable Bitmap bitmap = bundle.getParcelable(keyForField(FIELD_BITMAP)); + @Nullable Bitmap bitmap = bundle.getParcelable(FIELD_BITMAP); if (bitmap != null) { builder.setBitmap(bitmap); } - if (bundle.containsKey(keyForField(FIELD_LINE)) - && bundle.containsKey(keyForField(FIELD_LINE_TYPE))) { - builder.setLine( - bundle.getFloat(keyForField(FIELD_LINE)), bundle.getInt(keyForField(FIELD_LINE_TYPE))); + if (bundle.containsKey(FIELD_LINE) && bundle.containsKey(FIELD_LINE_TYPE)) { + builder.setLine(bundle.getFloat(FIELD_LINE), bundle.getInt(FIELD_LINE_TYPE)); } - if (bundle.containsKey(keyForField(FIELD_LINE_ANCHOR))) { - builder.setLineAnchor(bundle.getInt(keyForField(FIELD_LINE_ANCHOR))); + if (bundle.containsKey(FIELD_LINE_ANCHOR)) { + builder.setLineAnchor(bundle.getInt(FIELD_LINE_ANCHOR)); } - if (bundle.containsKey(keyForField(FIELD_POSITION))) { - builder.setPosition(bundle.getFloat(keyForField(FIELD_POSITION))); + if (bundle.containsKey(FIELD_POSITION)) { + builder.setPosition(bundle.getFloat(FIELD_POSITION)); } - if (bundle.containsKey(keyForField(FIELD_POSITION_ANCHOR))) { - builder.setPositionAnchor(bundle.getInt(keyForField(FIELD_POSITION_ANCHOR))); + if (bundle.containsKey(FIELD_POSITION_ANCHOR)) { + builder.setPositionAnchor(bundle.getInt(FIELD_POSITION_ANCHOR)); } - if (bundle.containsKey(keyForField(FIELD_TEXT_SIZE)) - && bundle.containsKey(keyForField(FIELD_TEXT_SIZE_TYPE))) { - builder.setTextSize( - bundle.getFloat(keyForField(FIELD_TEXT_SIZE)), - bundle.getInt(keyForField(FIELD_TEXT_SIZE_TYPE))); + if (bundle.containsKey(FIELD_TEXT_SIZE) && bundle.containsKey(FIELD_TEXT_SIZE_TYPE)) { + builder.setTextSize(bundle.getFloat(FIELD_TEXT_SIZE), bundle.getInt(FIELD_TEXT_SIZE_TYPE)); } - if (bundle.containsKey(keyForField(FIELD_SIZE))) { - builder.setSize(bundle.getFloat(keyForField(FIELD_SIZE))); + if (bundle.containsKey(FIELD_SIZE)) { + builder.setSize(bundle.getFloat(FIELD_SIZE)); } - if (bundle.containsKey(keyForField(FIELD_BITMAP_HEIGHT))) { - builder.setBitmapHeight(bundle.getFloat(keyForField(FIELD_BITMAP_HEIGHT))); + if (bundle.containsKey(FIELD_BITMAP_HEIGHT)) { + builder.setBitmapHeight(bundle.getFloat(FIELD_BITMAP_HEIGHT)); } - if (bundle.containsKey(keyForField(FIELD_WINDOW_COLOR))) { - builder.setWindowColor(bundle.getInt(keyForField(FIELD_WINDOW_COLOR))); + if (bundle.containsKey(FIELD_WINDOW_COLOR)) { + builder.setWindowColor(bundle.getInt(FIELD_WINDOW_COLOR)); } - if (!bundle.getBoolean(keyForField(FIELD_WINDOW_COLOR_SET), /* defaultValue= */ false)) { + if (!bundle.getBoolean(FIELD_WINDOW_COLOR_SET, /* defaultValue= */ false)) { builder.clearWindowColor(); } - if (bundle.containsKey(keyForField(FIELD_VERTICAL_TYPE))) { - builder.setVerticalType(bundle.getInt(keyForField(FIELD_VERTICAL_TYPE))); + if (bundle.containsKey(FIELD_VERTICAL_TYPE)) { + builder.setVerticalType(bundle.getInt(FIELD_VERTICAL_TYPE)); } - if (bundle.containsKey(keyForField(FIELD_SHEAR_DEGREES))) { - builder.setShearDegrees(bundle.getFloat(keyForField(FIELD_SHEAR_DEGREES))); + if (bundle.containsKey(FIELD_SHEAR_DEGREES)) { + builder.setShearDegrees(bundle.getFloat(FIELD_SHEAR_DEGREES)); } return builder.build(); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/common/src/main/java/androidx/media3/common/text/CueGroup.java b/libraries/common/src/main/java/androidx/media3/common/text/CueGroup.java index df11b6fda8..a77a75c66c 100644 --- a/libraries/common/src/main/java/androidx/media3/common/text/CueGroup.java +++ b/libraries/common/src/main/java/androidx/media3/common/text/CueGroup.java @@ -15,21 +15,15 @@ */ package androidx.media3.common.text; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.graphics.Bitmap; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.Bundleable; import androidx.media3.common.Timeline; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.List; @@ -66,41 +60,31 @@ public final class CueGroup implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_CUES, FIELD_PRESENTATION_TIME_US}) - private @interface FieldNumber {} - - private static final int FIELD_CUES = 0; - private static final int FIELD_PRESENTATION_TIME_US = 1; + private static final String FIELD_CUES = Util.intToStringMaxRadix(0); + private static final String FIELD_PRESENTATION_TIME_US = Util.intToStringMaxRadix(1); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); bundle.putParcelableArrayList( - keyForField(FIELD_CUES), BundleableUtil.toBundleArrayList(filterOutBitmapCues(cues))); - bundle.putLong(keyForField(FIELD_PRESENTATION_TIME_US), presentationTimeUs); + FIELD_CUES, BundleableUtil.toBundleArrayList(filterOutBitmapCues(cues))); + bundle.putLong(FIELD_PRESENTATION_TIME_US, presentationTimeUs); return bundle; } @UnstableApi public static final Creator CREATOR = CueGroup::fromBundle; private static final CueGroup fromBundle(Bundle bundle) { - @Nullable ArrayList cueBundles = bundle.getParcelableArrayList(keyForField(FIELD_CUES)); + @Nullable ArrayList cueBundles = bundle.getParcelableArrayList(FIELD_CUES); List cues = cueBundles == null ? ImmutableList.of() : BundleableUtil.fromBundleList(Cue.CREATOR, cueBundles); - long presentationTimeUs = bundle.getLong(keyForField(FIELD_PRESENTATION_TIME_US)); + long presentationTimeUs = bundle.getLong(FIELD_PRESENTATION_TIME_US); return new CueGroup(cues, presentationTimeUs); } - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } - /** * Filters out {@link Cue} objects containing {@link Bitmap}. It is used when transferring cues * between processes to prevent transferring too much data. diff --git a/libraries/common/src/main/java/androidx/media3/common/util/ListenerSet.java b/libraries/common/src/main/java/androidx/media3/common/util/ListenerSet.java index 78e529ae3a..0ab3bab541 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/ListenerSet.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/ListenerSet.java @@ -15,9 +15,12 @@ */ package androidx.media3.common.util; +import static androidx.media3.common.util.Assertions.checkState; + import android.os.Looper; import android.os.Message; import androidx.annotation.CheckResult; +import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.FlagSet; @@ -34,6 +37,9 @@ import org.checkerframework.checker.nullness.qual.NonNull; *

    Events are also guaranteed to be only sent to the listeners registered at the time the event * was enqueued and haven't been removed since. * + *

    All methods must be called on the {@link Looper} passed to the constructor unless indicated + * otherwise. + * * @param The listener type. */ @UnstableApi @@ -76,14 +82,18 @@ public final class ListenerSet { private final CopyOnWriteArraySet> listeners; private final ArrayDeque flushingEvents; private final ArrayDeque queuedEvents; + private final Object releasedLock; + @GuardedBy("releasedLock") private boolean released; + private boolean throwsWhenUsingWrongThread; + /** * Creates a new listener set. * * @param looper A {@link Looper} used to call listeners on. The same {@link Looper} must be used - * to call all other methods of this class. + * to call all other methods of this class unless indicated otherwise. * @param clock A {@link Clock}. * @param iterationFinishedEvent An {@link IterationFinishedEvent} sent when all other events sent * during one {@link Looper} message queue iteration were handled by the listeners. @@ -100,17 +110,21 @@ public final class ListenerSet { this.clock = clock; this.listeners = listeners; this.iterationFinishedEvent = iterationFinishedEvent; + releasedLock = new Object(); flushingEvents = new ArrayDeque<>(); queuedEvents = new ArrayDeque<>(); // It's safe to use "this" because we don't send a message before exiting the constructor. @SuppressWarnings("nullness:methodref.receiver.bound") HandlerWrapper handler = clock.createHandler(looper, this::handleMessage); this.handler = handler; + throwsWhenUsingWrongThread = true; } /** * Copies the listener set. * + *

    This method can be called from any thread. + * * @param looper The new {@link Looper} for the copied listener set. * @param iterationFinishedEvent The new {@link IterationFinishedEvent} sent when all other events * sent during one {@link Looper} message queue iteration were handled by the listeners. @@ -124,6 +138,8 @@ public final class ListenerSet { /** * Copies the listener set. * + *

    This method can be called from any thread. + * * @param looper The new {@link Looper} for the copied listener set. * @param clock The new {@link Clock} for the copied listener set. * @param iterationFinishedEvent The new {@link IterationFinishedEvent} sent when all other events @@ -141,14 +157,18 @@ public final class ListenerSet { * *

    If a listener is already present, it will not be added again. * + *

    This method can be called from any thread. + * * @param listener The listener to be added. */ public void add(T listener) { - if (released) { - return; - } Assertions.checkNotNull(listener); - listeners.add(new ListenerHolder<>(listener)); + synchronized (releasedLock) { + if (released) { + return; + } + listeners.add(new ListenerHolder<>(listener)); + } } /** @@ -159,6 +179,7 @@ public final class ListenerSet { * @param listener The listener to be removed. */ public void remove(T listener) { + verifyCurrentThread(); for (ListenerHolder listenerHolder : listeners) { if (listenerHolder.listener.equals(listener)) { listenerHolder.release(iterationFinishedEvent); @@ -169,11 +190,13 @@ public final class ListenerSet { /** Removes all listeners from the set. */ public void clear() { + verifyCurrentThread(); listeners.clear(); } /** Returns the number of added listeners. */ public int size() { + verifyCurrentThread(); return listeners.size(); } @@ -185,6 +208,7 @@ public final class ListenerSet { * @param event The event. */ public void queueEvent(int eventFlag, Event event) { + verifyCurrentThread(); CopyOnWriteArraySet> listenerSnapshot = new CopyOnWriteArraySet<>(listeners); queuedEvents.add( () -> { @@ -196,6 +220,7 @@ public final class ListenerSet { /** Notifies listeners of events previously enqueued with {@link #queueEvent(int, Event)}. */ public void flushEvents() { + verifyCurrentThread(); if (queuedEvents.isEmpty()) { return; } @@ -234,11 +259,27 @@ public final class ListenerSet { *

    This will ensure no events are sent to any listener after this method has been called. */ public void release() { + verifyCurrentThread(); + synchronized (releasedLock) { + released = true; + } for (ListenerHolder listenerHolder : listeners) { listenerHolder.release(iterationFinishedEvent); } listeners.clear(); - released = true; + } + + /** + * Sets whether methods throw when using the wrong thread. + * + *

    Do not use this method unless to support legacy use cases. + * + * @param throwsWhenUsingWrongThread Whether to throw when using the wrong thread. + * @deprecated Do not use this method and ensure all calls are made from the correct thread. + */ + @Deprecated + public void setThrowsWhenUsingWrongThread(boolean throwsWhenUsingWrongThread) { + this.throwsWhenUsingWrongThread = throwsWhenUsingWrongThread; } private boolean handleMessage(Message message) { @@ -254,6 +295,13 @@ public final class ListenerSet { return true; } + private void verifyCurrentThread() { + if (!throwsWhenUsingWrongThread) { + return; + } + checkState(Thread.currentThread() == handler.getLooper().getThread()); + } + private static final class ListenerHolder { public final T listener; diff --git a/libraries/common/src/main/java/androidx/media3/common/util/ParsableByteArray.java b/libraries/common/src/main/java/androidx/media3/common/util/ParsableByteArray.java index 0367ab8f22..bd1117bc78 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/ParsableByteArray.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/ParsableByteArray.java @@ -17,6 +17,9 @@ package androidx.media3.common.util; import androidx.annotation.Nullable; import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Chars; +import com.google.common.primitives.UnsignedBytes; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.util.Arrays; @@ -28,6 +31,12 @@ import java.util.Arrays; @UnstableApi public final class ParsableByteArray { + private static final char[] CR_AND_LF = {'\r', '\n'}; + private static final char[] LF = {'\n'}; + private static final ImmutableSet SUPPORTED_CHARSETS_FOR_READLINE = + ImmutableSet.of( + Charsets.US_ASCII, Charsets.UTF_8, Charsets.UTF_16, Charsets.UTF_16BE, Charsets.UTF_16LE); + private byte[] data; private int position; // TODO(internal b/147657250): Enforce this limit on all read methods. @@ -490,45 +499,47 @@ public final class ParsableByteArray { } /** - * Reads a line of text. + * Reads a line of text in UTF-8. * - *

    A line is considered to be terminated by any one of a carriage return ('\r'), a line feed - * ('\n'), or a carriage return followed immediately by a line feed ('\r\n'). The UTF-8 charset is - * used. This method discards leading UTF-8 byte order marks, if present. - * - * @return The line not including any line-termination characters, or null if the end of the data - * has already been reached. + *

    Equivalent to passing {@link Charsets#UTF_8} to {@link #readLine(Charset)}. */ @Nullable public String readLine() { + return readLine(Charsets.UTF_8); + } + + /** + * Reads a line of text in {@code charset}. + * + *

    A line is considered to be terminated by any one of a carriage return ('\r'), a line feed + * ('\n'), or a carriage return followed immediately by a line feed ('\r\n'). This method discards + * leading UTF byte order marks (BOM), if present. + * + *

    The {@linkplain #getPosition() position} is advanced to start of the next line (i.e. any + * line terminators are skipped). + * + * @param charset The charset used to interpret the bytes as a {@link String}. + * @return The line not including any line-termination characters, or null if the end of the data + * has already been reached. + * @throws IllegalArgumentException if charset is not supported. Only US_ASCII, UTF-8, UTF-16, + * UTF-16BE, and UTF-16LE are supported. + */ + @Nullable + public String readLine(Charset charset) { + Assertions.checkArgument( + SUPPORTED_CHARSETS_FOR_READLINE.contains(charset), "Unsupported charset: " + charset); if (bytesLeft() == 0) { return null; } - int lineLimit = position; - while (lineLimit < limit && !Util.isLinebreak(data[lineLimit])) { - lineLimit++; + if (!charset.equals(Charsets.US_ASCII)) { + readUtfCharsetFromBom(); // Skip BOM if present } - if (lineLimit - position >= 3 - && data[position] == (byte) 0xEF - && data[position + 1] == (byte) 0xBB - && data[position + 2] == (byte) 0xBF) { - // There's a UTF-8 byte order mark at the start of the line. Discard it. - position += 3; - } - String line = Util.fromUtf8Bytes(data, position, lineLimit - position); - position = lineLimit; + int lineLimit = findNextLineTerminator(charset); + String line = readString(lineLimit - position, charset); if (position == limit) { return line; } - if (data[position] == '\r') { - position++; - if (position == limit) { - return line; - } - } - if (data[position] == '\n') { - position++; - } + skipLineTerminator(charset); return line; } @@ -566,4 +577,99 @@ public final class ParsableByteArray { position += length; return value; } + + /** + * Reads a UTF byte order mark (BOM) and returns the UTF {@link Charset} it represents. Returns + * {@code null} without advancing {@link #getPosition() position} if no BOM is found. + */ + @Nullable + public Charset readUtfCharsetFromBom() { + if (bytesLeft() >= 3 + && data[position] == (byte) 0xEF + && data[position + 1] == (byte) 0xBB + && data[position + 2] == (byte) 0xBF) { + position += 3; + return Charsets.UTF_8; + } else if (bytesLeft() >= 2) { + if (data[position] == (byte) 0xFE && data[position + 1] == (byte) 0xFF) { + position += 2; + return Charsets.UTF_16BE; + } else if (data[position] == (byte) 0xFF && data[position + 1] == (byte) 0xFE) { + position += 2; + return Charsets.UTF_16LE; + } + } + return null; + } + + /** + * Returns the index of the next occurrence of '\n' or '\r', or {@link #limit} if none is found. + */ + private int findNextLineTerminator(Charset charset) { + int stride; + if (charset.equals(Charsets.UTF_8) || charset.equals(Charsets.US_ASCII)) { + stride = 1; + } else if (charset.equals(Charsets.UTF_16) + || charset.equals(Charsets.UTF_16LE) + || charset.equals(Charsets.UTF_16BE)) { + stride = 2; + } else { + throw new IllegalArgumentException("Unsupported charset: " + charset); + } + for (int i = position; i < limit - (stride - 1); i += stride) { + if ((charset.equals(Charsets.UTF_8) || charset.equals(Charsets.US_ASCII)) + && Util.isLinebreak(data[i])) { + return i; + } else if ((charset.equals(Charsets.UTF_16) || charset.equals(Charsets.UTF_16BE)) + && data[i] == 0x00 + && Util.isLinebreak(data[i + 1])) { + return i; + } else if (charset.equals(Charsets.UTF_16LE) + && data[i + 1] == 0x00 + && Util.isLinebreak(data[i])) { + return i; + } + } + return limit; + } + + private void skipLineTerminator(Charset charset) { + if (readCharacterIfInList(charset, CR_AND_LF) == '\r') { + readCharacterIfInList(charset, LF); + } + } + + /** + * Peeks at the character at {@link #position} (as decoded by {@code charset}), returns it and + * advances {@link #position} past it if it's in {@code chars}, otherwise returns {@code 0} + * without advancing {@link #position}. Returns {@code 0} if {@link #bytesLeft()} doesn't allow + * reading a whole character in {@code charset}. + * + *

    Only supports characters in {@code chars} that occupy a single code unit (i.e. one byte for + * UTF-8 and two bytes for UTF-16). + */ + private char readCharacterIfInList(Charset charset, char[] chars) { + char character; + int characterSize; + if ((charset.equals(Charsets.UTF_8) || charset.equals(Charsets.US_ASCII)) && bytesLeft() >= 1) { + character = Chars.checkedCast(UnsignedBytes.toInt(data[position])); + characterSize = 1; + } else if ((charset.equals(Charsets.UTF_16) || charset.equals(Charsets.UTF_16BE)) + && bytesLeft() >= 2) { + character = Chars.fromBytes(data[position], data[position + 1]); + characterSize = 2; + } else if (charset.equals(Charsets.UTF_16LE) && bytesLeft() >= 2) { + character = Chars.fromBytes(data[position + 1], data[position]); + characterSize = 2; + } else { + return 0; + } + + if (Chars.contains(chars, character)) { + position += characterSize; + return Chars.checkedCast(character); + } else { + return 0; + } + } } diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Size.java b/libraries/common/src/main/java/androidx/media3/common/util/Size.java index dddb834edd..5ffaac5911 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Size.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Size.java @@ -29,6 +29,9 @@ public final class Size { public static final Size UNKNOWN = new Size(/* width= */ C.LENGTH_UNSET, /* height= */ C.LENGTH_UNSET); + /* A static instance to represent a size of zero height and width. */ + public static final Size ZERO = new Size(/* width= */ 0, /* height= */ 0); + private final int width; private final int height; diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Util.java b/libraries/common/src/main/java/androidx/media3/common/util/Util.java index 30d94ef39e..464db2648d 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Util.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Util.java @@ -47,6 +47,7 @@ import android.content.res.Resources; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.graphics.Point; +import android.graphics.drawable.Drawable; import android.hardware.display.DisplayManager; import android.media.AudioFormat; import android.media.AudioManager; @@ -66,6 +67,8 @@ import android.util.SparseLongArray; import android.view.Display; import android.view.SurfaceView; import android.view.WindowManager; +import androidx.annotation.DoNotInline; +import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.media3.common.C; @@ -2864,6 +2867,33 @@ public final class Util { return sum; } + /** + * Returns a {@link Drawable} for the given resource or throws a {@link + * Resources.NotFoundException} if not found. + * + * @param context The context to get the theme from starting with API 21. + * @param resources The resources to load the drawable from. + * @param drawableRes The drawable resource int. + * @return The loaded {@link Drawable}. + */ + @UnstableApi + public static Drawable getDrawable( + Context context, Resources resources, @DrawableRes int drawableRes) { + return SDK_INT >= 21 + ? Api21.getDrawable(context, resources, drawableRes) + : resources.getDrawable(drawableRes); + } + + /** + * Returns a string representation of the integer using radix value {@link Character#MAX_RADIX}. + * + * @param i An integer to be converted to String. + */ + @UnstableApi + public static String intToStringMaxRadix(int i) { + return Integer.toString(i, Character.MAX_RADIX); + } + @Nullable private static String getSystemProperty(String name) { try { @@ -3100,4 +3130,12 @@ public final class Util { 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3 }; + + @RequiresApi(21) + private static final class Api21 { + @DoNotInline + public static Drawable getDrawable(Context context, Resources resources, @DrawableRes int res) { + return resources.getDrawable(res, context.getTheme()); + } + } } diff --git a/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java index d398cd5b0f..6a07dce3dc 100644 --- a/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/AdPlaybackStateTest.java @@ -24,6 +24,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; import android.net.Uri; +import android.os.Bundle; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Assert; import org.junit.Test; @@ -402,7 +403,43 @@ public class AdPlaybackStateTest { } @Test - public void roundTripViaBundle_yieldsEqualFieldsExceptAdsId() { + public void adPlaybackStateWithNoAds_checkValues() { + AdPlaybackState adPlaybackStateWithNoAds = AdPlaybackState.NONE; + + // Please refrain from altering these values since doing so would cause issues with backwards + // compatibility. + assertThat(adPlaybackStateWithNoAds.adsId).isNull(); + assertThat(adPlaybackStateWithNoAds.adGroupCount).isEqualTo(0); + assertThat(adPlaybackStateWithNoAds.adResumePositionUs).isEqualTo(0); + assertThat(adPlaybackStateWithNoAds.contentDurationUs).isEqualTo(C.TIME_UNSET); + assertThat(adPlaybackStateWithNoAds.removedAdGroupCount).isEqualTo(0); + } + + @Test + public void adPlaybackStateWithNoAds_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + AdPlaybackState adPlaybackStateWithNoAds = AdPlaybackState.NONE; + + Bundle adPlaybackStateWithNoAdsBundle = adPlaybackStateWithNoAds.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(adPlaybackStateWithNoAdsBundle.keySet()).isEmpty(); + + AdPlaybackState adPlaybackStateWithNoAdsFromBundle = + AdPlaybackState.CREATOR.fromBundle(adPlaybackStateWithNoAdsBundle); + + assertThat(adPlaybackStateWithNoAdsFromBundle.adsId).isEqualTo(adPlaybackStateWithNoAds.adsId); + assertThat(adPlaybackStateWithNoAdsFromBundle.adGroupCount) + .isEqualTo(adPlaybackStateWithNoAds.adGroupCount); + assertThat(adPlaybackStateWithNoAdsFromBundle.adResumePositionUs) + .isEqualTo(adPlaybackStateWithNoAds.adResumePositionUs); + assertThat(adPlaybackStateWithNoAdsFromBundle.contentDurationUs) + .isEqualTo(adPlaybackStateWithNoAds.contentDurationUs); + assertThat(adPlaybackStateWithNoAdsFromBundle.removedAdGroupCount) + .isEqualTo(adPlaybackStateWithNoAds.removedAdGroupCount); + } + + @Test + public void createAdPlaybackState_roundTripViaBundle_yieldsEqualFieldsExceptAdsId() { AdPlaybackState originalState = new AdPlaybackState(TEST_ADS_ID, TEST_AD_GROUP_TIMES_US) .withRemovedAdGroupCount(1) diff --git a/libraries/common/src/test/java/androidx/media3/common/BasePlayerTest.java b/libraries/common/src/test/java/androidx/media3/common/BasePlayerTest.java new file mode 100644 index 0000000000..4f3c677f66 --- /dev/null +++ b/libraries/common/src/test/java/androidx/media3/common/BasePlayerTest.java @@ -0,0 +1,318 @@ +/* + * Copyright 2022 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.common; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import androidx.media3.test.utils.FakeTimeline; +import androidx.media3.test.utils.StubPlayer; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link BasePlayer}. */ +@RunWith(AndroidJUnit4.class) +public class BasePlayerTest { + + @Test + public void seekTo_withIndexAndPosition_usesCommandSeekToMediaItem() { + BasePlayer player = spy(new TestBasePlayer()); + + player.seekTo(/* mediaItemIndex= */ 2, /* positionMs= */ 4000); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 2, + /* positionMs= */ 4000, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekTo_withPosition_usesCommandSeekInCurrentMediaItem() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + }); + + player.seekTo(/* positionMs= */ 4000); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 1, + /* positionMs= */ 4000, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToDefaultPosition_withIndex_usesCommandSeekToMediaItem() { + BasePlayer player = spy(new TestBasePlayer()); + + player.seekToDefaultPosition(/* mediaItemIndex= */ 2); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 2, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToDefaultPosition_withoutIndex_usesCommandSeekToDefaultPosition() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + }); + + player.seekToDefaultPosition(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 1, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_DEFAULT_POSITION, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToNext_usesCommandSeekToNext() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + }); + + player.seekToNext(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 2, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_NEXT, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToNextMediaItem_usesCommandSeekToNextMediaItem() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + }); + + player.seekToNextMediaItem(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 2, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekForward_usesCommandSeekForward() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public long getSeekForwardIncrement() { + return 2000; + } + + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + + @Override + public long getCurrentPosition() { + return 5000; + } + }); + + player.seekForward(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 1, + /* positionMs= */ 7000, + Player.COMMAND_SEEK_FORWARD, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToPrevious_usesCommandSeekToPrevious() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + + @Override + public long getMaxSeekToPreviousPosition() { + return 4000; + } + + @Override + public long getCurrentPosition() { + return 2000; + } + }); + + player.seekToPrevious(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 0, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_PREVIOUS, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekToPreviousMediaItem_usesCommandSeekToPreviousMediaItem() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + }); + + player.seekToPreviousMediaItem(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 0, + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ false); + } + + @Test + public void seekBack_usesCommandSeekBack() { + BasePlayer player = + spy( + new TestBasePlayer() { + @Override + public long getSeekBackIncrement() { + return 2000; + } + + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + + @Override + public long getCurrentPosition() { + return 5000; + } + }); + + player.seekBack(); + + verify(player) + .seekTo( + /* mediaItemIndex= */ 1, + /* positionMs= */ 3000, + Player.COMMAND_SEEK_BACK, + /* isRepeatingCurrentItem= */ false); + } + + private static class TestBasePlayer extends StubPlayer { + + @Override + public void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem) { + // Do nothing. + } + + @Override + public long getSeekBackIncrement() { + return 2000; + } + + @Override + public long getSeekForwardIncrement() { + return 2000; + } + + @Override + public long getMaxSeekToPreviousPosition() { + return 2000; + } + + @Override + public Timeline getCurrentTimeline() { + return new FakeTimeline(/* windowCount= */ 3); + } + + @Override + public int getCurrentMediaItemIndex() { + return 1; + } + + @Override + public long getCurrentPosition() { + return 5000; + } + + @Override + public long getDuration() { + return 20000; + } + + @Override + public boolean isPlayingAd() { + return false; + } + + @Override + public int getRepeatMode() { + return Player.REPEAT_MODE_OFF; + } + + @Override + public boolean getShuffleModeEnabled() { + return false; + } + } +} diff --git a/libraries/common/src/test/java/androidx/media3/common/FormatTest.java b/libraries/common/src/test/java/androidx/media3/common/FormatTest.java index ab656935ff..e15d6fb5d1 100644 --- a/libraries/common/src/test/java/androidx/media3/common/FormatTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/FormatTest.java @@ -111,6 +111,8 @@ public final class FormatTest { .setEncoderPadding(1002) .setAccessibilityChannel(2) .setCryptoType(C.CRYPTO_TYPE_CUSTOM_BASE) + .setTileCountHorizontal(20) + .setTileCountVertical(40) .build(); } 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 f861a701ef..4df2c9d0b7 100644 --- a/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/MediaItemTest.java @@ -360,10 +360,12 @@ public class MediaItemTest { } @Test - public void clippingConfigurationDefaults() { + public void createDefaultClippingConfigurationInstance_checksDefaultValues() { MediaItem.ClippingConfiguration clippingConfiguration = new MediaItem.ClippingConfiguration.Builder().build(); + // Please refrain from altering default values since doing so would cause issues with backwards + // compatibility. assertThat(clippingConfiguration.startPositionMs).isEqualTo(0L); assertThat(clippingConfiguration.endPositionMs).isEqualTo(C.TIME_END_OF_SOURCE); assertThat(clippingConfiguration.relativeToLiveWindow).isFalse(); @@ -372,6 +374,38 @@ public class MediaItemTest { assertThat(clippingConfiguration).isEqualTo(MediaItem.ClippingConfiguration.UNSET); } + @Test + public void + createDefaultClippingConfigurationInstance_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + MediaItem.ClippingConfiguration clippingConfiguration = + new MediaItem.ClippingConfiguration.Builder().build(); + + Bundle clippingConfigurationBundle = clippingConfiguration.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(clippingConfigurationBundle.keySet()).isEmpty(); + + MediaItem.ClippingConfiguration clippingConfigurationFromBundle = + MediaItem.ClippingConfiguration.CREATOR.fromBundle(clippingConfigurationBundle); + + assertThat(clippingConfigurationFromBundle).isEqualTo(clippingConfiguration); + } + + @Test + public void createClippingConfigurationInstance_roundTripViaBundle_yieldsEqualInstance() { + // Creates instance by setting some non-default values + MediaItem.ClippingConfiguration clippingConfiguration = + new MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(1000L) + .setStartsAtKeyFrame(true) + .build(); + + MediaItem.ClippingConfiguration clippingConfigurationFromBundle = + MediaItem.ClippingConfiguration.CREATOR.fromBundle(clippingConfiguration.toBundle()); + + assertThat(clippingConfigurationFromBundle).isEqualTo(clippingConfiguration); + } + @Test public void clippingConfigurationBuilder_throwsOnInvalidValues() { MediaItem.ClippingConfiguration.Builder clippingConfigurationBuilder = @@ -514,6 +548,53 @@ public class MediaItemTest { assertThat(mediaItem.mediaMetadata).isEqualTo(mediaMetadata); } + @Test + public void createDefaultLiveConfigurationInstance_checksDefaultValues() { + MediaItem.LiveConfiguration liveConfiguration = + new MediaItem.LiveConfiguration.Builder().build(); + + // Please refrain from altering default values since doing so would cause issues with backwards + // compatibility. + assertThat(liveConfiguration.targetOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(liveConfiguration.minOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(liveConfiguration.maxOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(liveConfiguration.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); + assertThat(liveConfiguration.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); + assertThat(liveConfiguration).isEqualTo(MediaItem.LiveConfiguration.UNSET); + } + + @Test + public void + createDefaultLiveConfigurationInstance_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + MediaItem.LiveConfiguration liveConfiguration = + new MediaItem.LiveConfiguration.Builder().build(); + + Bundle liveConfigurationBundle = liveConfiguration.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(liveConfigurationBundle.keySet()).isEmpty(); + + MediaItem.LiveConfiguration liveConfigurationFromBundle = + MediaItem.LiveConfiguration.CREATOR.fromBundle(liveConfigurationBundle); + + assertThat(liveConfigurationFromBundle).isEqualTo(liveConfiguration); + } + + @Test + public void createLiveConfigurationInstance_roundTripViaBundle_yieldsEqualInstance() { + // Creates instance by setting some non-default values + MediaItem.LiveConfiguration liveConfiguration = + new MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(10_000) + .setMaxPlaybackSpeed(2f) + .build(); + + MediaItem.LiveConfiguration liveConfigurationFromBundle = + MediaItem.LiveConfiguration.CREATOR.fromBundle(liveConfiguration.toBundle()); + + assertThat(liveConfigurationFromBundle).isEqualTo(liveConfiguration); + } + @Test public void builderSetLiveConfiguration() { MediaItem mediaItem = @@ -747,4 +828,62 @@ public class MediaItemTest { assertThat(mediaItem.localConfiguration).isNotNull(); assertThat(MediaItem.CREATOR.fromBundle(mediaItem.toBundle()).localConfiguration).isNull(); } + + @Test + public void createDefaultMediaItemInstance_checksDefaultValues() { + MediaItem mediaItem = new MediaItem.Builder().build(); + + // Please refrain from altering default values since doing so would cause issues with backwards + // compatibility. + assertThat(mediaItem.mediaId).isEqualTo(MediaItem.DEFAULT_MEDIA_ID); + assertThat(mediaItem.liveConfiguration).isEqualTo(MediaItem.LiveConfiguration.UNSET); + assertThat(mediaItem.mediaMetadata).isEqualTo(MediaMetadata.EMPTY); + assertThat(mediaItem.clippingConfiguration).isEqualTo(MediaItem.ClippingConfiguration.UNSET); + assertThat(mediaItem.requestMetadata).isEqualTo(RequestMetadata.EMPTY); + assertThat(mediaItem).isEqualTo(MediaItem.EMPTY); + } + + @Test + public void createDefaultMediaItemInstance_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + MediaItem mediaItem = new MediaItem.Builder().build(); + + Bundle mediaItemBundle = mediaItem.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(mediaItemBundle.keySet()).isEmpty(); + + MediaItem mediaItemFromBundle = MediaItem.CREATOR.fromBundle(mediaItem.toBundle()); + + assertThat(mediaItemFromBundle).isEqualTo(mediaItem); + } + + @Test + public void createMediaItemInstance_roundTripViaBundle_yieldsEqualInstance() { + Bundle extras = new Bundle(); + extras.putString("key", "value"); + // Creates instance by setting some non-default values + MediaItem mediaItem = + new MediaItem.Builder() + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(20_000) + .setMinOffsetMs(2_222) + .setMaxOffsetMs(4_444) + .setMinPlaybackSpeed(.9f) + .setMaxPlaybackSpeed(1.1f) + .build()) + .setRequestMetadata( + new RequestMetadata.Builder() + .setMediaUri(Uri.parse("http://test.test")) + .setSearchQuery("search") + .setExtras(extras) + .build()) + .build(); + + MediaItem mediaItemFromBundle = MediaItem.CREATOR.fromBundle(mediaItem.toBundle()); + + assertThat(mediaItemFromBundle).isEqualTo(mediaItem); + assertThat(mediaItemFromBundle.requestMetadata.extras) + .isEqualTo(mediaItem.requestMetadata.extras); + } } diff --git a/libraries/common/src/test/java/androidx/media3/common/MediaMetadataTest.java b/libraries/common/src/test/java/androidx/media3/common/MediaMetadataTest.java index 7e606597c4..904c55ee15 100644 --- a/libraries/common/src/test/java/androidx/media3/common/MediaMetadataTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/MediaMetadataTest.java @@ -49,6 +49,7 @@ public class MediaMetadataTest { assertThat(mediaMetadata.trackNumber).isNull(); assertThat(mediaMetadata.totalTrackCount).isNull(); assertThat(mediaMetadata.folderType).isNull(); + assertThat(mediaMetadata.isBrowsable).isNull(); assertThat(mediaMetadata.isPlayable).isNull(); assertThat(mediaMetadata.recordingYear).isNull(); assertThat(mediaMetadata.recordingMonth).isNull(); @@ -64,6 +65,7 @@ public class MediaMetadataTest { assertThat(mediaMetadata.genre).isNull(); assertThat(mediaMetadata.compilation).isNull(); assertThat(mediaMetadata.station).isNull(); + assertThat(mediaMetadata.mediaType).isNull(); assertThat(mediaMetadata.extras).isNull(); } @@ -105,13 +107,86 @@ public class MediaMetadataTest { } @Test - public void roundTripViaBundle_yieldsEqualInstance() { + public void toBundleSkipsDefaultValues_fromBundleRestoresThem() { + MediaMetadata mediaMetadata = new MediaMetadata.Builder().build(); + + Bundle mediaMetadataBundle = mediaMetadata.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(mediaMetadataBundle.keySet()).isEmpty(); + + MediaMetadata mediaMetadataFromBundle = MediaMetadata.CREATOR.fromBundle(mediaMetadataBundle); + + assertThat(mediaMetadataFromBundle).isEqualTo(mediaMetadata); + // Extras is not implemented in MediaMetadata.equals(Object o). + assertThat(mediaMetadataFromBundle.extras).isNull(); + } + + @Test + public void createFullyPopulatedMediaMetadata_roundTripViaBundle_yieldsEqualInstance() { MediaMetadata mediaMetadata = getFullyPopulatedMediaMetadata(); - MediaMetadata fromBundle = MediaMetadata.CREATOR.fromBundle(mediaMetadata.toBundle()); - assertThat(fromBundle).isEqualTo(mediaMetadata); + MediaMetadata mediaMetadataFromBundle = + MediaMetadata.CREATOR.fromBundle(mediaMetadata.toBundle()); + + assertThat(mediaMetadataFromBundle).isEqualTo(mediaMetadata); // Extras is not implemented in MediaMetadata.equals(Object o). - assertThat(fromBundle.extras.getString(EXTRAS_KEY)).isEqualTo(EXTRAS_VALUE); + assertThat(mediaMetadataFromBundle.extras.getString(EXTRAS_KEY)).isEqualTo(EXTRAS_VALUE); + } + + @Test + public void builderSetFolderType_toNone_setsIsBrowsableToFalse() { + MediaMetadata mediaMetadata = + new MediaMetadata.Builder().setFolderType(MediaMetadata.FOLDER_TYPE_NONE).build(); + + assertThat(mediaMetadata.isBrowsable).isFalse(); + } + + @Test + public void builderSetFolderType_toNotNone_setsIsBrowsableToTrueAndMatchingMediaType() { + MediaMetadata mediaMetadata = + new MediaMetadata.Builder().setFolderType(MediaMetadata.FOLDER_TYPE_PLAYLISTS).build(); + + assertThat(mediaMetadata.isBrowsable).isTrue(); + assertThat(mediaMetadata.mediaType).isEqualTo(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); + } + + @Test + public void + builderSetFolderType_toNotNoneWithManualMediaType_setsIsBrowsableToTrueAndDoesNotOverrideMediaType() { + MediaMetadata mediaMetadata = + new MediaMetadata.Builder() + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS) + .setFolderType(MediaMetadata.FOLDER_TYPE_PLAYLISTS) + .build(); + + assertThat(mediaMetadata.isBrowsable).isTrue(); + assertThat(mediaMetadata.mediaType).isEqualTo(MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS); + } + + @Test + public void builderSetIsBrowsable_toTrueWithoutMediaType_setsFolderTypeToMixed() { + MediaMetadata mediaMetadata = new MediaMetadata.Builder().setIsBrowsable(true).build(); + + assertThat(mediaMetadata.folderType).isEqualTo(MediaMetadata.FOLDER_TYPE_MIXED); + } + + @Test + public void builderSetIsBrowsable_toTrueWithMediaType_setsFolderTypeToMatchMediaType() { + MediaMetadata mediaMetadata = + new MediaMetadata.Builder() + .setIsBrowsable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS) + .build(); + + assertThat(mediaMetadata.folderType).isEqualTo(MediaMetadata.FOLDER_TYPE_ARTISTS); + } + + @Test + public void builderSetFolderType_toFalse_setsFolderTypeToNone() { + MediaMetadata mediaMetadata = new MediaMetadata.Builder().setIsBrowsable(false).build(); + + assertThat(mediaMetadata.folderType).isEqualTo(MediaMetadata.FOLDER_TYPE_NONE); } private static MediaMetadata getFullyPopulatedMediaMetadata() { @@ -134,6 +209,7 @@ public class MediaMetadataTest { .setTrackNumber(4) .setTotalTrackCount(12) .setFolderType(MediaMetadata.FOLDER_TYPE_PLAYLISTS) + .setIsBrowsable(true) .setIsPlayable(true) .setRecordingYear(2000) .setRecordingMonth(11) @@ -149,6 +225,7 @@ public class MediaMetadataTest { .setGenre("Pop") .setCompilation("Amazing songs.") .setStation("radio station") + .setMediaType(MediaMetadata.MEDIA_TYPE_MIXED) .setExtras(extras) .build(); } diff --git a/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java b/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java index 1b13cb00fc..a78d2a9c58 100644 --- a/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java @@ -16,25 +16,48 @@ package androidx.media3.common; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import android.graphics.SurfaceTexture; import android.os.Looper; +import android.os.SystemClock; +import android.view.Surface; +import androidx.annotation.Nullable; import androidx.media3.common.Player.Commands; import androidx.media3.common.Player.Listener; import androidx.media3.common.SimpleBasePlayer.State; +import androidx.media3.common.text.Cue; +import androidx.media3.common.text.CueGroup; +import androidx.media3.common.util.Size; +import androidx.media3.test.utils.FakeMetadataEntry; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; /** Unit test for {@link SimpleBasePlayer}. */ @RunWith(AndroidJUnit4.class) @@ -61,6 +84,61 @@ public class SimpleBasePlayerTest { /* playWhenReady= */ true, /* playWhenReadyChangeReason= */ Player .PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) + .setPlaybackState(Player.STATE_IDLE) + .setPlaybackSuppressionReason( + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) + .setPlayerError( + new PlaybackException( + /* message= */ null, + /* cause= */ null, + PlaybackException.ERROR_CODE_DECODING_FAILED)) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setShuffleModeEnabled(true) + .setIsLoading(false) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(4000) + .setMaxSeekToPreviousPositionMs(3000) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)) + .setTrackSelectionParameters(TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT) + .setAudioAttributes( + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build()) + .setVolume(0.5f) + .setVideoSize(new VideoSize(/* width= */ 200, /* height= */ 400)) + .setCurrentCues( + new CueGroup( + ImmutableList.of(new Cue.Builder().setText("text").build()), + /* presentationTimeUs= */ 123)) + .setDeviceInfo( + new DeviceInfo( + DeviceInfo.PLAYBACK_TYPE_LOCAL, /* minVolume= */ 3, /* maxVolume= */ 7)) + .setIsDeviceMuted(true) + .setSurfaceSize(new Size(480, 360)) + .setNewlyRenderedFirstFrame(true) + .setTimedMetadata(new Metadata()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 555, + 666)) + .build())) + .build())) + .setPlaylistMetadata(new MediaMetadata.Builder().setArtist("artist").build()) + .setCurrentMediaItemIndex(1) + .setCurrentAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 2) + .setContentPositionMs(() -> 456) + .setAdPositionMs(() -> 6678) + .setContentBufferedPositionMs(() -> 999) + .setAdBufferedPositionMs(() -> 888) + .setTotalBufferedDurationMs(() -> 567) + .setPositionDiscontinuity( + Player.DISCONTINUITY_REASON_SEEK, /* discontinuityPositionMs= */ 400) .build(); State newState = state.buildUpon().build(); @@ -70,29 +148,644 @@ public class SimpleBasePlayerTest { } @Test - public void stateBuilderSetAvailableCommands_setsAvailableCommands() { + public void mediaItemDataBuildUpon_build_isEqual() { + SimpleBasePlayer.MediaItemData mediaItemData = + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setTracks( + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup(new Format.Builder().build()), + /* adaptiveSupported= */ true, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true})))) + .setMediaItem(new MediaItem.Builder().setMediaId("id").build()) + .setMediaMetadata(new MediaMetadata.Builder().setTitle("title").build()) + .setManifest(new Object()) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .setPresentationStartTimeMs(12) + .setWindowStartTimeMs(23) + .setElapsedRealtimeEpochOffsetMs(10234) + .setIsSeekable(true) + .setIsDynamic(true) + .setDefaultPositionUs(456_789) + .setDurationUs(500_000) + .setPositionInFirstPeriodUs(100_000) + .setIsPlaceholder(true) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()).build())) + .build(); + + SimpleBasePlayer.MediaItemData newMediaItemData = mediaItemData.buildUpon().build(); + + assertThat(newMediaItemData).isEqualTo(mediaItemData); + assertThat(newMediaItemData.hashCode()).isEqualTo(mediaItemData.hashCode()); + } + + @Test + public void periodDataBuildUpon_build_isEqual() { + SimpleBasePlayer.PeriodData periodData = + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setIsPlaceholder(true) + .setDurationUs(600_000) + .setAdPlaybackState( + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 555, 666)) + .build(); + + SimpleBasePlayer.PeriodData newPeriodData = periodData.buildUpon().build(); + + assertThat(newPeriodData).isEqualTo(periodData); + assertThat(newPeriodData.hashCode()).isEqualTo(periodData.hashCode()); + } + + @Test + public void stateBuilderBuild_setsCorrectValues() { Commands commands = new Commands.Builder() .addAll(Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_GET_TIMELINE) .build(); - State state = new State.Builder().setAvailableCommands(commands).build(); + PlaybackException error = + new PlaybackException( + /* message= */ null, /* cause= */ null, PlaybackException.ERROR_CODE_DECODING_FAILED); + PlaybackParameters playbackParameters = new PlaybackParameters(/* speed= */ 2f); + TrackSelectionParameters trackSelectionParameters = + TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + .buildUpon() + .setMaxVideoBitrate(1000) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(); + VideoSize videoSize = new VideoSize(/* width= */ 200, /* height= */ 400); + CueGroup cueGroup = + new CueGroup( + ImmutableList.of(new Cue.Builder().setText("text").build()), + /* presentationTimeUs= */ 123); + Metadata timedMetadata = new Metadata(new FakeMetadataEntry("data")); + Size surfaceSize = new Size(480, 360); + DeviceInfo deviceInfo = + new DeviceInfo(DeviceInfo.PLAYBACK_TYPE_LOCAL, /* minVolume= */ 3, /* maxVolume= */ 7); + ImmutableList playlist = + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs...= */ 555, 666)) + .build())) + .build()); + MediaMetadata playlistMetadata = new MediaMetadata.Builder().setArtist("artist").build(); + SimpleBasePlayer.PositionSupplier contentPositionSupplier = () -> 456; + SimpleBasePlayer.PositionSupplier adPositionSupplier = () -> 6678; + SimpleBasePlayer.PositionSupplier contentBufferedPositionSupplier = () -> 999; + SimpleBasePlayer.PositionSupplier adBufferedPositionSupplier = () -> 888; + SimpleBasePlayer.PositionSupplier totalBufferedPositionSupplier = () -> 567; - assertThat(state.availableCommands).isEqualTo(commands); - } - - @Test - public void stateBuilderSetPlayWhenReady_setsStatePlayWhenReadyAndReason() { State state = new State.Builder() + .setAvailableCommands(commands) .setPlayWhenReady( /* playWhenReady= */ true, /* playWhenReadyChangeReason= */ Player .PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) + .setPlaybackState(Player.STATE_IDLE) + .setPlaybackSuppressionReason( + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) + .setPlayerError(error) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setShuffleModeEnabled(true) + .setIsLoading(false) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(4000) + .setMaxSeekToPreviousPositionMs(3000) + .setPlaybackParameters(playbackParameters) + .setTrackSelectionParameters(trackSelectionParameters) + .setAudioAttributes(audioAttributes) + .setVolume(0.5f) + .setVideoSize(videoSize) + .setCurrentCues(cueGroup) + .setDeviceInfo(deviceInfo) + .setDeviceVolume(5) + .setIsDeviceMuted(true) + .setSurfaceSize(surfaceSize) + .setNewlyRenderedFirstFrame(true) + .setTimedMetadata(timedMetadata) + .setPlaylist(playlist) + .setPlaylistMetadata(playlistMetadata) + .setCurrentMediaItemIndex(1) + .setCurrentAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 2) + .setContentPositionMs(contentPositionSupplier) + .setAdPositionMs(adPositionSupplier) + .setContentBufferedPositionMs(contentBufferedPositionSupplier) + .setAdBufferedPositionMs(adBufferedPositionSupplier) + .setTotalBufferedDurationMs(totalBufferedPositionSupplier) + .setPositionDiscontinuity( + Player.DISCONTINUITY_REASON_SEEK, /* discontinuityPositionMs= */ 400) .build(); + assertThat(state.availableCommands).isEqualTo(commands); assertThat(state.playWhenReady).isTrue(); assertThat(state.playWhenReadyChangeReason) .isEqualTo(Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS); + assertThat(state.playbackState).isEqualTo(Player.STATE_IDLE); + assertThat(state.playbackSuppressionReason) + .isEqualTo(Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS); + assertThat(state.playerError).isEqualTo(error); + assertThat(state.repeatMode).isEqualTo(Player.REPEAT_MODE_ALL); + assertThat(state.shuffleModeEnabled).isTrue(); + assertThat(state.isLoading).isFalse(); + assertThat(state.seekBackIncrementMs).isEqualTo(5000); + assertThat(state.seekForwardIncrementMs).isEqualTo(4000); + assertThat(state.maxSeekToPreviousPositionMs).isEqualTo(3000); + assertThat(state.playbackParameters).isEqualTo(playbackParameters); + assertThat(state.trackSelectionParameters).isEqualTo(trackSelectionParameters); + assertThat(state.audioAttributes).isEqualTo(audioAttributes); + assertThat(state.volume).isEqualTo(0.5f); + assertThat(state.videoSize).isEqualTo(videoSize); + assertThat(state.currentCues).isEqualTo(cueGroup); + assertThat(state.deviceInfo).isEqualTo(deviceInfo); + assertThat(state.deviceVolume).isEqualTo(5); + assertThat(state.isDeviceMuted).isTrue(); + assertThat(state.surfaceSize).isEqualTo(surfaceSize); + assertThat(state.newlyRenderedFirstFrame).isTrue(); + assertThat(state.timedMetadata).isEqualTo(timedMetadata); + assertThat(state.playlist).isEqualTo(playlist); + assertThat(state.playlistMetadata).isEqualTo(playlistMetadata); + assertThat(state.currentMediaItemIndex).isEqualTo(1); + assertThat(state.currentAdGroupIndex).isEqualTo(1); + assertThat(state.currentAdIndexInAdGroup).isEqualTo(2); + assertThat(state.contentPositionMsSupplier).isEqualTo(contentPositionSupplier); + assertThat(state.adPositionMsSupplier).isEqualTo(adPositionSupplier); + assertThat(state.contentBufferedPositionMsSupplier).isEqualTo(contentBufferedPositionSupplier); + assertThat(state.adBufferedPositionMsSupplier).isEqualTo(adBufferedPositionSupplier); + assertThat(state.totalBufferedDurationMsSupplier).isEqualTo(totalBufferedPositionSupplier); + assertThat(state.hasPositionDiscontinuity).isTrue(); + assertThat(state.positionDiscontinuityReason).isEqualTo(Player.DISCONTINUITY_REASON_SEEK); + assertThat(state.discontinuityPositionMs).isEqualTo(400); + } + + @Test + public void stateBuilderBuild_emptyTimelineWithReadyState_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist(ImmutableList.of()) + .setPlaybackState(Player.STATE_READY) + .build()); + } + + @Test + public void stateBuilderBuild_emptyTimelineWithBufferingState_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist(ImmutableList.of()) + .setPlaybackState(Player.STATE_BUFFERING) + .build()); + } + + @Test + public void stateBuilderBuild_idleStateWithIsLoading_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaybackState(Player.STATE_IDLE) + .setIsLoading(true) + .build()); + } + + @Test + public void stateBuilderBuild_currentMediaItemIndexUnset_doesNotThrow() { + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setCurrentMediaItemIndex(C.INDEX_UNSET) + .build(); + + assertThat(state.currentMediaItemIndex).isEqualTo(C.INDEX_UNSET); + } + + @Test + public void stateBuilderBuild_currentMediaItemIndexSetForEmptyPlaylist_doesNotThrow() { + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(20) + .build(); + + assertThat(state.currentMediaItemIndex).isEqualTo(20); + } + + @Test + public void stateBuilderBuild_currentMediaItemIndexExceedsPlaylistLength_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .build())) + .setCurrentMediaItemIndex(2) + .build()); + } + + @Test + public void stateBuilderBuild_currentAdGroupIndexExceedsAdGroupCount_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 123)) + .build())) + .build())) + .setCurrentAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 2) + .build()); + } + + @Test + public void stateBuilderBuild_currentAdIndexExceedsAdCountInAdGroup_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 123) + .withAdCount( + /* adGroupIndex= */ 0, /* adCount= */ 2)) + .build())) + .build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2) + .build()); + } + + @Test + public void stateBuilderBuild_setAdAndEmptyPlaylist_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setCurrentAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 3) + .build()); + } + + @Test + public void stateBuilderBuild_playerErrorInNonIdleState_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaybackState(Player.STATE_READY) + .setPlayerError( + new PlaybackException( + /* message= */ null, + /* cause= */ null, + PlaybackException.ERROR_CODE_DECODING_FAILED)) + .build()); + } + + @Test + public void stateBuilderBuild_multipleMediaItemsWithSameIds_throwsException() { + Object uid = new Object(); + + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(uid).build(), + new SimpleBasePlayer.MediaItemData.Builder(uid).build())) + .build()); + } + + @Test + public void stateBuilderBuild_adGroupIndexWithUnsetAdIndex_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setCurrentAd(/* adGroupIndex= */ C.INDEX_UNSET, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void stateBuilderBuild_unsetAdGroupIndexWithSetAdIndex_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.State.Builder() + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ C.INDEX_UNSET)); + } + + @Test + public void stateBuilderBuild_unsetAdGroupIndexAndAdIndex_doesNotThrow() { + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setCurrentAd(/* adGroupIndex= */ C.INDEX_UNSET, /* adIndexInAdGroup= */ C.INDEX_UNSET) + .build(); + + assertThat(state.currentAdGroupIndex).isEqualTo(C.INDEX_UNSET); + assertThat(state.currentAdIndexInAdGroup).isEqualTo(C.INDEX_UNSET); + } + + @Test + public void stateBuilderBuild_returnsAdvancingContentPositionWhenPlaying() { + SystemClock.setCurrentTimeMillis(10000); + + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setContentPositionMs(4000) + .setPlayWhenReady(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(Player.STATE_READY) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)) + .build(); + long position1 = state.contentPositionMsSupplier.get(); + SystemClock.setCurrentTimeMillis(12000); + long position2 = state.contentPositionMsSupplier.get(); + + assertThat(position1).isEqualTo(4000); + assertThat(position2).isEqualTo(8000); + } + + @Test + public void stateBuilderBuild_withUnsetPositionAndPlaying_returnsConstantContentPosition() { + SystemClock.setCurrentTimeMillis(10000); + + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setContentPositionMs(C.TIME_UNSET) + .setPlayWhenReady(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(Player.STATE_READY) + .build(); + long position1 = state.contentPositionMsSupplier.get(); + SystemClock.setCurrentTimeMillis(12000); + long position2 = state.contentPositionMsSupplier.get(); + + assertThat(position1).isEqualTo(C.TIME_UNSET); + assertThat(position2).isEqualTo(C.TIME_UNSET); + } + + @Test + public void stateBuilderBuild_returnsConstantContentPositionWhenNotPlaying() { + SystemClock.setCurrentTimeMillis(10000); + + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setContentPositionMs(4000) + .setPlaybackState(Player.STATE_BUFFERING) + .build(); + long position1 = state.contentPositionMsSupplier.get(); + SystemClock.setCurrentTimeMillis(12000); + long position2 = state.contentPositionMsSupplier.get(); + + assertThat(position1).isEqualTo(4000); + assertThat(position2).isEqualTo(4000); + } + + @Test + public void stateBuilderBuild_returnsAdvancingAdPositionWhenPlaying() { + SystemClock.setCurrentTimeMillis(10000); + + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 123) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2)) + .build())) + .build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1) + .setAdPositionMs(4000) + .setPlayWhenReady(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(Player.STATE_READY) + // This should be ignored as ads are assumed to be played with unit speed. + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)) + .build(); + long position1 = state.adPositionMsSupplier.get(); + SystemClock.setCurrentTimeMillis(12000); + long position2 = state.adPositionMsSupplier.get(); + + assertThat(position1).isEqualTo(4000); + assertThat(position2).isEqualTo(6000); + } + + @Test + public void stateBuilderBuild_returnsConstantAdPositionWhenNotPlaying() { + SystemClock.setCurrentTimeMillis(10000); + + SimpleBasePlayer.State state = + new SimpleBasePlayer.State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 123) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2)) + .build())) + .build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1) + .setAdPositionMs(4000) + .setPlaybackState(Player.STATE_BUFFERING) + .build(); + long position1 = state.adPositionMsSupplier.get(); + SystemClock.setCurrentTimeMillis(12000); + long position2 = state.adPositionMsSupplier.get(); + + assertThat(position1).isEqualTo(4000); + assertThat(position2).isEqualTo(4000); + } + + @Test + public void mediaItemDataBuilderBuild_setsCorrectValues() { + Object uid = new Object(); + Tracks tracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup(new Format.Builder().build()), + /* adaptiveSupported= */ true, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true}))); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); + Object manifest = new Object(); + MediaItem.LiveConfiguration liveConfiguration = + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build(); + ImmutableList periods = + ImmutableList.of(new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()).build()); + + SimpleBasePlayer.MediaItemData mediaItemData = + new SimpleBasePlayer.MediaItemData.Builder(uid) + .setTracks(tracks) + .setMediaItem(mediaItem) + .setMediaMetadata(mediaMetadata) + .setManifest(manifest) + .setLiveConfiguration(liveConfiguration) + .setPresentationStartTimeMs(12) + .setWindowStartTimeMs(23) + .setElapsedRealtimeEpochOffsetMs(10234) + .setIsSeekable(true) + .setIsDynamic(true) + .setDefaultPositionUs(456_789) + .setDurationUs(500_000) + .setPositionInFirstPeriodUs(100_000) + .setIsPlaceholder(true) + .setPeriods(periods) + .build(); + + assertThat(mediaItemData.uid).isEqualTo(uid); + assertThat(mediaItemData.tracks).isEqualTo(tracks); + assertThat(mediaItemData.mediaItem).isEqualTo(mediaItem); + assertThat(mediaItemData.mediaMetadata).isEqualTo(mediaMetadata); + assertThat(mediaItemData.manifest).isEqualTo(manifest); + assertThat(mediaItemData.liveConfiguration).isEqualTo(liveConfiguration); + assertThat(mediaItemData.presentationStartTimeMs).isEqualTo(12); + assertThat(mediaItemData.windowStartTimeMs).isEqualTo(23); + assertThat(mediaItemData.elapsedRealtimeEpochOffsetMs).isEqualTo(10234); + assertThat(mediaItemData.isSeekable).isTrue(); + assertThat(mediaItemData.isDynamic).isTrue(); + assertThat(mediaItemData.defaultPositionUs).isEqualTo(456_789); + assertThat(mediaItemData.durationUs).isEqualTo(500_000); + assertThat(mediaItemData.positionInFirstPeriodUs).isEqualTo(100_000); + assertThat(mediaItemData.isPlaceholder).isTrue(); + assertThat(mediaItemData.periods).isEqualTo(periods); + } + + @Test + public void mediaItemDataBuilderBuild_presentationStartTimeIfNotLive_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPresentationStartTimeMs(12) + .build()); + } + + @Test + public void mediaItemDataBuilderBuild_windowStartTimeIfNotLive_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setWindowStartTimeMs(12) + .build()); + } + + @Test + public void mediaItemDataBuilderBuild_elapsedEpochOffsetIfNotLive_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setElapsedRealtimeEpochOffsetMs(12) + .build()); + } + + @Test + public void + mediaItemDataBuilderBuild_windowStartTimeLessThanPresentationStartTime_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setLiveConfiguration(MediaItem.LiveConfiguration.UNSET) + .setWindowStartTimeMs(12) + .setPresentationStartTimeMs(13) + .build()); + } + + @Test + public void mediaItemDataBuilderBuild_multiplePeriodsWithSameUid_throwsException() { + Object uid = new Object(); + + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(uid).build(), + new SimpleBasePlayer.PeriodData.Builder(uid).build())) + .build()); + } + + @Test + public void mediaItemDataBuilderBuild_defaultPositionGreaterThanDuration_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setDefaultPositionUs(16) + .setDurationUs(15) + .build()); + } + + @Test + public void periodDataBuilderBuild_setsCorrectValues() { + Object uid = new Object(); + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 555, 666); + + SimpleBasePlayer.PeriodData periodData = + new SimpleBasePlayer.PeriodData.Builder(uid) + .setIsPlaceholder(true) + .setDurationUs(600_000) + .setAdPlaybackState(adPlaybackState) + .build(); + + assertThat(periodData.uid).isEqualTo(uid); + assertThat(periodData.isPlaceholder).isTrue(); + assertThat(periodData.durationUs).isEqualTo(600_000); + assertThat(periodData.adPlaybackState).isEqualTo(adPlaybackState); } @Test @@ -101,6 +794,72 @@ public class SimpleBasePlayerTest { new Commands.Builder() .addAll(Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_GET_TIMELINE) .build(); + PlaybackException error = + new PlaybackException( + /* message= */ null, /* cause= */ null, PlaybackException.ERROR_CODE_DECODING_FAILED); + PlaybackParameters playbackParameters = new PlaybackParameters(/* speed= */ 2f); + TrackSelectionParameters trackSelectionParameters = + TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + .buildUpon() + .setMaxVideoBitrate(1000) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(); + VideoSize videoSize = new VideoSize(/* width= */ 200, /* height= */ 400); + CueGroup cueGroup = + new CueGroup( + ImmutableList.of(new Cue.Builder().setText("text").build()), + /* presentationTimeUs= */ 123); + DeviceInfo deviceInfo = + new DeviceInfo(DeviceInfo.PLAYBACK_TYPE_LOCAL, /* minVolume= */ 3, /* maxVolume= */ 7); + MediaMetadata playlistMetadata = new MediaMetadata.Builder().setArtist("artist").build(); + SimpleBasePlayer.PositionSupplier contentPositionSupplier = () -> 456; + SimpleBasePlayer.PositionSupplier contentBufferedPositionSupplier = () -> 499; + SimpleBasePlayer.PositionSupplier totalBufferedPositionSupplier = () -> 567; + Object mediaItemUid = new Object(); + Object periodUid = new Object(); + Tracks tracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup(new Format.Builder().build()), + /* adaptiveSupported= */ true, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true}))); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); + Object manifest = new Object(); + Size surfaceSize = new Size(480, 360); + MediaItem.LiveConfiguration liveConfiguration = + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build(); + ImmutableList playlist = + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid) + .setTracks(tracks) + .setMediaItem(mediaItem) + .setMediaMetadata(mediaMetadata) + .setManifest(manifest) + .setLiveConfiguration(liveConfiguration) + .setPresentationStartTimeMs(12) + .setWindowStartTimeMs(23) + .setElapsedRealtimeEpochOffsetMs(10234) + .setIsSeekable(true) + .setIsDynamic(true) + .setDefaultPositionUs(456_789) + .setDurationUs(500_000) + .setPositionInFirstPeriodUs(100_000) + .setIsPlaceholder(true) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(periodUid) + .setIsPlaceholder(true) + .setDurationUs(600_000) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs...= */ 555, 666)) + .build())) + .build()); State state = new State.Builder() .setAvailableCommands(commands) @@ -108,8 +867,35 @@ public class SimpleBasePlayerTest { /* playWhenReady= */ true, /* playWhenReadyChangeReason= */ Player .PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) + .setPlaybackState(Player.STATE_IDLE) + .setPlaybackSuppressionReason( + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) + .setPlayerError(error) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setShuffleModeEnabled(true) + .setIsLoading(false) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(4000) + .setMaxSeekToPreviousPositionMs(3000) + .setPlaybackParameters(playbackParameters) + .setTrackSelectionParameters(trackSelectionParameters) + .setAudioAttributes(audioAttributes) + .setVolume(0.5f) + .setVideoSize(videoSize) + .setCurrentCues(cueGroup) + .setDeviceInfo(deviceInfo) + .setDeviceVolume(5) + .setIsDeviceMuted(true) + .setSurfaceSize(surfaceSize) + .setPlaylist(playlist) + .setPlaylistMetadata(playlistMetadata) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(contentPositionSupplier) + .setContentBufferedPositionMs(contentBufferedPositionSupplier) + .setTotalBufferedDurationMs(totalBufferedPositionSupplier) .build(); - SimpleBasePlayer player = + + Player player = new SimpleBasePlayer(Looper.myLooper()) { @Override protected State getState() { @@ -120,11 +906,305 @@ public class SimpleBasePlayerTest { assertThat(player.getApplicationLooper()).isEqualTo(Looper.myLooper()); assertThat(player.getAvailableCommands()).isEqualTo(commands); assertThat(player.getPlayWhenReady()).isTrue(); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + assertThat(player.getPlaybackSuppressionReason()) + .isEqualTo(Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS); + assertThat(player.getPlayerError()).isEqualTo(error); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + assertThat(player.getShuffleModeEnabled()).isTrue(); + assertThat(player.isLoading()).isFalse(); + assertThat(player.getSeekBackIncrement()).isEqualTo(5000); + assertThat(player.getSeekForwardIncrement()).isEqualTo(4000); + assertThat(player.getMaxSeekToPreviousPosition()).isEqualTo(3000); + assertThat(player.getPlaybackParameters()).isEqualTo(playbackParameters); + assertThat(player.getCurrentTracks()).isEqualTo(tracks); + assertThat(player.getTrackSelectionParameters()).isEqualTo(trackSelectionParameters); + assertThat(player.getMediaMetadata()).isEqualTo(mediaMetadata); + assertThat(player.getPlaylistMetadata()).isEqualTo(playlistMetadata); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getDuration()).isEqualTo(500); + assertThat(player.getCurrentPosition()).isEqualTo(456); + assertThat(player.getBufferedPosition()).isEqualTo(499); + assertThat(player.getTotalBufferedDuration()).isEqualTo(567); + assertThat(player.isPlayingAd()).isFalse(); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(C.INDEX_UNSET); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(C.INDEX_UNSET); + assertThat(player.getContentPosition()).isEqualTo(456); + assertThat(player.getContentBufferedPosition()).isEqualTo(499); + assertThat(player.getAudioAttributes()).isEqualTo(audioAttributes); + assertThat(player.getVolume()).isEqualTo(0.5f); + assertThat(player.getVideoSize()).isEqualTo(videoSize); + assertThat(player.getCurrentCues()).isEqualTo(cueGroup); + assertThat(player.getDeviceInfo()).isEqualTo(deviceInfo); + assertThat(player.getDeviceVolume()).isEqualTo(5); + assertThat(player.isDeviceMuted()).isTrue(); + assertThat(player.getSurfaceSize()).isEqualTo(surfaceSize); + Timeline timeline = player.getCurrentTimeline(); + assertThat(timeline.getPeriodCount()).isEqualTo(2); + assertThat(timeline.getWindowCount()).isEqualTo(2); + Timeline.Window window = timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()); + assertThat(window.defaultPositionUs).isEqualTo(0); + assertThat(window.durationUs).isEqualTo(C.TIME_UNSET); + assertThat(window.elapsedRealtimeEpochOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(window.firstPeriodIndex).isEqualTo(0); + assertThat(window.isDynamic).isFalse(); + assertThat(window.isPlaceholder).isFalse(); + assertThat(window.isSeekable).isFalse(); + assertThat(window.lastPeriodIndex).isEqualTo(0); + assertThat(window.positionInFirstPeriodUs).isEqualTo(0); + assertThat(window.presentationStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.windowStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.liveConfiguration).isNull(); + assertThat(window.manifest).isNull(); + assertThat(window.mediaItem).isEqualTo(MediaItem.EMPTY); + window = timeline.getWindow(/* windowIndex= */ 1, new Timeline.Window()); + assertThat(window.defaultPositionUs).isEqualTo(456_789); + assertThat(window.durationUs).isEqualTo(500_000); + assertThat(window.elapsedRealtimeEpochOffsetMs).isEqualTo(10234); + assertThat(window.firstPeriodIndex).isEqualTo(1); + assertThat(window.isDynamic).isTrue(); + assertThat(window.isPlaceholder).isTrue(); + assertThat(window.isSeekable).isTrue(); + assertThat(window.lastPeriodIndex).isEqualTo(1); + assertThat(window.positionInFirstPeriodUs).isEqualTo(100_000); + assertThat(window.presentationStartTimeMs).isEqualTo(12); + assertThat(window.windowStartTimeMs).isEqualTo(23); + assertThat(window.liveConfiguration).isEqualTo(liveConfiguration); + assertThat(window.manifest).isEqualTo(manifest); + assertThat(window.mediaItem).isEqualTo(mediaItem); + assertThat(window.uid).isEqualTo(mediaItemUid); + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true); + assertThat(period.durationUs).isEqualTo(C.TIME_UNSET); + assertThat(period.isPlaceholder).isFalse(); + assertThat(period.positionInWindowUs).isEqualTo(0); + assertThat(period.windowIndex).isEqualTo(0); + assertThat(period.getAdGroupCount()).isEqualTo(0); + period = timeline.getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true); + assertThat(period.durationUs).isEqualTo(600_000); + assertThat(period.isPlaceholder).isTrue(); + assertThat(period.positionInWindowUs).isEqualTo(-100_000); + assertThat(period.windowIndex).isEqualTo(1); + assertThat(period.id).isEqualTo(periodUid); + assertThat(period.getAdGroupCount()).isEqualTo(2); + assertThat(period.getAdGroupTimeUs(/* adGroupIndex= */ 0)).isEqualTo(555); + assertThat(period.getAdGroupTimeUs(/* adGroupIndex= */ 1)).isEqualTo(666); + } + + @Test + public void getterMethods_duringAd_returnAdState() { + SimpleBasePlayer.PositionSupplier contentPositionSupplier = () -> 456; + SimpleBasePlayer.PositionSupplier contentBufferedPositionSupplier = () -> 499; + SimpleBasePlayer.PositionSupplier totalBufferedPositionSupplier = () -> 567; + SimpleBasePlayer.PositionSupplier adPositionSupplier = () -> 321; + SimpleBasePlayer.PositionSupplier adBufferedPositionSupplier = () -> 345; + ImmutableList playlist = + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()) + .setDurationUs(500_000) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setIsPlaceholder(true) + .setDurationUs(600_000) + .setAdPlaybackState( + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs...= */ + 555, + 666) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs... */ 700_000) + .withAdDurationsUs( + /* adGroupIndex= */ 1, /* adDurationsUs... */ 800_000)) + .build())) + .build()); + State state = + new State.Builder() + .setPlaylist(playlist) + .setCurrentMediaItemIndex(1) + .setCurrentAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0) + .setContentPositionMs(contentPositionSupplier) + .setContentBufferedPositionMs(contentBufferedPositionSupplier) + .setTotalBufferedDurationMs(totalBufferedPositionSupplier) + .setAdPositionMs(adPositionSupplier) + .setAdBufferedPositionMs(adBufferedPositionSupplier) + .build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + assertThat(player.getDuration()).isEqualTo(800); + assertThat(player.getCurrentPosition()).isEqualTo(321); + assertThat(player.getBufferedPosition()).isEqualTo(345); + assertThat(player.getTotalBufferedDuration()).isEqualTo(567); + assertThat(player.isPlayingAd()).isTrue(); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(1); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getContentPosition()).isEqualTo(456); + assertThat(player.getContentBufferedPosition()).isEqualTo(499); + } + + @Test + public void getterMethods_withEmptyTimeline_returnPlaceholderValues() { + State state = new State.Builder().setCurrentMediaItemIndex(4).build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getCurrentTracks()).isEqualTo(Tracks.EMPTY); + assertThat(player.getMediaMetadata()).isEqualTo(MediaMetadata.EMPTY); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(4); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(4); + } + + @Test + public void getCurrentMediaItemIndex_withUnsetIndexInState_returnsDefaultIndex() { + State state = new State.Builder().setCurrentMediaItemIndex(C.INDEX_UNSET).build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + } + + @Test + public void getCurrentPeriodIndex_withUnsetIndexInState_returnsPeriodForCurrentPosition() { + State state = + new State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setPeriods( + ImmutableList.of( + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ "period0") + .setDurationUs(60_000_000) + .build(), + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ "period1") + .setDurationUs(5_000_000) + .build(), + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ "period2") + .setDurationUs(5_000_000) + .build())) + .setPositionInFirstPeriodUs(50_000_000) + .build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(12_000) + .build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getCurrentPeriodIndex()).isEqualTo(2); + } + + @Test + public void getCurrentPosition_withUnsetPositionInState_returnsDefaultPosition() { + State state = + new State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0) + .setDefaultPositionUs(5_000_000) + .build())) + .setContentPositionMs(C.TIME_UNSET) + .build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getCurrentPosition()).isEqualTo(5000); + } + + @Test + public void getBufferedPosition_withUnsetBufferedPositionInState_returnsDefaultPosition() { + State state = + new State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0) + .setDefaultPositionUs(5_000_000) + .build())) + .setContentBufferedPositionMs( + SimpleBasePlayer.PositionSupplier.getConstant(C.TIME_UNSET)) + .build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getBufferedPosition()).isEqualTo(5000); + } + + @Test + public void + getBufferedPosition_withUnsetBufferedPositionAndPositionInState_returnsDefaultPosition() { + State state = + new State.Builder() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0) + .setDefaultPositionUs(5_000_000) + .build())) + .setContentPositionMs(C.TIME_UNSET) + .setContentBufferedPositionMs( + SimpleBasePlayer.PositionSupplier.getConstant(C.TIME_UNSET)) + .build(); + + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + }; + + assertThat(player.getBufferedPosition()).isEqualTo(5000); } @SuppressWarnings("deprecation") // Verifying deprecated listener call. @Test - public void invalidateState_updatesStateAndInformsListeners() { + public void invalidateState_updatesStateAndInformsListeners() throws Exception { + Object mediaItemUid0 = new Object(); + MediaItem mediaItem0 = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData0 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid0).setMediaItem(mediaItem0).build(); State state1 = new State.Builder() .setAvailableCommands(new Commands.Builder().addAllCommands().build()) @@ -132,14 +1212,106 @@ public class SimpleBasePlayerTest { /* playWhenReady= */ true, /* playWhenReadyChangeReason= */ Player .PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) + .setPlaybackState(Player.STATE_READY) + .setPlaybackSuppressionReason(Player.PLAYBACK_SUPPRESSION_REASON_NONE) + .setPlayerError(null) + .setRepeatMode(Player.REPEAT_MODE_ONE) + .setShuffleModeEnabled(false) + .setIsLoading(true) + .setSeekBackIncrementMs(7000) + .setSeekForwardIncrementMs(2000) + .setMaxSeekToPreviousPositionMs(8000) + .setPlaybackParameters(PlaybackParameters.DEFAULT) + .setTrackSelectionParameters(TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT) + .setAudioAttributes(AudioAttributes.DEFAULT) + .setVolume(1f) + .setVideoSize(VideoSize.UNKNOWN) + .setCurrentCues(CueGroup.EMPTY_TIME_ZERO) + .setDeviceInfo(DeviceInfo.UNKNOWN) + .setDeviceVolume(0) + .setIsDeviceMuted(false) + .setPlaylist(ImmutableList.of(mediaItemData0)) + .setPlaylistMetadata(MediaMetadata.EMPTY) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(8_000) .build(); - Commands commands = new Commands.Builder().add(Player.COMMAND_GET_TEXT).build(); + Object mediaItemUid1 = new Object(); + MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("1").build(); + MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); + Tracks tracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup(new Format.Builder().build()), + /* adaptiveSupported= */ true, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true}))); + SimpleBasePlayer.MediaItemData mediaItemData1 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1) + .setMediaItem(mediaItem1) + .setMediaMetadata(mediaMetadata) + .setTracks(tracks) + .build(); + Commands commands = + new Commands.Builder() + .addAll(Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_GET_TIMELINE) + .build(); + PlaybackException error = + new PlaybackException( + /* message= */ null, /* cause= */ null, PlaybackException.ERROR_CODE_DECODING_FAILED); + PlaybackParameters playbackParameters = new PlaybackParameters(/* speed= */ 2f); + TrackSelectionParameters trackSelectionParameters = + TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + .buildUpon() + .setMaxVideoBitrate(1000) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(); + VideoSize videoSize = new VideoSize(/* width= */ 200, /* height= */ 400); + CueGroup cueGroup = + new CueGroup( + ImmutableList.of(new Cue.Builder().setText("text").build()), + /* presentationTimeUs= */ 123); + Metadata timedMetadata = + new Metadata(/* presentationTimeUs= */ 42, new FakeMetadataEntry("data")); + Size surfaceSize = new Size(480, 360); + DeviceInfo deviceInfo = + new DeviceInfo(DeviceInfo.PLAYBACK_TYPE_LOCAL, /* minVolume= */ 3, /* maxVolume= */ 7); + MediaMetadata playlistMetadata = new MediaMetadata.Builder().setArtist("artist").build(); State state2 = new State.Builder() .setAvailableCommands(commands) .setPlayWhenReady( /* playWhenReady= */ false, - /* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE) + /* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(Player.STATE_IDLE) + .setPlaybackSuppressionReason( + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) + .setPlayerError(error) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setShuffleModeEnabled(true) + .setIsLoading(false) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(4000) + .setMaxSeekToPreviousPositionMs(3000) + .setPlaybackParameters(playbackParameters) + .setTrackSelectionParameters(trackSelectionParameters) + .setAudioAttributes(audioAttributes) + .setVolume(0.5f) + .setVideoSize(videoSize) + .setCurrentCues(cueGroup) + .setDeviceInfo(deviceInfo) + .setDeviceVolume(5) + .setIsDeviceMuted(true) + .setSurfaceSize(surfaceSize) + .setNewlyRenderedFirstFrame(true) + .setTimedMetadata(timedMetadata) + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setPlaylistMetadata(playlistMetadata) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(12_000) + .setPositionDiscontinuity( + Player.DISCONTINUITY_REASON_SEEK, /* discontinuityPositionMs= */ 11_500) .build(); AtomicBoolean returnState2 = new AtomicBoolean(); SimpleBasePlayer player = @@ -156,17 +1328,595 @@ public class SimpleBasePlayerTest { returnState2.set(true); player.invalidateState(); - - // Verify updated state. - assertThat(player.getAvailableCommands()).isEqualTo(commands); + // Verify state2 is used. assertThat(player.getPlayWhenReady()).isFalse(); - // Verify listener calls. + // Idle Looper to ensure all callbacks (including onEvents) are delivered. + ShadowLooper.idleMainLooper(); + + // Assert listener calls. verify(listener).onAvailableCommandsChanged(commands); verify(listener) .onPlayWhenReadyChanged( - /* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE); + /* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST); verify(listener) .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE); + verify(listener).onPlaybackStateChanged(Player.STATE_IDLE); + verify(listener) + .onPlaybackSuppressionReasonChanged( + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS); + verify(listener).onIsPlayingChanged(false); + verify(listener).onPlayerError(error); + verify(listener).onPlayerErrorChanged(error); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verify(listener).onShuffleModeEnabledChanged(true); + verify(listener).onLoadingChanged(false); + verify(listener).onIsLoadingChanged(false); + verify(listener).onSeekBackIncrementChanged(5000); + verify(listener).onSeekForwardIncrementChanged(4000); + verify(listener).onMaxSeekToPreviousPositionChanged(3000); + verify(listener).onPlaybackParametersChanged(playbackParameters); + verify(listener).onTrackSelectionParametersChanged(trackSelectionParameters); + verify(listener).onAudioAttributesChanged(audioAttributes); + verify(listener).onVolumeChanged(0.5f); + verify(listener).onVideoSizeChanged(videoSize); + verify(listener).onCues(cueGroup.cues); + verify(listener).onCues(cueGroup); + verify(listener).onDeviceInfoChanged(deviceInfo); + verify(listener).onDeviceVolumeChanged(/* volume= */ 5, /* muted= */ true); + verify(listener) + .onTimelineChanged(state2.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onMediaMetadataChanged(mediaMetadata); + verify(listener).onTracksChanged(tracks); + verify(listener).onPlaylistMetadataChanged(playlistMetadata); + verify(listener).onRenderedFirstFrame(); + verify(listener).onMetadata(timedMetadata); + verify(listener).onSurfaceSizeChanged(surfaceSize.getWidth(), surfaceSize.getHeight()); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid0, + /* mediaItemIndex= */ 0, + mediaItem0, + /* periodUid= */ mediaItemUid0, + /* periodIndex= */ 0, + /* positionMs= */ 8_000, + /* contentPositionMs= */ 8_000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid1, + /* mediaItemIndex= */ 1, + mediaItem1, + /* periodUid= */ mediaItemUid1, + /* periodIndex= */ 1, + /* positionMs= */ 11_500, + /* contentPositionMs= */ 11_500, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onMediaItemTransition(mediaItem1, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + verify(listener).onSeekProcessed(); + verify(listener) + .onEvents( + player, + new Player.Events( + new FlagSet.Builder() + .addAll( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_TRACKS_CHANGED, + Player.EVENT_IS_LOADING_CHANGED, + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_REPEAT_MODE_CHANGED, + Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED, + Player.EVENT_PLAYER_ERROR, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_PLAYBACK_PARAMETERS_CHANGED, + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + Player.EVENT_MEDIA_METADATA_CHANGED, + Player.EVENT_PLAYLIST_METADATA_CHANGED, + Player.EVENT_SEEK_BACK_INCREMENT_CHANGED, + Player.EVENT_SEEK_FORWARD_INCREMENT_CHANGED, + Player.EVENT_MAX_SEEK_TO_PREVIOUS_POSITION_CHANGED, + Player.EVENT_TRACK_SELECTION_PARAMETERS_CHANGED, + Player.EVENT_AUDIO_ATTRIBUTES_CHANGED, + Player.EVENT_VOLUME_CHANGED, + Player.EVENT_SURFACE_SIZE_CHANGED, + Player.EVENT_VIDEO_SIZE_CHANGED, + Player.EVENT_RENDERED_FIRST_FRAME, + Player.EVENT_CUES, + Player.EVENT_METADATA, + Player.EVENT_DEVICE_INFO_CHANGED, + Player.EVENT_DEVICE_VOLUME_CHANGED) + .build())); + verifyNoMoreInteractions(listener); + // Assert that we actually called all listeners. + for (Method method : Player.Listener.class.getDeclaredMethods()) { + if (method.getName().equals("onAudioSessionIdChanged") + || method.getName().equals("onSkipSilenceEnabledChanged")) { + // Skip listeners for ExoPlayer-specific states + continue; + } + method.invoke(verify(listener), getAnyArguments(method)); + } + } + + @Test + public void invalidateState_withMediaItemDetailChange_reportsTimelineSourceUpdate() { + Object mediaItemUid0 = new Object(); + SimpleBasePlayer.MediaItemData mediaItemData0 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid0).build(); + Object mediaItemUid1 = new Object(); + SimpleBasePlayer.MediaItemData mediaItemData1 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1).build(); + State state1 = + new State.Builder().setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)).build(); + SimpleBasePlayer.MediaItemData mediaItemData1Updated = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1).setDurationUs(10_000).build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1Updated)) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onTimelineChanged(state2.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void invalidateState_withCurrentMediaItemRemoval_reportsDiscontinuityReasonRemoved() { + Object mediaItemUid0 = new Object(); + MediaItem mediaItem0 = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData0 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid0).setMediaItem(mediaItem0).build(); + Object mediaItemUid1 = new Object(); + MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("1").build(); + SimpleBasePlayer.MediaItemData mediaItemData1 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1).setMediaItem(mediaItem1).build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(5000) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(2000) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid1, + /* mediaItemIndex= */ 1, + mediaItem1, + /* periodUid= */ mediaItemUid1, + /* periodIndex= */ 1, + /* positionMs= */ 5000, + /* contentPositionMs= */ 5000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid0, + /* mediaItemIndex= */ 0, + mediaItem0, + /* periodUid= */ mediaItemUid0, + /* periodIndex= */ 0, + /* positionMs= */ 2000, + /* contentPositionMs= */ 2000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition(mediaItem0, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void + invalidateState_withTransitionFromEndOfItem_reportsDiscontinuityReasonAutoTransition() { + Object mediaItemUid0 = new Object(); + MediaItem mediaItem0 = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData0 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid0) + .setMediaItem(mediaItem0) + .setDurationUs(50_000) + .build(); + Object mediaItemUid1 = new Object(); + MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("1").build(); + SimpleBasePlayer.MediaItemData mediaItemData1 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1).setMediaItem(mediaItem1).build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(50) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(10) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid0, + /* mediaItemIndex= */ 0, + mediaItem0, + /* periodUid= */ mediaItemUid0, + /* periodIndex= */ 0, + /* positionMs= */ 50, + /* contentPositionMs= */ 50, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid1, + /* mediaItemIndex= */ 1, + mediaItem1, + /* periodUid= */ mediaItemUid1, + /* periodIndex= */ 1, + /* positionMs= */ 10, + /* contentPositionMs= */ 10, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + verify(listener).onMediaItemTransition(mediaItem1, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void invalidateState_withTransitionFromMiddleOfItem_reportsDiscontinuityReasonSkip() { + Object mediaItemUid0 = new Object(); + MediaItem mediaItem0 = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData0 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid0) + .setMediaItem(mediaItem0) + .setDurationUs(50_000) + .build(); + Object mediaItemUid1 = new Object(); + MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("1").build(); + SimpleBasePlayer.MediaItemData mediaItemData1 = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid1).setMediaItem(mediaItem1).build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(20) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData0, mediaItemData1)) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(10) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid0, + /* mediaItemIndex= */ 0, + mediaItem0, + /* periodUid= */ mediaItemUid0, + /* periodIndex= */ 0, + /* positionMs= */ 20, + /* contentPositionMs= */ 20, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid1, + /* mediaItemIndex= */ 1, + mediaItem1, + /* periodUid= */ mediaItemUid1, + /* periodIndex= */ 1, + /* positionMs= */ 10, + /* contentPositionMs= */ 10, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_SKIP); + verify(listener) + .onMediaItemTransition(mediaItem1, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void invalidateState_withRepeatingItem_reportsDiscontinuityReasonAutoTransition() { + Object mediaItemUid = new Object(); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid) + .setMediaItem(mediaItem) + .setDurationUs(5_000_000) + .build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(5_000) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(0) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid, + /* mediaItemIndex= */ 0, + mediaItem, + /* periodUid= */ mediaItemUid, + /* periodIndex= */ 0, + /* positionMs= */ 5_000, + /* contentPositionMs= */ 5_000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid, + /* mediaItemIndex= */ 0, + mediaItem, + /* periodUid= */ mediaItemUid, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + verify(listener).onMediaItemTransition(mediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT); + } + + @Test + public void invalidateState_withDiscontinuityInsideItem_reportsDiscontinuityReasonInternal() { + Object mediaItemUid = new Object(); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("0").build(); + SimpleBasePlayer.MediaItemData mediaItemData = + new SimpleBasePlayer.MediaItemData.Builder(mediaItemUid) + .setMediaItem(mediaItem) + .setDurationUs(5_000_000) + .build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(1_000) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(3_000) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener) + .onPositionDiscontinuity( + /* oldPosition= */ new Player.PositionInfo( + mediaItemUid, + /* mediaItemIndex= */ 0, + mediaItem, + /* periodUid= */ mediaItemUid, + /* periodIndex= */ 0, + /* positionMs= */ 1_000, + /* contentPositionMs= */ 1_000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + /* newPosition= */ new Player.PositionInfo( + mediaItemUid, + /* mediaItemIndex= */ 0, + mediaItem, + /* periodUid= */ mediaItemUid, + /* periodIndex= */ 0, + /* positionMs= */ 3_000, + /* contentPositionMs= */ 3_000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET), + Player.DISCONTINUITY_REASON_INTERNAL); + verify(listener, never()).onMediaItemTransition(any(), anyInt()); + } + + @Test + public void invalidateState_withMinorPositionDrift_doesNotReportsDiscontinuity() { + SimpleBasePlayer.MediaItemData mediaItemData = + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build(); + State state1 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(1_000) + .build(); + State state2 = + new State.Builder() + .setPlaylist(ImmutableList.of(mediaItemData)) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(1_500) + .build(); + AtomicBoolean returnState2 = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return returnState2.get() ? state2 : state1; + } + }; + player.invalidateState(); + Listener listener = mock(Listener.class); + player.addListener(listener); + + returnState2.set(true); + player.invalidateState(); + + // Assert listener call. + verify(listener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + verify(listener, never()).onMediaItemTransition(any(), anyInt()); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void invalidateStateAndOtherOperation_withDiscontinuity_reportsDiscontinuityOnlyOnce() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0).build())) + .setPositionDiscontinuity( + Player.DISCONTINUITY_REASON_INTERNAL, /* discontinuityPositionMs= */ 2000) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handlePrepare() { + // We just care about the placeholder state, so return an unfulfilled future. + return SettableFuture.create(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.invalidateState(); + player.prepare(); + + // Assert listener calls (in particular getting only a single discontinuity). + verify(listener) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_INTERNAL)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_BUFFERING); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void + invalidateStateAndOtherOperation_withRenderedFirstFrame_reportsRenderedFirstFrameOnlyOnce() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 0).build())) + .setNewlyRenderedFirstFrame(true) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handlePrepare() { + // We just care about the placeholder state, so return an unfulfilled future. + return SettableFuture.create(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.invalidateState(); + player.prepare(); + + // Assert listener calls (in particular getting only a single rendered first frame). + verify(listener).onRenderedFirstFrame(); + verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_BUFFERING); verifyNoMoreInteractions(listener); } @@ -296,17 +2046,18 @@ public class SimpleBasePlayerTest { .setPlayWhenReady( /* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE) .build(); - AtomicBoolean stateUpdated = new AtomicBoolean(); SimpleBasePlayer player = new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + @Override protected State getState() { - return stateUpdated.get() ? updatedState : state; + return playerState; } @Override protected ListenableFuture handleSetPlayWhenReady(boolean playWhenReady) { - stateUpdated.set(true); + playerState = updatedState; return Futures.immediateVoidFuture(); } }; @@ -403,4 +2154,5218 @@ public class SimpleBasePlayerTest { assertThat(callForwarded.get()).isFalse(); } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void prepare_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_IDLE) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .build(); + State updatedState = state.buildUpon().setPlaybackState(Player.STATE_READY).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handlePrepare() { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.prepare(); + + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_READY); + verify(listener).onPlaybackStateChanged(Player.STATE_READY); + verify(listener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_READY); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void prepare_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_IDLE) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .build(); + State updatedState = state.buildUpon().setPlaybackState(Player.STATE_READY).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handlePrepare() { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.prepare(); + + // Verify placeholder state and listener calls. + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_BUFFERING); + verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(listener) + .onPlayerStateChanged( + /* playWhenReady= */ false, /* playbackState= */ Player.STATE_BUFFERING); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_READY); + verify(listener).onPlaybackStateChanged(Player.STATE_READY); + verify(listener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_READY); + verifyNoMoreInteractions(listener); + } + + @Test + public void prepare_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_PREPARE).build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handlePrepare() { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.prepare(); + + assertThat(callForwarded.get()).isFalse(); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void stop_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .build(); + State updatedState = state.buildUpon().setPlaybackState(Player.STATE_IDLE).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleStop() { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.stop(); + + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + verify(listener).onPlaybackStateChanged(Player.STATE_IDLE); + verify(listener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener call. + @Test + public void stop_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setIsLoading(true) + .build(); + // Additionally set the repeat mode to see a difference between the placeholder and new state. + State updatedState = + state + .buildUpon() + .setPlaybackState(Player.STATE_IDLE) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setIsLoading(false) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleStop() { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.stop(); + + // Verify placeholder state and listener calls. + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF); + assertThat(player.isLoading()).isFalse(); + verify(listener).onPlaybackStateChanged(Player.STATE_IDLE); + verify(listener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE); + verify(listener).onIsLoadingChanged(false); + verify(listener).onLoadingChanged(false); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verifyNoMoreInteractions(listener); + } + + @Test + public void stop_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_STOP).build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleStop() { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.stop(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void release_immediateHandling_updatesStateInformsListenersAndReturnsIdle() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .build(); + State updatedState = state.buildUpon().setRepeatMode(Player.REPEAT_MODE_ALL).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleRelease() { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.release(); + + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verify(listener).onEvents(eq(player), any()); + verifyNoMoreInteractions(listener); + } + + @Test + public void release_asyncHandling_returnsIdleAndIgnoredAsyncStateUpdate() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ new Object()).build())) + .setIsLoading(true) + .build(); + // Additionally set the repeat mode to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setRepeatMode(Player.REPEAT_MODE_ALL).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRelease() { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.release(); + + // Verify initial change to IDLE and !isLoading without listener call. + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + assertThat(player.isLoading()).isFalse(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify no further update happened. + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF); + verifyNoMoreInteractions(listener); + } + + @Ignore("b/261158047: Ignore test while Player.COMMAND_RELEASE doesn't exist.") + @Test + public void release_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + // TODO(b/261158047): Uncomment once test is no longer ignored. + // .setAvailableCommands( + // new Commands.Builder().addAllCommands().remove(Player.COMMAND_RELEASE).build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleRelease() { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.release(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void release_withSubsequentPlayerAction_ignoresSubsequentAction() { + AtomicBoolean releaseCalled = new AtomicBoolean(); + AtomicBoolean getStateCalledAfterRelease = new AtomicBoolean(); + AtomicBoolean handlePlayWhenReadyCalledAfterRelease = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + if (releaseCalled.get()) { + getStateCalledAfterRelease.set(true); + } + return new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + } + + @Override + protected ListenableFuture handleSetPlayWhenReady(boolean playWhenReady) { + if (releaseCalled.get()) { + handlePlayWhenReadyCalledAfterRelease.set(true); + } + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleRelease() { + return Futures.immediateVoidFuture(); + } + }; + + player.release(); + releaseCalled.set(true); + // Try triggering a regular player action and to invalidate the state manually. + player.setPlayWhenReady(true); + player.invalidateState(); + + assertThat(getStateCalledAfterRelease.get()).isFalse(); + assertThat(handlePlayWhenReadyCalledAfterRelease.get()).isFalse(); + } + + @Test + public void setRepeatMode_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = state.buildUpon().setRepeatMode(Player.REPEAT_MODE_ALL).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetRepeatMode(@Player.RepeatMode int repeatMode) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setRepeatMode(Player.REPEAT_MODE_ONE); + + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verifyNoMoreInteractions(listener); + } + + @Test + public void setRepeatMode_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a new repeat mode to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setRepeatMode(Player.REPEAT_MODE_ALL).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetRepeatMode(@Player.RepeatMode int repeatMode) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setRepeatMode(Player.REPEAT_MODE_ONE); + + // Verify placeholder state and listener calls. + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verifyNoMoreInteractions(listener); + } + + @Test + public void setRepeatMode_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_REPEAT_MODE) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetRepeatMode(@Player.RepeatMode int repeatMode) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setRepeatMode(Player.REPEAT_MODE_ONE); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setShuffleModeEnabled_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Also change the repeat mode to ensure the updated state is used. + State updatedState = + state.buildUpon().setShuffleModeEnabled(true).setRepeatMode(Player.REPEAT_MODE_ALL).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetShuffleModeEnabled(boolean shuffleModeEnabled) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setShuffleModeEnabled(true); + + assertThat(player.getShuffleModeEnabled()).isTrue(); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(listener).onShuffleModeEnabledChanged(true); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verifyNoMoreInteractions(listener); + } + + @Test + public void setShuffleModeEnabled_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + // Always return the same state to revert the shuffle mode change. This allows to see a + // difference between the placeholder and new state. + return state; + } + + @Override + protected ListenableFuture handleSetShuffleModeEnabled(boolean shuffleModeEnabled) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setShuffleModeEnabled(true); + + // Verify placeholder state and listener calls. + assertThat(player.getShuffleModeEnabled()).isTrue(); + verify(listener).onShuffleModeEnabledChanged(true); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getShuffleModeEnabled()).isFalse(); + verify(listener).onShuffleModeEnabledChanged(false); + verifyNoMoreInteractions(listener); + } + + @Test + public void setShuffleModeEnabled_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_SHUFFLE_MODE) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetShuffleModeEnabled(boolean shuffleModeEnabled) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setShuffleModeEnabled(true); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setPlaybackParameters_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = + state.buildUpon().setPlaybackParameters(new PlaybackParameters(/* speed= */ 3f)).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetPlaybackParameters( + PlaybackParameters playbackParameters) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)); + + assertThat(player.getPlaybackParameters()).isEqualTo(new PlaybackParameters(/* speed= */ 3f)); + verify(listener).onPlaybackParametersChanged(new PlaybackParameters(/* speed= */ 3f)); + verifyNoMoreInteractions(listener); + } + + @Test + public void setPlaybackParameters_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a new repeat mode to see a difference between the placeholder and new state. + State updatedState = + state.buildUpon().setPlaybackParameters(new PlaybackParameters(/* speed= */ 3f)).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetPlaybackParameters( + PlaybackParameters playbackParameters) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)); + + // Verify placeholder state and listener calls. + assertThat(player.getPlaybackParameters()).isEqualTo(new PlaybackParameters(/* speed= */ 2f)); + verify(listener).onPlaybackParametersChanged(new PlaybackParameters(/* speed= */ 2f)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getPlaybackParameters()).isEqualTo(new PlaybackParameters(/* speed= */ 3f)); + verify(listener).onPlaybackParametersChanged(new PlaybackParameters(/* speed= */ 3f)); + verifyNoMoreInteractions(listener); + } + + @Test + public void setPlaybackParameters_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_SPEED_AND_PITCH) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetPlaybackParameters( + PlaybackParameters playbackParameters) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setTrackSelectionParameters_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + TrackSelectionParameters updatedParameters = + new TrackSelectionParameters.Builder(ApplicationProvider.getApplicationContext()) + .setMaxVideoBitrate(3000) + .build(); + State updatedState = state.buildUpon().setTrackSelectionParameters(updatedParameters).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetTrackSelectionParameters( + TrackSelectionParameters trackSelectionParameters) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setTrackSelectionParameters( + new TrackSelectionParameters.Builder(ApplicationProvider.getApplicationContext()) + .setMaxVideoBitrate(1000) + .build()); + + assertThat(player.getTrackSelectionParameters()).isEqualTo(updatedParameters); + verify(listener).onTrackSelectionParametersChanged(updatedParameters); + verifyNoMoreInteractions(listener); + } + + @Test + public void setTrackSelectionParameters_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set new parameters to see a difference between the placeholder and new state. + TrackSelectionParameters updatedParameters = + new TrackSelectionParameters.Builder(ApplicationProvider.getApplicationContext()) + .setMaxVideoBitrate(3000) + .build(); + State updatedState = state.buildUpon().setTrackSelectionParameters(updatedParameters).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetTrackSelectionParameters( + TrackSelectionParameters trackSelectionParameters) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + TrackSelectionParameters requestedParameters = + new TrackSelectionParameters.Builder(ApplicationProvider.getApplicationContext()) + .setMaxVideoBitrate(3000) + .build(); + player.setTrackSelectionParameters(requestedParameters); + + // Verify placeholder state and listener calls. + assertThat(player.getTrackSelectionParameters()).isEqualTo(requestedParameters); + verify(listener).onTrackSelectionParametersChanged(requestedParameters); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getTrackSelectionParameters()).isEqualTo(updatedParameters); + verify(listener).onTrackSelectionParametersChanged(updatedParameters); + verifyNoMoreInteractions(listener); + } + + @Test + public void setTrackSelectionParameters_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetTrackSelectionParameters( + TrackSelectionParameters trackSelectionParameters) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setTrackSelectionParameters( + new TrackSelectionParameters.Builder(ApplicationProvider.getApplicationContext()) + .setMaxVideoBitrate(1000) + .build()); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setPlaylistMetadata_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + MediaMetadata updatedMetadata = new MediaMetadata.Builder().setArtist("artist").build(); + State updatedState = state.buildUpon().setPlaylistMetadata(updatedMetadata).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetPlaylistMetadata(MediaMetadata playlistMetadata) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setPlaylistMetadata(new MediaMetadata.Builder().setTitle("title").build()); + + assertThat(player.getPlaylistMetadata()).isEqualTo(updatedMetadata); + verify(listener).onPlaylistMetadataChanged(updatedMetadata); + verifyNoMoreInteractions(listener); + } + + @Test + public void setPlaylistMetadata_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set new metadata to see a difference between the placeholder and new state. + MediaMetadata updatedMetadata = new MediaMetadata.Builder().setArtist("artist").build(); + State updatedState = state.buildUpon().setPlaylistMetadata(updatedMetadata).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetPlaylistMetadata(MediaMetadata playlistMetadata) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + MediaMetadata requestedMetadata = new MediaMetadata.Builder().setTitle("title").build(); + player.setPlaylistMetadata(requestedMetadata); + + // Verify placeholder state and listener calls. + assertThat(player.getPlaylistMetadata()).isEqualTo(requestedMetadata); + verify(listener).onPlaylistMetadataChanged(requestedMetadata); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getPlaylistMetadata()).isEqualTo(updatedMetadata); + verify(listener).onPlaylistMetadataChanged(updatedMetadata); + verifyNoMoreInteractions(listener); + } + + @Test + public void setPlaylistMetadata_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_MEDIA_ITEMS_METADATA) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetPlaylistMetadata(MediaMetadata playlistMetadata) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setPlaylistMetadata(new MediaMetadata.Builder().setTitle("title").build()); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setVolume_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = state.buildUpon().setVolume(.8f).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetVolume(float volume) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setVolume(.5f); + + assertThat(player.getVolume()).isEqualTo(.8f); + verify(listener).onVolumeChanged(.8f); + verifyNoMoreInteractions(listener); + } + + @Test + public void setVolume_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a new volume to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setVolume(.8f).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetVolume(float volume) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setVolume(.5f); + + // Verify placeholder state and listener calls. + assertThat(player.getVolume()).isEqualTo(.5f); + verify(listener).onVolumeChanged(.5f); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getVolume()).isEqualTo(.8f); + verify(listener).onVolumeChanged(.8f); + verifyNoMoreInteractions(listener); + } + + @Test + public void setVolume_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_SET_VOLUME).build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetVolume(float volume) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setVolume(.5f); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setDeviceVolume_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = state.buildUpon().setDeviceVolume(6).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetDeviceVolume(int volume) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setDeviceVolume(3); + + assertThat(player.getDeviceVolume()).isEqualTo(6); + verify(listener).onDeviceVolumeChanged(6, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void setDeviceVolume_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Set a new volume to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setDeviceVolume(6).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetDeviceVolume(int volume) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setDeviceVolume(3); + + // Verify placeholder state and listener calls. + assertThat(player.getDeviceVolume()).isEqualTo(3); + verify(listener).onDeviceVolumeChanged(3, /* muted= */ false); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getDeviceVolume()).isEqualTo(6); + verify(listener).onDeviceVolumeChanged(6, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void setDeviceVolume_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_DEVICE_VOLUME) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetDeviceVolume(int volume) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setDeviceVolume(3); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void increaseDeviceVolume_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setDeviceVolume(3) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = state.buildUpon().setDeviceVolume(6).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleIncreaseDeviceVolume() { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.increaseDeviceVolume(); + + assertThat(player.getDeviceVolume()).isEqualTo(6); + verify(listener).onDeviceVolumeChanged(6, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void increaseDeviceVolume_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setDeviceVolume(3) + .build(); + // Set a new volume to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setDeviceVolume(6).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleIncreaseDeviceVolume() { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.increaseDeviceVolume(); + + // Verify placeholder state and listener calls. + assertThat(player.getDeviceVolume()).isEqualTo(4); + verify(listener).onDeviceVolumeChanged(4, /* muted= */ false); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getDeviceVolume()).isEqualTo(6); + verify(listener).onDeviceVolumeChanged(6, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void increaseDeviceVolume_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_ADJUST_DEVICE_VOLUME) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleIncreaseDeviceVolume() { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.increaseDeviceVolume(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void decreaseDeviceVolume_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setDeviceVolume(3) + .build(); + // Set a different one to the one requested to ensure the updated state is used. + State updatedState = state.buildUpon().setDeviceVolume(1).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleDecreaseDeviceVolume() { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.decreaseDeviceVolume(); + + assertThat(player.getDeviceVolume()).isEqualTo(1); + verify(listener).onDeviceVolumeChanged(1, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void decreaseDeviceVolume_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setDeviceVolume(3) + .build(); + // Set a new volume to see a difference between the placeholder and new state. + State updatedState = state.buildUpon().setDeviceVolume(1).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleDecreaseDeviceVolume() { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.decreaseDeviceVolume(); + + // Verify placeholder state and listener calls. + assertThat(player.getDeviceVolume()).isEqualTo(2); + verify(listener).onDeviceVolumeChanged(2, /* muted= */ false); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getDeviceVolume()).isEqualTo(1); + verify(listener).onDeviceVolumeChanged(1, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void decreaseDeviceVolume_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_ADJUST_DEVICE_VOLUME) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleDecreaseDeviceVolume() { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.decreaseDeviceVolume(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setDeviceMuted_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + // Also change the volume to ensure the updated state is used. + State updatedState = state.buildUpon().setIsDeviceMuted(true).setDeviceVolume(6).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetDeviceMuted(boolean muted) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setDeviceMuted(true); + + assertThat(player.isDeviceMuted()).isTrue(); + assertThat(player.getDeviceVolume()).isEqualTo(6); + verify(listener).onDeviceVolumeChanged(6, /* muted= */ true); + verifyNoMoreInteractions(listener); + } + + @Test + public void setDeviceMuted_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + // Always return the same state to revert the muted change. This allows to see a + // difference between the placeholder and new state. + return state; + } + + @Override + protected ListenableFuture handleSetDeviceMuted(boolean muted) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setDeviceMuted(true); + + // Verify placeholder state and listener calls. + assertThat(player.isDeviceMuted()).isTrue(); + verify(listener).onDeviceVolumeChanged(0, /* muted= */ true); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.isDeviceMuted()).isFalse(); + verify(listener).onDeviceVolumeChanged(0, /* muted= */ false); + verifyNoMoreInteractions(listener); + } + + @Test + public void setDeviceMuted_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_ADJUST_DEVICE_VOLUME) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetDeviceMuted(boolean muted) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setDeviceMuted(true); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setVideoSurface_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setSurfaceSize(Size.ZERO) + .build(); + Size updatedSize = new Size(/* width= */ 300, /* height= */ 200); + State updatedState = state.buildUpon().setSurfaceSize(updatedSize).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetVideoOutput(Object videoOutput) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setVideoSurface(new Surface(new SurfaceTexture(0))); + + assertThat(player.getSurfaceSize()).isEqualTo(updatedSize); + verify(listener).onSurfaceSizeChanged(updatedSize.getWidth(), updatedSize.getHeight()); + verifyNoMoreInteractions(listener); + } + + @Test + public void setVideoSurface_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setSurfaceSize(Size.ZERO) + .build(); + SettableFuture future = SettableFuture.create(); + Size updatedSize = new Size(/* width= */ 300, /* height= */ 200); + State updatedState = state.buildUpon().setSurfaceSize(updatedSize).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetVideoOutput(Object videoOutput) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setVideoSurface(new Surface(new SurfaceTexture(0))); + + // Verify placeholder state and listener calls. + assertThat(player.getSurfaceSize()).isEqualTo(Size.UNKNOWN); + verify(listener) + .onSurfaceSizeChanged(/* width= */ C.LENGTH_UNSET, /* height= */ C.LENGTH_UNSET); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getSurfaceSize()).isEqualTo(updatedSize); + verify(listener).onSurfaceSizeChanged(updatedSize.getWidth(), updatedSize.getHeight()); + verifyNoMoreInteractions(listener); + } + + @Test + public void setVideoSurface_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_VIDEO_SURFACE) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetVideoOutput(Object videoOutput) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setVideoSurface(new Surface(new SurfaceTexture(0))); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void clearVideoSurface_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setSurfaceSize(new Size(/* width= */ 300, /* height= */ 200)) + .build(); + // Change something else in addition to ensure we actually use the updated state. + State updatedState = + state.buildUpon().setSurfaceSize(Size.ZERO).setRepeatMode(Player.REPEAT_MODE_ONE).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleClearVideoOutput(@Nullable Object videoOutput) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.clearVideoSurface(); + + assertThat(player.getSurfaceSize()).isEqualTo(Size.ZERO); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE); + verify(listener).onSurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + verifyNoMoreInteractions(listener); + } + + @Test + public void clearVideoSurface_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setSurfaceSize(new Size(/* width= */ 300, /* height= */ 200)) + .build(); + // Change something else in addition to ensure we actually use the updated state. + State updatedState = + state.buildUpon().setSurfaceSize(Size.ZERO).setRepeatMode(Player.REPEAT_MODE_ONE).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleClearVideoOutput(@Nullable Object videoOutput) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.clearVideoSurface(); + + // Verify placeholder state and listener calls. + assertThat(player.getSurfaceSize()).isEqualTo(Size.ZERO); + verify(listener).onSurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getSurfaceSize()).isEqualTo(Size.ZERO); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + verifyNoMoreInteractions(listener); + } + + @Test + public void clearVideoSurface_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SET_VIDEO_SURFACE) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleClearVideoOutput(@Nullable Object videoOutput) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.clearVideoSurface(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void addMediaItems_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.addMediaItems( + /* index= */ 1, + ImmutableList.of( + new MediaItem.Builder().setMediaId("3").build(), + new MediaItem.Builder().setMediaId("4").build())); + + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void addMediaItems_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(3) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.addMediaItems( + /* index= */ 1, + ImmutableList.of( + new MediaItem.Builder().setMediaId("3").build(), + new MediaItem.Builder().setMediaId("4").build())); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(3); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(4); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.mediaItem.mediaId).isEqualTo("4"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 3, window); + assertThat(window.uid).isEqualTo(2); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(3); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + addMediaItems_asyncHandlingWhileAdIsPlaying_usesPlaceholderStateAndInformsListeners() { + SimpleBasePlayer.PeriodData adPeriodData = + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 123)) + .build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setPeriods(ImmutableList.of(adPeriodData)) + .build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setPeriods(ImmutableList.of(adPeriodData)) + .build())) + .setCurrentMediaItemIndex(1) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.addMediaItem(/* index= */ 0, new MediaItem.Builder().setMediaId("id").build()); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("id"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void addMediaItems_asyncHandlingFromEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.addMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build())); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + addMediaItems_asyncHandlingFromEmptyWithPreviouslySetPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.addMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem)); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + addMediaItems_asyncHandlingFromEmptyWithPreviouslySetPositionExceedingNewPlaylistSize_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(5000) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(1000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(1_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.addMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build())); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + addMediaItems_asyncHandlingFromEmptyWithPreviouslySetIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.addMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem)); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void addMediaItems_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.addMediaItem(new MediaItem.Builder().setMediaId("id").build()); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void addMediaItems_withInvalidIndex_addsToEndOfPlaylist() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicInteger indexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleAddMediaItems(int index, List mediaItems) { + indexInHandleMethod.set(index); + return SettableFuture.create(); + } + }; + + player.addMediaItem(/* index= */ 5000, new MediaItem.Builder().setMediaId("new").build()); + + assertThat(indexInHandleMethod.get()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("new"); + } + + @Test + public void moveMediaItems_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setCurrentMediaItemIndex(2) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleMoveMediaItems( + int fromIndex, int toIndex, int newIndex) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 0); + + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void moveMediaItems_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setCurrentMediaItemIndex(2) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleMoveMediaItems( + int fromIndex, int toIndex, int newIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 0); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(2); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(3); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(2); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(3); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.uid).isEqualTo(1); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(2); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void + moveMediaItems_asyncHandlingWhileAdIsPlaying_usesPlaceholderStateAndInformsListeners() { + SimpleBasePlayer.PeriodData adPeriodData = + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 123)) + .build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setPeriods(ImmutableList.of(adPeriodData)) + .build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setPeriods(ImmutableList.of(adPeriodData)) + .build())) + .setCurrentMediaItemIndex(2) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleMoveMediaItems( + int fromIndex, int toIndex, int newIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 0); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(2); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(3); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(2); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(3); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.uid).isEqualTo(1); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(2); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void moveMediaItems_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleMoveMediaItems( + int fromIndex, int toIndex, int newIndex) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, /* newIndex= */ 0); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void moveMediaItems_withInvalidIndices_usesValidIndexRange() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .build(); + AtomicInteger fromIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + AtomicInteger toIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + AtomicInteger newIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleMoveMediaItems( + int fromIndex, int toIndex, int newIndex) { + fromIndexInHandleMethod.set(fromIndex); + toIndexInHandleMethod.set(toIndex); + newIndexInHandleMethod.set(newIndex); + return SettableFuture.create(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2500, /* newIndex= */ 0); + assertThat(fromIndexInHandleMethod.get()).isEqualTo(1); + assertThat(toIndexInHandleMethod.get()).isEqualTo(3); + assertThat(newIndexInHandleMethod.get()).isEqualTo(0); + + player.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 2, /* newIndex= */ 6000); + assertThat(fromIndexInHandleMethod.get()).isEqualTo(0); + assertThat(toIndexInHandleMethod.get()).isEqualTo(2); + assertThat(newIndexInHandleMethod.get()).isEqualTo(1); + + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(3); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(2); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.uid).isEqualTo(3); + verify(listener, times(2)) + .onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + verifyNoMoreInteractions(listener); + } + + @Test + public void removeMediaItems_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build())) + .setCurrentMediaItemIndex(3) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build())) + .setCurrentMediaItemIndex(1) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3); + + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void removeMediaItems_asyncHandling_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build())) + .setCurrentMediaItemIndex(3) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 5).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build())) + .setCurrentMediaItemIndex(1) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(4); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void + removeMediaItems_asyncHandlingWhileAdIsPlaying_usesPlaceholderStateAndInformsListeners() { + SimpleBasePlayer.PeriodData adPeriodData = + new SimpleBasePlayer.PeriodData.Builder(/* uid= */ new Object()) + .setAdPlaybackState( + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 123)) + .build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4) + .setPeriods(ImmutableList.of(adPeriodData)) + .build())) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .setCurrentMediaItemIndex(3) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 5).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4) + .setPeriods(ImmutableList.of(adPeriodData)) + .build())) + .setCurrentMediaItemIndex(1) + .setCurrentAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(4); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentAdGroupIndex()).isEqualTo(0); + assertThat(player.getCurrentAdIndexInAdGroup()).isEqualTo(0); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + removeMediaItems_asyncHandlingRemovingCurrentItemWithSubsequentMatch_usesPlaceholderStateAndInformsListeners() { + MediaItem lastMediaItem = new MediaItem.Builder().setMediaId("id").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4) + .setMediaItem(lastMediaItem) + .build())) + .setCurrentMediaItemIndex(1) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 5).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4) + .setMediaItem(lastMediaItem) + .build())) + .setCurrentMediaItemIndex(1) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(4); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition(lastMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + removeMediaItems_asyncHandlingRemovingCurrentItemWithoutSubsequentMatch_usesPlaceholderStateAndInformsListeners() { + MediaItem firstMediaItem = new MediaItem.Builder().setMediaId("id").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setMediaItem(firstMediaItem) + .build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_READY) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setMediaItem(firstMediaItem) + .build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 5).build())) + .setCurrentMediaItemIndex(0) + .setPlaybackState(Player.STATE_ENDED) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(1); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + firstMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPlaybackStateChanged(Player.STATE_ENDED); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_ENDED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + removeMediaItems_asyncHandlingRemovingEntirePlaylist_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_READY) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state + .buildUpon() + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(C.INDEX_UNSET) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .setPlaybackState(Player.STATE_ENDED) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.clearMediaItems(); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPlaybackStateChanged(Player.STATE_ENDED); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_ENDED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + verifyNoMoreInteractions(listener); + } + + @Test + public void removeMediaItems_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.removeMediaItem(/* index= */ 0); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void removeMediaItems_withInvalidIndex_removesToEndOfPlaylist() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicInteger fromIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + AtomicInteger toIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleRemoveMediaItems(int fromIndex, int toIndex) { + fromIndexInHandleMethod.set(fromIndex); + toIndexInHandleMethod.set(toIndex); + return SettableFuture.create(); + } + }; + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 5000); + + assertThat(fromIndexInHandleMethod.get()).isEqualTo(1); + assertThat(toIndexInHandleMethod.get()).isEqualTo(2); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(1); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void setMediaItems_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("new").build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3) + .setMediaItem(newMediaItem) + .build())) + .setCurrentMediaItemIndex(1) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems( + ImmutableList.of( + new MediaItem.Builder().setMediaId("2").build(), + new MediaItem.Builder().setMediaId("3").build())); + + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + setMediaItems_asyncHandlingWithIndexAndPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem), + /* startIndex= */ 1, + /* startPositionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithIndexAndPositionFromEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("1").build(), newMediaItem), + /* startIndex= */ 1, + /* startPositionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("1"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(3_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("1").build(), newMediaItem), + /* startIndex= */ 1, + /* startPositionMs= */ C.TIME_UNSET); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("1"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + setMediaItems_asyncHandlingWithEmptyPlaylistAndIndexAndPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(20) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* startIndex= */ 20, /* startPositionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(20); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verify(listener) + .onTimelineChanged(Timeline.EMPTY, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(20); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + setMediaItems_asyncHandlingWithEmptyPlaylistAndIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + State updatedState = + state.buildUpon().setPlaylist(ImmutableList.of()).setCurrentMediaItemIndex(20).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems( + ImmutableList.of(), /* startIndex= */ 20, /* startPositionMs= */ C.TIME_UNSET); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(20); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verify(listener) + .onTimelineChanged(Timeline.EMPTY, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(20); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void setMediaItems_asyncHandlingWithResetTrue_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.setMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build()), + /* resetPosition= */ true); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetTrueFromEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.setMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build()), + /* resetPosition= */ true); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + setMediaItems_asyncHandlingWithResetTrueToEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(C.INDEX_UNSET) + .setContentPositionMs(C.TIME_UNSET) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ true); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verify(listener) + .onTimelineChanged(Timeline.EMPTY, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition( + /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetTrueFromEmptyToEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setCurrentMediaItemIndex(C.INDEX_UNSET) + .setContentPositionMs(C.TIME_UNSET) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ true); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void setMediaItems_asyncHandlingWithResetFalse_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem), + /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyWithSetPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem), + /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyWithSetPositionExceedingPlaylistSize_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(5000) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(0) + .setContentPositionMs(1000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(1_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.setMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build()), + /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyWithIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + + player.setMediaItems( + ImmutableList.of(new MediaItem.Builder().setMediaId("3").build(), newMediaItem), + /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyWithDefaultIndexAndPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setContentPositionMs(5000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + + @Override + protected MediaItemData getPlaceholderMediaItemData(MediaItem mediaItem) { + return super.getPlaceholderMediaItemData(mediaItem) + .buildUpon() + .setDefaultPositionUs(5_000_000) + .build(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("3").build(); + + player.setMediaItems( + ImmutableList.of(newMediaItem, new MediaItem.Builder().setMediaId("2").build()), + /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("3"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5000); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + setMediaItems_asyncHandlingWithResetFalseToEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verify(listener) + .onTimelineChanged(Timeline.EMPTY, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition( + /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyToEmptyWithSetPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .setContentPositionMs(3000) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyToEmptyWithIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setCurrentMediaItemIndex(1) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @Test + public void + setMediaItems_asyncHandlingWithResetFalseFromEmptyToEmptyWithDefaultIndexAndPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.setMediaItems(ImmutableList.of(), /* resetPosition= */ false); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + } + + @Test + public void setMediaItems_withoutAvailableCommandForEmptyPlaylist_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setMediaItems(ImmutableList.of()); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setMediaItems_withoutAvailableCommandForSingleItemPlaylist_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .removeAll(Player.COMMAND_CHANGE_MEDIA_ITEMS, Player.COMMAND_SET_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setMediaItems(ImmutableList.of(new MediaItem.Builder().setMediaId("new").build())); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void setMediaItems_withJustSetMediaItemCommandForSingleItemPlaylist_isForwarded() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().add(Player.COMMAND_SET_MEDIA_ITEM).build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setMediaItems(ImmutableList.of(new MediaItem.Builder().setMediaId("new").build())); + + assertThat(callForwarded.get()).isTrue(); + } + + @Test + public void setMediaItems_withJustChangeMediaItemsCommandForSingleItemPlaylist_isForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().add(Player.COMMAND_CHANGE_MEDIA_ITEMS).build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setMediaItems(ImmutableList.of(new MediaItem.Builder().setMediaId("new").build())); + + assertThat(callForwarded.get()).isTrue(); + } + + @Test + public void setMediaItems_withoutAvailableCommandForMultiItemPlaylist_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSetMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.setMediaItems( + ImmutableList.of( + new MediaItem.Builder().setMediaId("1").build(), + new MediaItem.Builder().setMediaId("2").build())); + + assertThat(callForwarded.get()).isFalse(); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void seekTo_immediateHandling_updatesStateAndInformsListeners() { + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2) + .setMediaItem(newMediaItem) + .build())) + .build(); + State updatedState = + state.buildUpon().setCurrentMediaItemIndex(1).setContentPositionMs(3000).build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 3000); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void seekTo_asyncHandlingWithIndexAndPosition_usesPlaceholderStateAndInformsListeners() { + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2) + .setMediaItem(newMediaItem) + .build())) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state.buildUpon().setCurrentMediaItemIndex(1).setContentPositionMs(3005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getBufferedPosition()).isEqualTo(3000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithIndexAndDefaultPosition_usesPlaceholderStateAndInformsListeners() { + MediaItem newMediaItem = new MediaItem.Builder().setMediaId("2").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2) + .setMediaItem(newMediaItem) + .setDefaultPositionUs(3_000_000) + .build())) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state.buildUpon().setCurrentMediaItemIndex(1).setContentPositionMs(3005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ C.TIME_UNSET); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getBufferedPosition()).isEqualTo(3000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onMediaItemTransition(newMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithIndexAndPositionAndEmptyPlaylist_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist(ImmutableList.of()) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state.buildUpon().setCurrentMediaItemIndex(1).setContentPositionMs(3005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getBufferedPosition()).isEqualTo(3000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(3005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithIndexAndDefaultPositionAndEmptyPlaylist_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist(ImmutableList.of()) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = + state.buildUpon().setCurrentMediaItemIndex(1).setContentPositionMs(100).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ C.TIME_UNSET); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getBufferedPosition()).isEqualTo(0); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(100); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithSeekBackInCurrentItem_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = state.buildUpon().setContentPositionMs(3005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* positionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getBufferedPosition()).isEqualTo(3000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(3005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithSeekToCurrentPosition_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setContentPositionMs(3000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(7000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = state.buildUpon().setContentPositionMs(3005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* positionMs= */ 3000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(3000); + assertThat(player.getBufferedPosition()).isEqualTo(10000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(7000); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(3005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithSeekForwardInCurrentItem_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setContentPositionMs(3000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(7000)) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = state.buildUpon().setContentPositionMs(7005).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekTo(/* positionMs= */ 7000); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(7000); + assertThat(player.getBufferedPosition()).isEqualTo(10000); + assertThat(player.getTotalBufferedDuration()).isEqualTo(3000); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(7005); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. + @Test + public void + seekTo_asyncHandlingWithRepeatOfCurrentItem_usesPlaceholderStateAndInformsListeners() { + MediaItem mediaItem = new MediaItem.Builder().setMediaId("id").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setMediaItem(mediaItem) + .build())) + .setContentPositionMs(8000) + .setContentBufferedPositionMs(SimpleBasePlayer.PositionSupplier.getConstant(10000)) + .setTotalBufferedDurationMs(SimpleBasePlayer.PositionSupplier.getConstant(2000)) + .setRepeatMode(Player.REPEAT_MODE_ALL) + .build(); + // Change updated state slightly to see a difference to the placeholder state. + State updatedState = state.buildUpon().setContentPositionMs(5).build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.seekToNext(); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(0); + assertThat(player.getBufferedPosition()).isEqualTo(0); + assertThat(player.getTotalBufferedDuration()).isEqualTo(0); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + verify(listener).onMediaItemTransition(mediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + verify(listener).onSeekProcessed(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(5); + verifyNoMoreInteractions(listener); + } + + @Test + public void seekTo_withoutAvailableCommandForSeekToMediaItem_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 4000); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekTo_withoutAvailableCommandForSeekInCurrentMediaItem_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekTo(/* positionMs= */ 4000); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekToDefaultPosition_withoutAvailableCommandForSeekToMediaItem_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void + seekToDefaultPosition_withoutAvailableCommandForSeekToDefaultPosition_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToDefaultPosition(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekBack_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_SEEK_BACK).build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekBack(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekToPrevious_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_PREVIOUS) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToPrevious(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekToPreviousMediaItem_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToPreviousMediaItem(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekForward_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_SEEK_FORWARD).build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekForward(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekToNext_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder().addAllCommands().remove(Player.COMMAND_SEEK_TO_NEXT).build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToNext(); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void seekToNextMediaItem_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.seekToNextMediaItem(); + + assertThat(callForwarded.get()).isFalse(); + } + + private static Object[] getAnyArguments(Method method) { + Object[] arguments = new Object[method.getParameterCount()]; + Class[] argumentTypes = method.getParameterTypes(); + for (int i = 0; i < arguments.length; i++) { + if (argumentTypes[i].equals(Integer.TYPE)) { + arguments[i] = anyInt(); + } else if (argumentTypes[i].equals(Long.TYPE)) { + arguments[i] = anyLong(); + } else if (argumentTypes[i].equals(Float.TYPE)) { + arguments[i] = anyFloat(); + } else if (argumentTypes[i].equals(Boolean.TYPE)) { + arguments[i] = anyBoolean(); + } else { + arguments[i] = any(); + } + } + return arguments; + } } diff --git a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java index 6844330e14..716e16ec3c 100644 --- a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java @@ -17,6 +17,7 @@ package androidx.media3.common; import static com.google.common.truth.Truth.assertThat; +import android.os.Bundle; import androidx.annotation.Nullable; import androidx.media3.common.MediaItem.LiveConfiguration; import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder; @@ -267,7 +268,9 @@ public class TimelineTest { /* durationUs= */ 2, /* defaultPositionUs= */ 22, /* windowOffsetInFirstPeriodUs= */ 222, - ImmutableList.of(AdPlaybackState.NONE), + ImmutableList.of( + new AdPlaybackState( + /* adsId= */ null, /* adGroupTimesUs...= */ 10_000, 20_000)), new MediaItem.Builder().setMediaId("mediaId2").build()), new TimelineWindowDefinition( /* periodCount= */ 3, @@ -334,6 +337,29 @@ public class TimelineTest { TimelineAsserts.assertEmpty(Timeline.CREATOR.fromBundle(Timeline.EMPTY.toBundle())); } + @Test + public void window_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + Timeline.Window window = new Timeline.Window(); + // Please refrain from altering these default values since doing so would cause issues with + // backwards compatibility. + window.presentationStartTimeMs = C.TIME_UNSET; + window.windowStartTimeMs = C.TIME_UNSET; + window.elapsedRealtimeEpochOffsetMs = C.TIME_UNSET; + window.durationUs = C.TIME_UNSET; + window.mediaItem = new MediaItem.Builder().build(); + + Bundle windowBundle = window.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(windowBundle.keySet()).isEmpty(); + + Timeline.Window restoredWindow = Timeline.Window.CREATOR.fromBundle(windowBundle); + + assertThat(restoredWindow.manifest).isNull(); + TimelineAsserts.assertWindowEqualsExceptUidAndManifest( + /* expectedWindow= */ window, /* actualWindow= */ restoredWindow); + } + @Test public void roundTripViaBundle_ofWindow_yieldsEqualInstanceExceptUidAndManifest() { Timeline.Window window = new Timeline.Window(); @@ -367,6 +393,26 @@ public class TimelineTest { /* expectedWindow= */ window, /* actualWindow= */ restoredWindow); } + @Test + public void period_toBundleSkipsDefaultValues_fromBundleRestoresThem() { + Timeline.Period period = new Timeline.Period(); + // Please refrain from altering these default values since doing so would cause issues with + // backwards compatibility. + period.durationUs = C.TIME_UNSET; + + Bundle periodBundle = period.toBundle(); + + // Check that default values are skipped when bundling. + assertThat(periodBundle.keySet()).isEmpty(); + + Timeline.Period restoredPeriod = Timeline.Period.CREATOR.fromBundle(periodBundle); + + assertThat(restoredPeriod.id).isNull(); + assertThat(restoredPeriod.uid).isNull(); + TimelineAsserts.assertPeriodEqualsExceptIds( + /* expectedPeriod= */ period, /* actualPeriod= */ restoredPeriod); + } + @Test public void roundTripViaBundle_ofPeriod_yieldsEqualInstanceExceptIds() { Timeline.Period period = new Timeline.Period(); diff --git a/libraries/common/src/test/java/androidx/media3/common/util/ParsableByteArrayTest.java b/libraries/common/src/test/java/androidx/media3/common/util/ParsableByteArrayTest.java index ddaf7ee981..2a97c0dfd9 100644 --- a/libraries/common/src/test/java/androidx/media3/common/util/ParsableByteArrayTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/util/ParsableByteArrayTest.java @@ -15,11 +15,13 @@ */ package androidx.media3.common.util; +import static androidx.media3.test.utils.TestUtil.createByteArray; import static com.google.common.truth.Truth.assertThat; import static java.nio.charset.Charset.forName; import static org.junit.Assert.fail; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.base.Charsets; import com.google.common.primitives.Bytes; import java.nio.ByteBuffer; import java.util.Arrays; @@ -330,6 +332,7 @@ public final class ParsableByteArrayTest { public void readLittleEndianLong() { ParsableByteArray byteArray = new ParsableByteArray(new byte[] {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte) 0xFF}); + assertThat(byteArray.readLittleEndianLong()).isEqualTo(0xFF00000000000001L); assertThat(byteArray.getPosition()).isEqualTo(8); } @@ -337,6 +340,7 @@ public final class ParsableByteArrayTest { @Test public void readLittleEndianUnsignedInt() { ParsableByteArray byteArray = new ParsableByteArray(new byte[] {0x10, 0x00, 0x00, (byte) 0xFF}); + assertThat(byteArray.readLittleEndianUnsignedInt()).isEqualTo(0xFF000010L); assertThat(byteArray.getPosition()).isEqualTo(4); } @@ -344,6 +348,7 @@ public final class ParsableByteArrayTest { @Test public void readLittleEndianInt() { ParsableByteArray byteArray = new ParsableByteArray(new byte[] {0x01, 0x00, 0x00, (byte) 0xFF}); + assertThat(byteArray.readLittleEndianInt()).isEqualTo(0xFF000001); assertThat(byteArray.getPosition()).isEqualTo(4); } @@ -352,6 +357,7 @@ public final class ParsableByteArrayTest { public void readLittleEndianUnsignedInt24() { byte[] data = {0x01, 0x02, (byte) 0xFF}; ParsableByteArray byteArray = new ParsableByteArray(data); + assertThat(byteArray.readLittleEndianUnsignedInt24()).isEqualTo(0xFF0201); assertThat(byteArray.getPosition()).isEqualTo(3); } @@ -360,6 +366,7 @@ public final class ParsableByteArrayTest { public void readInt24Positive() { byte[] data = {0x01, 0x02, (byte) 0xFF}; ParsableByteArray byteArray = new ParsableByteArray(data); + assertThat(byteArray.readInt24()).isEqualTo(0x0102FF); assertThat(byteArray.getPosition()).isEqualTo(3); } @@ -368,6 +375,7 @@ public final class ParsableByteArrayTest { public void readInt24Negative() { byte[] data = {(byte) 0xFF, 0x02, (byte) 0x01}; ParsableByteArray byteArray = new ParsableByteArray(data); + assertThat(byteArray.readInt24()).isEqualTo(0xFFFF0201); assertThat(byteArray.getPosition()).isEqualTo(3); } @@ -376,6 +384,7 @@ public final class ParsableByteArrayTest { public void readLittleEndianUnsignedShort() { ParsableByteArray byteArray = new ParsableByteArray(new byte[] {0x01, (byte) 0xFF, 0x02, (byte) 0xFF}); + assertThat(byteArray.readLittleEndianUnsignedShort()).isEqualTo(0xFF01); assertThat(byteArray.getPosition()).isEqualTo(2); assertThat(byteArray.readLittleEndianUnsignedShort()).isEqualTo(0xFF02); @@ -386,6 +395,7 @@ public final class ParsableByteArrayTest { public void readLittleEndianShort() { ParsableByteArray byteArray = new ParsableByteArray(new byte[] {0x01, (byte) 0xFF, 0x02, (byte) 0xFF}); + assertThat(byteArray.readLittleEndianShort()).isEqualTo((short) 0xFF01); assertThat(byteArray.getPosition()).isEqualTo(2); assertThat(byteArray.readLittleEndianShort()).isEqualTo((short) 0xFF02); @@ -420,6 +430,7 @@ public final class ParsableByteArrayTest { (byte) 0x20, }; ParsableByteArray byteArray = new ParsableByteArray(data); + assertThat(byteArray.readString(data.length)).isEqualTo("ä ö ® π √ ± 谢 "); assertThat(byteArray.getPosition()).isEqualTo(data.length); } @@ -428,6 +439,7 @@ public final class ParsableByteArrayTest { public void readAsciiString() { byte[] data = new byte[] {'t', 'e', 's', 't'}; ParsableByteArray testArray = new ParsableByteArray(data); + assertThat(testArray.readString(data.length, forName("US-ASCII"))).isEqualTo("test"); assertThat(testArray.getPosition()).isEqualTo(data.length); } @@ -436,6 +448,7 @@ public final class ParsableByteArrayTest { public void readStringOutOfBoundsDoesNotMovePosition() { byte[] data = {(byte) 0xC3, (byte) 0xA4, (byte) 0x20}; ParsableByteArray byteArray = new ParsableByteArray(data); + try { byteArray.readString(data.length + 1); fail(); @@ -452,17 +465,22 @@ public final class ParsableByteArrayTest { } @Test - public void readNullTerminatedStringWithLengths() { + public void readNullTerminatedStringWithLengths_readLengthsMatchNullPositions() { byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; - // Test with lengths that match NUL byte positions. + ParsableByteArray parser = new ParsableByteArray(bytes); assertThat(parser.readNullTerminatedString(4)).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readNullTerminatedString(4)).isEqualTo("bar"); assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readNullTerminatedString()).isNull(); - // Test with lengths that do not match NUL byte positions. - parser = new ParsableByteArray(bytes); + } + + @Test + public void readNullTerminatedStringWithLengths_readLengthsDontMatchNullPositions() { + byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; + ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readNullTerminatedString(2)).isEqualTo("fo"); assertThat(parser.getPosition()).isEqualTo(2); assertThat(parser.readNullTerminatedString(2)).isEqualTo("o"); @@ -472,13 +490,23 @@ public final class ParsableByteArrayTest { assertThat(parser.readNullTerminatedString(1)).isEqualTo(""); assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readNullTerminatedString()).isNull(); - // Test with limit at NUL - parser = new ParsableByteArray(bytes, 4); + } + + @Test + public void readNullTerminatedStringWithLengths_limitAtNull() { + byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 4); + assertThat(parser.readNullTerminatedString(4)).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readNullTerminatedString()).isNull(); - // Test with limit before NUL - parser = new ParsableByteArray(bytes, 3); + } + + @Test + public void readNullTerminatedStringWithLengths_limitBeforeNull() { + byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 3); + assertThat(parser.readNullTerminatedString(3)).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(3); assertThat(parser.readNullTerminatedString()).isNull(); @@ -487,20 +515,30 @@ public final class ParsableByteArrayTest { @Test public void readNullTerminatedString() { byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; - // Test normal case. ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readNullTerminatedString()).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readNullTerminatedString()).isEqualTo("bar"); assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readNullTerminatedString()).isNull(); - // Test with limit at NUL. - parser = new ParsableByteArray(bytes, 4); + } + + @Test + public void readNullTerminatedString_withLimitAtNull() { + byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 4); + assertThat(parser.readNullTerminatedString()).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readNullTerminatedString()).isNull(); - // Test with limit before NUL. - parser = new ParsableByteArray(bytes, 3); + } + + @Test + public void readNullTerminatedString_withLimitBeforeNull() { + byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r', 0}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 3); + assertThat(parser.readNullTerminatedString()).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(3); assertThat(parser.readNullTerminatedString()).isNull(); @@ -510,6 +548,7 @@ public final class ParsableByteArrayTest { public void readNullTerminatedStringWithoutEndingNull() { byte[] bytes = new byte[] {'f', 'o', 'o', 0, 'b', 'a', 'r'}; ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readNullTerminatedString()).isEqualTo("foo"); assertThat(parser.readNullTerminatedString()).isEqualTo("bar"); assertThat(parser.readNullTerminatedString()).isNull(); @@ -518,78 +557,364 @@ public final class ParsableByteArrayTest { @Test public void readDelimiterTerminatedString() { byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r', '*'}; - // Test normal case. ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("bar"); assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readDelimiterTerminatedString('*')).isNull(); + } + + @Test + public void readDelimiterTerminatedString_limitAtDelimiter() { + byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r', '*'}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 4); - // Test with limit at delimiter. - parser = new ParsableByteArray(bytes, 4); assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readDelimiterTerminatedString('*')).isNull(); - // Test with limit before delimiter. - parser = new ParsableByteArray(bytes, 3); + } + + @Test + public void readDelimiterTerminatedString_limitBeforeDelimiter() { + byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r', '*'}; + ParsableByteArray parser = new ParsableByteArray(bytes, /* limit= */ 3); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); assertThat(parser.getPosition()).isEqualTo(3); assertThat(parser.readDelimiterTerminatedString('*')).isNull(); } @Test - public void readDelimiterTerminatedStringWithoutEndingDelimiter() { + public void readDelimiterTerminatedStringW_noDelimiter() { byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r'}; ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("bar"); assertThat(parser.readDelimiterTerminatedString('*')).isNull(); } @Test - public void readSingleLineWithoutEndingTrail() { - byte[] bytes = new byte[] {'f', 'o', 'o'}; + public void readSingleLineWithoutEndingTrail_ascii() { + byte[] bytes = "foo".getBytes(Charsets.US_ASCII); ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(3); + assertThat(parser.readLine(Charsets.US_ASCII)).isNull(); + } + + @Test + public void readSingleLineWithEndingLf_ascii() { + byte[] bytes = "foo\n".getBytes(Charsets.US_ASCII); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); + assertThat(parser.readLine(Charsets.US_ASCII)).isNull(); + } + + @Test + public void readTwoLinesWithCrFollowedByLf_ascii() { + byte[] bytes = "foo\r\nbar".getBytes(Charsets.US_ASCII); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(5); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.US_ASCII)).isNull(); + } + + @Test + public void readThreeLinesWithEmptyLine_ascii() { + byte[] bytes = "foo\r\n\rbar".getBytes(Charsets.US_ASCII); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(5); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(6); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(9); + assertThat(parser.readLine(Charsets.US_ASCII)).isNull(); + } + + @Test + public void readFourLinesWithLfFollowedByCr_ascii() { + byte[] bytes = "foo\n\r\rbar\r\n".getBytes(Charsets.US_ASCII); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(5); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(6); + assertThat(parser.readLine(Charsets.US_ASCII)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(11); + assertThat(parser.readLine(Charsets.US_ASCII)).isNull(); + } + + @Test + public void readSingleLineWithoutEndingTrail_utf8() { + byte[] bytes = "foo".getBytes(Charsets.UTF_8); + ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readLine()).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(3); assertThat(parser.readLine()).isNull(); } @Test - public void readSingleLineWithEndingLf() { - byte[] bytes = new byte[] {'f', 'o', 'o', '\n'}; + public void readSingleLineWithEndingLf_utf8() { + byte[] bytes = "foo\n".getBytes(Charsets.UTF_8); ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readLine()).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readLine()).isNull(); } @Test - public void readTwoLinesWithCrFollowedByLf() { - byte[] bytes = new byte[] {'f', 'o', 'o', '\r', '\n', 'b', 'a', 'r'}; + public void readTwoLinesWithCrFollowedByLf_utf8() { + byte[] bytes = "foo\r\nbar".getBytes(Charsets.UTF_8); ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readLine()).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(5); assertThat(parser.readLine()).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readLine()).isNull(); } @Test - public void readThreeLinesWithEmptyLine() { - byte[] bytes = new byte[] {'f', 'o', 'o', '\r', '\n', '\r', 'b', 'a', 'r'}; + public void readThreeLinesWithEmptyLineAndLeadingBom_utf8() { + byte[] bytes = + Bytes.concat(createByteArray(0xEF, 0xBB, 0xBF), "foo\r\n\rbar".getBytes(Charsets.UTF_8)); ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readLine()).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); assertThat(parser.readLine()).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(9); assertThat(parser.readLine()).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(12); assertThat(parser.readLine()).isNull(); } @Test - public void readFourLinesWithLfFollowedByCr() { - byte[] bytes = new byte[] {'f', 'o', 'o', '\n', '\r', '\r', 'b', 'a', 'r', '\r', '\n'}; + public void readFourLinesWithLfFollowedByCr_utf8() { + byte[] bytes = "foo\n\r\rbar\r\n".getBytes(Charsets.UTF_8); ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readLine()).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); assertThat(parser.readLine()).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(5); assertThat(parser.readLine()).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(6); assertThat(parser.readLine()).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(11); assertThat(parser.readLine()).isNull(); } + + @Test + public void readSingleLineWithoutEndingTrail_utf16() { + // Use UTF_16BE because we don't want the leading BOM that's added by getBytes(UTF_16). We + // explicitly test with a BOM elsewhere. + byte[] bytes = "foo".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(6); + assertThat(parser.readLine(Charsets.UTF_16)).isNull(); + } + + @Test + public void readSingleLineWithEndingLf_utf16() { + // Use UTF_16BE because we don't want the leading BOM that's added by getBytes(UTF_16). We + // explicitly test with a BOM elsewhere. + byte[] bytes = "foo\n".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16)).isNull(); + } + + @Test + public void readTwoLinesWithCrFollowedByLf_utf16() { + // Use UTF_16BE because we don't want the leading BOM that's added by getBytes(UTF_16). We + // explicitly test with a BOM elsewhere. + byte[] bytes = "foo\r\nbar".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(16); + assertThat(parser.readLine(Charsets.UTF_16)).isNull(); + } + + @Test + public void readThreeLinesWithEmptyLineAndLeadingBom_utf16() { + // getBytes(UTF_16) always adds the leading BOM. + byte[] bytes = "foo\r\n\rbar".getBytes(Charsets.UTF_16); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(14); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(20); + assertThat(parser.readLine(Charsets.UTF_16)).isNull(); + } + + @Test + public void readFourLinesWithLfFollowedByCr_utf16() { + // Use UTF_16BE because we don't want the leading BOM that's added by getBytes(UTF_16). We + // explicitly test with a BOM elsewhere. + byte[] bytes = "foo\n\r\rbar\r\n".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(22); + assertThat(parser.readLine(Charsets.UTF_16)).isNull(); + } + + @Test + public void readSingleLineWithoutEndingTrail_utf16be() { + byte[] bytes = "foo".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(6); + assertThat(parser.readLine(Charsets.UTF_16BE)).isNull(); + } + + @Test + public void readSingleLineWithEndingLf_utf16be() { + byte[] bytes = "foo\n".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16BE)).isNull(); + } + + @Test + public void readTwoLinesWithCrFollowedByLf_utf16be() { + byte[] bytes = "foo\r\nbar".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(16); + assertThat(parser.readLine(Charsets.UTF_16BE)).isNull(); + } + + @Test + public void readThreeLinesWithEmptyLineAndLeadingBom_utf16be() { + byte[] bytes = + Bytes.concat(createByteArray(0xFE, 0xFF), "foo\r\n\rbar".getBytes(Charsets.UTF_16BE)); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(14); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(20); + assertThat(parser.readLine(Charsets.UTF_16BE)).isNull(); + } + + @Test + public void readFourLinesWithLfFollowedByCr_utf16be() { + byte[] bytes = "foo\n\r\rbar\r\n".getBytes(Charsets.UTF_16BE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16BE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(22); + assertThat(parser.readLine(Charsets.UTF_16BE)).isNull(); + } + + @Test + public void readSingleLineWithoutEndingTrail_utf16le() { + byte[] bytes = "foo".getBytes(Charsets.UTF_16LE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(6); + assertThat(parser.readLine(Charsets.UTF_16LE)).isNull(); + } + + @Test + public void readSingleLineWithEndingLf_utf16le() { + byte[] bytes = "foo\n".getBytes(Charsets.UTF_16LE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16LE)).isNull(); + } + + @Test + public void readTwoLinesWithCrFollowedByLf_utf16le() { + byte[] bytes = "foo\r\nbar".getBytes(Charsets.UTF_16LE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(16); + assertThat(parser.readLine(Charsets.UTF_16LE)).isNull(); + } + + @Test + public void readThreeLinesWithEmptyLineAndLeadingBom_utf16le() { + byte[] bytes = + Bytes.concat(createByteArray(0xFF, 0xFE), "foo\r\n\rbar".getBytes(Charsets.UTF_16LE)); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(14); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(20); + assertThat(parser.readLine(Charsets.UTF_16LE)).isNull(); + } + + @Test + public void readFourLinesWithLfFollowedByCr_utf16le() { + byte[] bytes = "foo\n\r\rbar\r\n".getBytes(Charsets.UTF_16LE); + ParsableByteArray parser = new ParsableByteArray(bytes); + + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(10); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo(""); + assertThat(parser.getPosition()).isEqualTo(12); + assertThat(parser.readLine(Charsets.UTF_16LE)).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(22); + assertThat(parser.readLine(Charsets.UTF_16LE)).isNull(); + } } diff --git a/libraries/database/README.md b/libraries/database/README.md index e0c51e762f..793664d5ad 100644 --- a/libraries/database/README.md +++ b/libraries/database/README.md @@ -5,7 +5,6 @@ will not normally need to depend on this module directly. ## Links -* [Javadoc][]: Classes matching `androidx.media3.database.*` belong to this - module. +* [Javadoc][] -[Javadoc]: https://exoplayer.dev/doc/reference/index.html +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/datasource/README.md b/libraries/datasource/README.md index 42e17153e7..4e75082831 100644 --- a/libraries/datasource/README.md +++ b/libraries/datasource/README.md @@ -3,3 +3,9 @@ Provides a `DataSource` abstraction and a number of concrete implementations for reading data from different sources. Application code will not normally need to depend on this module directly. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/datasource_cronet/README.md b/libraries/datasource_cronet/README.md index c64f8b3bae..4a5dbbd674 100644 --- a/libraries/datasource_cronet/README.md +++ b/libraries/datasource_cronet/README.md @@ -119,3 +119,9 @@ whilst still using Cronet Fallback for other networking performed by your application. [Send a simple request]: https://developer.android.com/guide/topics/connectivity/cronet/start + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/datasource_okhttp/README.md b/libraries/datasource_okhttp/README.md index 6be5b52137..cb62baa0b4 100644 --- a/libraries/datasource_okhttp/README.md +++ b/libraries/datasource_okhttp/README.md @@ -48,3 +48,9 @@ new DefaultDataSourceFactory( ... /* baseDataSourceFactory= */ new OkHttpDataSource.Factory(...)); ``` + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/datasource_rtmp/README.md b/libraries/datasource_rtmp/README.md index 7f52a665b3..27890cff86 100644 --- a/libraries/datasource_rtmp/README.md +++ b/libraries/datasource_rtmp/README.md @@ -45,3 +45,9 @@ application code are required. Alternatively, if you know that your application doesn't need to handle any other protocols, you can update any `DataSource.Factory` instantiations in your application code to use `RtmpDataSource.Factory` directly. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder/README.md b/libraries/decoder/README.md index 7d738f5230..150fcef72a 100644 --- a/libraries/decoder/README.md +++ b/libraries/decoder/README.md @@ -2,3 +2,9 @@ Provides a decoder abstraction. Application code will not normally need to depend on this module directly. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_av1/README.md b/libraries/decoder_av1/README.md index 75e89ad5f5..a4f741490a 100644 --- a/libraries/decoder_av1/README.md +++ b/libraries/decoder_av1/README.md @@ -123,3 +123,11 @@ gets from the libgav1 decoder: Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_ffmpeg/README.md b/libraries/decoder_ffmpeg/README.md index 21127e65c9..a819fc23ad 100644 --- a/libraries/decoder_ffmpeg/README.md +++ b/libraries/decoder_ffmpeg/README.md @@ -116,3 +116,11 @@ then implement your own logic to use the renderer for a given track. [Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html [ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781 [Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh b/libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh index fef653bf6e..1583c1c964 100755 --- a/libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh +++ b/libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +set -eu FFMPEG_MODULE_PATH=$1 NDK_PATH=$2 diff --git a/libraries/decoder_flac/README.md b/libraries/decoder_flac/README.md index 6d1046a073..e381d2f8e1 100644 --- a/libraries/decoder_flac/README.md +++ b/libraries/decoder_flac/README.md @@ -95,3 +95,11 @@ Note: These instructions assume you're using `DefaultTrackSelector`. If you have a custom track selector the choice of `Renderer` is up to your implementation, so you need to make sure you are passing an `LibflacAudioRenderer` to the player, then implement your own logic to use the renderer for a given track. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_opus/README.md b/libraries/decoder_opus/README.md index 44845605c6..26195664a8 100644 --- a/libraries/decoder_opus/README.md +++ b/libraries/decoder_opus/README.md @@ -99,3 +99,11 @@ Note: These instructions assume you're using `DefaultTrackSelector`. If you have a custom track selector the choice of `Renderer` is up to your implementation, so you need to make sure you are passing an `LibopusAudioRenderer` to the player, then implement your own logic to use the renderer for a given track. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_opus/src/main/jni/convert_android_asm.sh b/libraries/decoder_opus/src/main/jni/convert_android_asm.sh index 9c79738439..48b141dca2 100755 --- a/libraries/decoder_opus/src/main/jni/convert_android_asm.sh +++ b/libraries/decoder_opus/src/main/jni/convert_android_asm.sh @@ -15,7 +15,7 @@ # limitations under the License. # -set -e +set -eu ASM_CONVERTER="./libopus/celt/arm/arm2gnu.pl" if [[ ! -x "${ASM_CONVERTER}" ]]; then diff --git a/libraries/decoder_vp9/README.md b/libraries/decoder_vp9/README.md index fc63129a9d..e504c7a730 100644 --- a/libraries/decoder_vp9/README.md +++ b/libraries/decoder_vp9/README.md @@ -136,3 +136,11 @@ gets from the libvpx decoder: Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/decoder_vp9/src/main/jni/generate_libvpx_android_configs.sh b/libraries/decoder_vp9/src/main/jni/generate_libvpx_android_configs.sh index 18f1dd5c69..b121886070 100755 --- a/libraries/decoder_vp9/src/main/jni/generate_libvpx_android_configs.sh +++ b/libraries/decoder_vp9/src/main/jni/generate_libvpx_android_configs.sh @@ -18,7 +18,7 @@ # a bash script that generates the necessary config files for libvpx android ndk # builds. -set -e +set -eu if [ $# -ne 0 ]; then echo "Usage: ${0}" diff --git a/libraries/effect/README.md b/libraries/effect/README.md index 50fc67fe3b..532c8f61b8 100644 --- a/libraries/effect/README.md +++ b/libraries/effect/README.md @@ -17,3 +17,9 @@ Alternatively, you can clone this GitHub project and depend on the module locally. Instructions for doing this can be found in the [top level README][]. [top level README]: ../../README.md + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer/README.md b/libraries/exoplayer/README.md index 6f6b0d3b6a..0fca23f366 100644 --- a/libraries/exoplayer/README.md +++ b/libraries/exoplayer/README.md @@ -18,3 +18,9 @@ Alternatively, you can clone this GitHub project and depend on the module locally. Instructions for doing this can be found in the [top level README][]. [top level README]: ../../README.md + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlaybackException.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlaybackException.java index 19d69529ca..82a958ce16 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlaybackException.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlaybackException.java @@ -250,17 +250,15 @@ public final class ExoPlaybackException extends PlaybackException { private ExoPlaybackException(Bundle bundle) { super(bundle); - type = bundle.getInt(keyForField(FIELD_TYPE), /* defaultValue= */ TYPE_UNEXPECTED); - rendererName = bundle.getString(keyForField(FIELD_RENDERER_NAME)); - rendererIndex = - bundle.getInt(keyForField(FIELD_RENDERER_INDEX), /* defaultValue= */ C.INDEX_UNSET); - @Nullable Bundle rendererFormatBundle = bundle.getBundle(keyForField(FIELD_RENDERER_FORMAT)); + type = bundle.getInt(FIELD_TYPE, /* defaultValue= */ TYPE_UNEXPECTED); + rendererName = bundle.getString(FIELD_RENDERER_NAME); + rendererIndex = bundle.getInt(FIELD_RENDERER_INDEX, /* defaultValue= */ C.INDEX_UNSET); + @Nullable Bundle rendererFormatBundle = bundle.getBundle(FIELD_RENDERER_FORMAT); rendererFormat = rendererFormatBundle == null ? null : Format.CREATOR.fromBundle(rendererFormatBundle); rendererFormatSupport = - bundle.getInt( - keyForField(FIELD_RENDERER_FORMAT_SUPPORT), /* defaultValue= */ C.FORMAT_HANDLED); - isRecoverable = bundle.getBoolean(keyForField(FIELD_IS_RECOVERABLE), /* defaultValue= */ false); + bundle.getInt(FIELD_RENDERER_FORMAT_SUPPORT, /* defaultValue= */ C.FORMAT_HANDLED); + isRecoverable = bundle.getBoolean(FIELD_IS_RECOVERABLE, /* defaultValue= */ false); mediaPeriodId = null; } @@ -403,12 +401,17 @@ public final class ExoPlaybackException extends PlaybackException { @UnstableApi public static final Creator CREATOR = ExoPlaybackException::new; - private static final int FIELD_TYPE = FIELD_CUSTOM_ID_BASE + 1; - private static final int FIELD_RENDERER_NAME = FIELD_CUSTOM_ID_BASE + 2; - private static final int FIELD_RENDERER_INDEX = FIELD_CUSTOM_ID_BASE + 3; - private static final int FIELD_RENDERER_FORMAT = FIELD_CUSTOM_ID_BASE + 4; - private static final int FIELD_RENDERER_FORMAT_SUPPORT = FIELD_CUSTOM_ID_BASE + 5; - private static final int FIELD_IS_RECOVERABLE = FIELD_CUSTOM_ID_BASE + 6; + private static final String FIELD_TYPE = Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 1); + private static final String FIELD_RENDERER_NAME = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 2); + private static final String FIELD_RENDERER_INDEX = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 3); + private static final String FIELD_RENDERER_FORMAT = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 4); + private static final String FIELD_RENDERER_FORMAT_SUPPORT = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 5); + private static final String FIELD_IS_RECOVERABLE = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 6); /** * {@inheritDoc} @@ -420,14 +423,14 @@ public final class ExoPlaybackException extends PlaybackException { @Override public Bundle toBundle() { Bundle bundle = super.toBundle(); - bundle.putInt(keyForField(FIELD_TYPE), type); - bundle.putString(keyForField(FIELD_RENDERER_NAME), rendererName); - bundle.putInt(keyForField(FIELD_RENDERER_INDEX), rendererIndex); + bundle.putInt(FIELD_TYPE, type); + bundle.putString(FIELD_RENDERER_NAME, rendererName); + bundle.putInt(FIELD_RENDERER_INDEX, rendererIndex); if (rendererFormat != null) { - bundle.putBundle(keyForField(FIELD_RENDERER_FORMAT), rendererFormat.toBundle()); + bundle.putBundle(FIELD_RENDERER_FORMAT, rendererFormat.toBundle()); } - bundle.putInt(keyForField(FIELD_RENDERER_FORMAT_SUPPORT), rendererFormatSupport); - bundle.putBoolean(keyForField(FIELD_IS_RECOVERABLE), isRecoverable); + bundle.putInt(FIELD_RENDERER_FORMAT_SUPPORT, rendererFormatSupport); + bundle.putBoolean(FIELD_IS_RECOVERABLE, isRecoverable); return bundle; } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java index 84769d802f..e58db58847 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -24,6 +24,7 @@ import android.media.AudioDeviceInfo; import android.media.AudioTrack; import android.media.MediaCodec; import android.os.Looper; +import android.os.Process; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; @@ -131,15 +132,15 @@ import java.util.List; * threading model"> * *

      - *
    • ExoPlayer instances must be accessed from a single application thread. For the vast - * majority of cases this should be the application's main thread. Using the application's - * main thread is also a requirement when using ExoPlayer's UI components or the IMA - * extension. The thread on which an ExoPlayer instance must be accessed can be explicitly - * specified by passing a `Looper` when creating the player. If no `Looper` is specified, then - * the `Looper` of the thread that the player is created on is used, or if that thread does - * not have a `Looper`, the `Looper` of the application's main thread is used. In all cases - * the `Looper` of the thread from which the player must be accessed can be queried using - * {@link #getApplicationLooper()}. + *
    • ExoPlayer instances must be accessed from a single application thread unless indicated + * otherwise. For the vast majority of cases this should be the application's main thread. + * Using the application's main thread is also a requirement when using ExoPlayer's UI + * components or the IMA extension. The thread on which an ExoPlayer instance must be accessed + * can be explicitly specified by passing a `Looper` when creating the player. If no `Looper` + * is specified, then the `Looper` of the thread that the player is created on is used, or if + * that thread does not have a `Looper`, the `Looper` of the application's main thread is + * used. In all cases the `Looper` of the thread from which the player must be accessed can be + * queried using {@link #getApplicationLooper()}. *
    • Registered listeners are called on the thread associated with {@link * #getApplicationLooper()}. Note that this means registered listeners are called on the same * thread which must be used to access the player. @@ -485,6 +486,7 @@ public interface ExoPlayer extends Player { /* package */ long detachSurfaceTimeoutMs; /* package */ boolean pauseAtEndOfMediaItems; /* package */ boolean usePlatformDiagnostics; + @Nullable /* package */ Looper playbackLooper; /* package */ boolean buildCalled; /** @@ -527,6 +529,7 @@ public interface ExoPlayer extends Player { *
    • {@code pauseAtEndOfMediaItems}: {@code false} *
    • {@code usePlatformDiagnostics}: {@code true} *
    • {@link Clock}: {@link Clock#DEFAULT} + *
    • {@code playbackLooper}: {@code null} (create new thread) *
    * * @param context A {@link Context}. @@ -1134,6 +1137,24 @@ public interface ExoPlayer extends Player { return this; } + /** + * Sets the {@link Looper} that will be used for playback. + * + *

    The backing thread should run with priority {@link Process#THREAD_PRIORITY_AUDIO} and + * should handle messages within 10ms. + * + * @param playbackLooper A {@link looper}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + @UnstableApi + public Builder setPlaybackLooper(Looper playbackLooper) { + checkState(!buildCalled); + this.playbackLooper = playbackLooper; + return this; + } + /** * Builds an {@link ExoPlayer} instance. * @@ -1208,6 +1229,8 @@ public interface ExoPlayer extends Player { /** * Adds a listener to receive audio offload events. * + *

    This method can be called from any thread. + * * @param listener The listener to register. */ @UnstableApi @@ -1228,6 +1251,8 @@ public interface ExoPlayer extends Player { /** * Adds an {@link AnalyticsListener} to receive analytics events. * + *

    This method can be called from any thread. + * * @param listener The listener to be added. */ void addAnalyticsListener(AnalyticsListener listener); @@ -1293,11 +1318,19 @@ public interface ExoPlayer extends Player { @Deprecated TrackSelectionArray getCurrentTrackSelections(); - /** Returns the {@link Looper} associated with the playback thread. */ + /** + * Returns the {@link Looper} associated with the playback thread. + * + *

    This method may be called from any thread. + */ @UnstableApi Looper getPlaybackLooper(); - /** Returns the {@link Clock} used for playback. */ + /** + * Returns the {@link Clock} used for playback. + * + *

    This method can be called from any thread. + */ @UnstableApi Clock getClock(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 1d087d23b3..525b2df9c4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -18,6 +18,7 @@ package androidx.media3.exoplayer; import static androidx.media3.common.C.TRACK_TYPE_AUDIO; import static androidx.media3.common.C.TRACK_TYPE_CAMERA_MOTION; import static androidx.media3.common.C.TRACK_TYPE_VIDEO; +import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.castNonNull; @@ -76,7 +77,6 @@ import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.text.Cue; import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Clock; import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.HandlerWrapper; @@ -88,6 +88,7 @@ import androidx.media3.exoplayer.PlayerMessage.Target; import androidx.media3.exoplayer.Renderer.MessageType; import androidx.media3.exoplayer.analytics.AnalyticsCollector; import androidx.media3.exoplayer.analytics.AnalyticsListener; +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector; import androidx.media3.exoplayer.analytics.MediaMetricsListener; import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.audio.AudioRendererEventListener; @@ -345,7 +346,8 @@ import java.util.concurrent.TimeoutException; applicationLooper, clock, playbackInfoUpdateListener, - playerId); + playerId, + builder.playbackLooper); volume = 1; repeatMode = Player.REPEAT_MODE_OFF; @@ -478,7 +480,7 @@ import java.util.concurrent.TimeoutException; @Override public void removeAudioOffloadListener(AudioOffloadListener listener) { - // Don't verify application thread. We allow calls to this method from any thread. + verifyApplicationThread(); audioOffloadListeners.remove(listener); } @@ -621,7 +623,6 @@ import java.util.concurrent.TimeoutException; @Override public void addMediaItems(int index, List mediaItems) { verifyApplicationThread(); - index = min(index, mediaSourceHolderSnapshots.size()); addMediaSources(index, createMediaSources(mediaItems)); } @@ -646,7 +647,8 @@ import java.util.concurrent.TimeoutException; @Override public void addMediaSources(int index, List mediaSources) { verifyApplicationThread(); - Assertions.checkArgument(index >= 0); + checkArgument(index >= 0); + index = min(index, mediaSourceHolderSnapshots.size()); Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; List holders = addMediaSourceHolders(index, mediaSources); @@ -672,7 +674,13 @@ import java.util.concurrent.TimeoutException; @Override public void removeMediaItems(int fromIndex, int toIndex) { verifyApplicationThread(); - toIndex = min(toIndex, mediaSourceHolderSnapshots.size()); + checkArgument(fromIndex >= 0 && toIndex >= fromIndex); + int playlistSize = mediaSourceHolderSnapshots.size(); + toIndex = min(toIndex, playlistSize); + if (fromIndex >= playlistSize || fromIndex == toIndex) { + // Do nothing. + return; + } PlaybackInfo newPlaybackInfo = removeMediaItemsInternal(fromIndex, toIndex); boolean positionDiscontinuity = !newPlaybackInfo.periodId.periodUid.equals(playbackInfo.periodId.periodUid); @@ -691,14 +699,16 @@ import java.util.concurrent.TimeoutException; @Override public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { verifyApplicationThread(); - Assertions.checkArgument( - fromIndex >= 0 - && fromIndex <= toIndex - && toIndex <= mediaSourceHolderSnapshots.size() - && newFromIndex >= 0); + checkArgument(fromIndex >= 0 && fromIndex <= toIndex && newFromIndex >= 0); + int playlistSize = mediaSourceHolderSnapshots.size(); + toIndex = min(toIndex, playlistSize); + newFromIndex = min(newFromIndex, playlistSize - (toIndex - fromIndex)); + if (fromIndex >= playlistSize || fromIndex == toIndex || fromIndex == newFromIndex) { + // Do nothing. + return; + } Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; - newFromIndex = min(newFromIndex, mediaSourceHolderSnapshots.size() - (toIndex - fromIndex)); Util.moveItems(mediaSourceHolderSnapshots, fromIndex, toIndex, newFromIndex); Timeline newTimeline = createMaskingTimeline(); PlaybackInfo newPlaybackInfo = @@ -821,16 +831,51 @@ import java.util.concurrent.TimeoutException; } @Override - protected void repeatCurrentMediaItem() { + public void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem) { verifyApplicationThread(); - seekToInternal( - getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET, /* repeatMediaItem= */ true); - } - - @Override - public void seekTo(int mediaItemIndex, long positionMs) { - verifyApplicationThread(); - seekToInternal(mediaItemIndex, positionMs, /* repeatMediaItem= */ false); + checkArgument(mediaItemIndex >= 0); + analyticsCollector.notifySeekStarted(); + Timeline timeline = playbackInfo.timeline; + if (!timeline.isEmpty() && mediaItemIndex >= timeline.getWindowCount()) { + return; + } + pendingOperationAcks++; + if (isPlayingAd()) { + // TODO: Investigate adding support for seeking during ads. This is complicated to do in + // general because the midroll ad preceding the seek destination must be played before the + // content position can be played, if a different ad is playing at the moment. + Log.w(TAG, "seekTo ignored because an ad is playing"); + ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate = + new ExoPlayerImplInternal.PlaybackInfoUpdate(this.playbackInfo); + playbackInfoUpdate.incrementPendingOperationAcks(1); + playbackInfoUpdateListener.onPlaybackInfoUpdate(playbackInfoUpdate); + return; + } + @Player.State + int newPlaybackState = + getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : STATE_BUFFERING; + int oldMaskingMediaItemIndex = getCurrentMediaItemIndex(); + PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackState(newPlaybackState); + newPlaybackInfo = + maskTimelineAndPosition( + newPlaybackInfo, + timeline, + maskWindowPositionMsOrGetPeriodPositionUs(timeline, mediaItemIndex, positionMs)); + internalPlayer.seekTo(timeline, mediaItemIndex, Util.msToUs(positionMs)); + updatePlaybackInfo( + newPlaybackInfo, + /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + /* seekProcessed= */ true, + /* positionDiscontinuity= */ true, + /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK, + /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo), + oldMaskingMediaItemIndex, + isRepeatingCurrentItem); } @Override @@ -1486,7 +1531,7 @@ import java.util.concurrent.TimeoutException; @Override public void removeAnalyticsListener(AnalyticsListener listener) { - // Don't verify application thread. We allow calls to this method from any thread. + verifyApplicationThread(); analyticsCollector.removeListener(checkNotNull(listener)); } @@ -1603,9 +1648,8 @@ import java.util.concurrent.TimeoutException; @Override public void removeListener(Listener listener) { - // Don't verify application thread. We allow calls to this method from any thread. - checkNotNull(listener); - listeners.remove(listener); + verifyApplicationThread(); + listeners.remove(checkNotNull(listener)); } @Override @@ -1688,8 +1732,14 @@ import java.util.concurrent.TimeoutException; return false; } + @SuppressWarnings("deprecation") // Calling deprecated methods. /* package */ void setThrowsWhenUsingWrongThread(boolean throwsWhenUsingWrongThread) { this.throwsWhenUsingWrongThread = throwsWhenUsingWrongThread; + listeners.setThrowsWhenUsingWrongThread(throwsWhenUsingWrongThread); + if (analyticsCollector instanceof DefaultAnalyticsCollector) { + ((DefaultAnalyticsCollector) analyticsCollector) + .setThrowsWhenUsingWrongThread(throwsWhenUsingWrongThread); + } } /** @@ -2219,8 +2269,6 @@ import java.util.concurrent.TimeoutException; } private PlaybackInfo removeMediaItemsInternal(int fromIndex, int toIndex) { - Assertions.checkArgument( - fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolderSnapshots.size()); int currentIndex = getCurrentMediaItemIndex(); Timeline oldTimeline = getCurrentTimeline(); int currentMediaSourceCount = mediaSourceHolderSnapshots.size(); @@ -2259,7 +2307,7 @@ import java.util.concurrent.TimeoutException; private PlaybackInfo maskTimelineAndPosition( PlaybackInfo playbackInfo, Timeline timeline, @Nullable Pair periodPositionUs) { - Assertions.checkArgument(timeline.isEmpty() || periodPositionUs != null); + checkArgument(timeline.isEmpty() || periodPositionUs != null); Timeline oldTimeline = playbackInfo.timeline; // Mask the timeline. playbackInfo = playbackInfo.copyWithTimeline(timeline); @@ -2689,48 +2737,6 @@ import java.util.concurrent.TimeoutException; } } - private void seekToInternal(int mediaItemIndex, long positionMs, boolean repeatMediaItem) { - analyticsCollector.notifySeekStarted(); - Timeline timeline = playbackInfo.timeline; - if (mediaItemIndex < 0 - || (!timeline.isEmpty() && mediaItemIndex >= timeline.getWindowCount())) { - throw new IllegalSeekPositionException(timeline, mediaItemIndex, positionMs); - } - pendingOperationAcks++; - if (isPlayingAd()) { - // TODO: Investigate adding support for seeking during ads. This is complicated to do in - // general because the midroll ad preceding the seek destination must be played before the - // content position can be played, if a different ad is playing at the moment. - Log.w(TAG, "seekTo ignored because an ad is playing"); - ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate = - new ExoPlayerImplInternal.PlaybackInfoUpdate(this.playbackInfo); - playbackInfoUpdate.incrementPendingOperationAcks(1); - playbackInfoUpdateListener.onPlaybackInfoUpdate(playbackInfoUpdate); - return; - } - @Player.State - int newPlaybackState = - getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : STATE_BUFFERING; - int oldMaskingMediaItemIndex = getCurrentMediaItemIndex(); - PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackState(newPlaybackState); - newPlaybackInfo = - maskTimelineAndPosition( - newPlaybackInfo, - timeline, - maskWindowPositionMsOrGetPeriodPositionUs(timeline, mediaItemIndex, positionMs)); - internalPlayer.seekTo(timeline, mediaItemIndex, Util.msToUs(positionMs)); - updatePlaybackInfo( - newPlaybackInfo, - /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - /* seekProcessed= */ true, - /* positionDiscontinuity= */ true, - /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK, - /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo), - oldMaskingMediaItemIndex, - repeatMediaItem); - } - private static DeviceInfo createDeviceInfo(StreamVolumeManager streamVolumeManager) { return new DeviceInfo( DeviceInfo.PLAYBACK_TYPE_LOCAL, diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index 312a9c67ae..6c3c64075b 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -189,7 +189,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private final LoadControl loadControl; private final BandwidthMeter bandwidthMeter; private final HandlerWrapper handler; - private final HandlerThread internalPlaybackThread; + @Nullable private final HandlerThread internalPlaybackThread; private final Looper playbackLooper; private final Timeline.Window window; private final Timeline.Period period; @@ -244,7 +244,8 @@ import java.util.concurrent.atomic.AtomicBoolean; Looper applicationLooper, Clock clock, PlaybackInfoUpdateListener playbackInfoUpdateListener, - PlayerId playerId) { + PlayerId playerId, + Looper playbackLooper) { this.playbackInfoUpdateListener = playbackInfoUpdateListener; this.renderers = renderers; this.trackSelector = trackSelector; @@ -280,17 +281,23 @@ import java.util.concurrent.atomic.AtomicBoolean; deliverPendingMessageAtStartPositionRequired = true; - Handler eventHandler = new Handler(applicationLooper); + HandlerWrapper eventHandler = clock.createHandler(applicationLooper, /* callback= */ null); queue = new MediaPeriodQueue(analyticsCollector, eventHandler); mediaSourceList = new MediaSourceList(/* listener= */ this, analyticsCollector, eventHandler, playerId); - // Note: The documentation for Process.THREAD_PRIORITY_AUDIO that states "Applications can - // not normally change to this priority" is incorrect. - internalPlaybackThread = new HandlerThread("ExoPlayer:Playback", Process.THREAD_PRIORITY_AUDIO); - internalPlaybackThread.start(); - playbackLooper = internalPlaybackThread.getLooper(); - handler = clock.createHandler(playbackLooper, this); + if (playbackLooper != null) { + internalPlaybackThread = null; + this.playbackLooper = playbackLooper; + } else { + // Note: The documentation for Process.THREAD_PRIORITY_AUDIO that states "Applications can + // not normally change to this priority" is incorrect. + internalPlaybackThread = + new HandlerThread("ExoPlayer:Playback", Process.THREAD_PRIORITY_AUDIO); + internalPlaybackThread.start(); + this.playbackLooper = internalPlaybackThread.getLooper(); + } + handler = clock.createHandler(this.playbackLooper, this); } public void experimentalSetForegroundModeTimeoutMs(long setForegroundModeTimeoutMs) { @@ -393,7 +400,7 @@ import java.util.concurrent.atomic.AtomicBoolean; @Override public synchronized void sendMessage(PlayerMessage message) { - if (released || !internalPlaybackThread.isAlive()) { + if (released || !playbackLooper.getThread().isAlive()) { Log.w(TAG, "Ignoring messages sent after release."); message.markAsProcessed(/* isDelivered= */ false); return; @@ -408,7 +415,7 @@ import java.util.concurrent.atomic.AtomicBoolean; * @return Whether the operations succeeded. If false, the operation timed out. */ public synchronized boolean setForegroundMode(boolean foregroundMode) { - if (released || !internalPlaybackThread.isAlive()) { + if (released || !playbackLooper.getThread().isAlive()) { return true; } if (foregroundMode) { @@ -430,7 +437,7 @@ import java.util.concurrent.atomic.AtomicBoolean; * @return Whether the release succeeded. If false, the release timed out. */ public synchronized boolean release() { - if (released || !internalPlaybackThread.isAlive()) { + if (released || !playbackLooper.getThread().isAlive()) { return true; } handler.sendEmptyMessage(MSG_RELEASE); @@ -1382,7 +1389,9 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); - internalPlaybackThread.quit(); + if (internalPlaybackThread != null) { + internalPlaybackThread.quit(); + } synchronized (this) { released = true; notifyAll(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java index f812c11ed5..7649d1bbe8 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java @@ -26,6 +26,7 @@ import androidx.media3.common.C; import androidx.media3.common.Player.RepeatMode; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.HandlerWrapper; import androidx.media3.exoplayer.analytics.AnalyticsCollector; import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; @@ -71,7 +72,7 @@ import com.google.common.collect.ImmutableList; private final Timeline.Period period; private final Timeline.Window window; private final AnalyticsCollector analyticsCollector; - private final Handler analyticsCollectorHandler; + private final HandlerWrapper analyticsCollectorHandler; private long nextWindowSequenceNumber; private @RepeatMode int repeatMode; @@ -91,7 +92,7 @@ import com.google.common.collect.ImmutableList; * on. */ public MediaPeriodQueue( - AnalyticsCollector analyticsCollector, Handler analyticsCollectorHandler) { + AnalyticsCollector analyticsCollector, HandlerWrapper analyticsCollectorHandler) { this.analyticsCollector = analyticsCollector; this.analyticsCollectorHandler = analyticsCollectorHandler; period = new Timeline.Period(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java index 5bc6e1026a..21cd5ceec4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java @@ -15,13 +15,16 @@ */ package androidx.media3.exoplayer; +import static androidx.media3.common.util.Assertions.checkNotNull; import static java.lang.Math.max; import static java.lang.Math.min; import android.os.Handler; +import android.util.Pair; import androidx.annotation.Nullable; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; import androidx.media3.datasource.TransferListener; @@ -48,6 +51,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified @@ -77,11 +81,10 @@ import java.util.Set; private final IdentityHashMap mediaSourceByMediaPeriod; private final Map mediaSourceByUid; private final MediaSourceListInfoRefreshListener mediaSourceListInfoListener; - private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; - private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final HashMap childSources; private final Set enabledMediaSourceHolders; - + private final AnalyticsCollector eventListener; + private final HandlerWrapper eventHandler; private ShuffleOrder shuffleOrder; private boolean isPrepared; @@ -101,7 +104,7 @@ import java.util.Set; public MediaSourceList( MediaSourceListInfoRefreshListener listener, AnalyticsCollector analyticsCollector, - Handler analyticsCollectorHandler, + HandlerWrapper analyticsCollectorHandler, PlayerId playerId) { this.playerId = playerId; mediaSourceListInfoListener = listener; @@ -109,12 +112,10 @@ import java.util.Set; mediaSourceByMediaPeriod = new IdentityHashMap<>(); mediaSourceByUid = new HashMap<>(); mediaSourceHolders = new ArrayList<>(); - mediaSourceEventDispatcher = new MediaSourceEventListener.EventDispatcher(); - drmEventDispatcher = new DrmSessionEventListener.EventDispatcher(); + eventListener = analyticsCollector; + eventHandler = analyticsCollectorHandler; childSources = new HashMap<>(); enabledMediaSourceHolders = new HashSet<>(); - mediaSourceEventDispatcher.addEventListener(analyticsCollectorHandler, analyticsCollector); - drmEventDispatcher.addEventListener(analyticsCollectorHandler, analyticsCollector); } /** @@ -308,7 +309,7 @@ import java.util.Set; Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid); MediaSource.MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)); - MediaSourceHolder holder = Assertions.checkNotNull(mediaSourceByUid.get(mediaSourceHolderUid)); + MediaSourceHolder holder = checkNotNull(mediaSourceByUid.get(mediaSourceHolderUid)); enableMediaSource(holder); holder.activeMediaPeriodIds.add(childMediaPeriodId); MediaPeriod mediaPeriod = @@ -324,8 +325,7 @@ import java.util.Set; * @param mediaPeriod The period to release. */ public void releasePeriod(MediaPeriod mediaPeriod) { - MediaSourceHolder holder = - Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + MediaSourceHolder holder = checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); holder.mediaSource.releasePeriod(mediaPeriod); holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id); if (!mediaSourceByMediaPeriod.isEmpty()) { @@ -450,8 +450,7 @@ import java.util.Set; private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) { // Release if the source has been removed from the playlist and no periods are still active. if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) { - MediaSourceAndListener removedChild = - Assertions.checkNotNull(childSources.remove(mediaSourceHolder)); + MediaSourceAndListener removedChild = checkNotNull(childSources.remove(mediaSourceHolder)); removedChild.mediaSource.releaseSource(removedChild.caller); removedChild.mediaSource.removeEventListener(removedChild.eventListener); removedChild.mediaSource.removeDrmEventListener(removedChild.eventListener); @@ -526,12 +525,8 @@ import java.util.Set; implements MediaSourceEventListener, DrmSessionEventListener { private final MediaSourceList.MediaSourceHolder id; - private MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; - private DrmSessionEventListener.EventDispatcher drmEventDispatcher; public ForwardingEventListener(MediaSourceList.MediaSourceHolder id) { - mediaSourceEventDispatcher = MediaSourceList.this.mediaSourceEventDispatcher; - drmEventDispatcher = MediaSourceList.this.drmEventDispatcher; this.id = id; } @@ -543,8 +538,14 @@ import java.util.Set; @Nullable MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.loadStarted(loadEventData, mediaLoadData); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onLoadStarted( + eventParameters.first, eventParameters.second, loadEventData, mediaLoadData)); } } @@ -554,8 +555,14 @@ import java.util.Set; @Nullable MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.loadCompleted(loadEventData, mediaLoadData); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onLoadCompleted( + eventParameters.first, eventParameters.second, loadEventData, mediaLoadData)); } } @@ -565,8 +572,14 @@ import java.util.Set; @Nullable MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.loadCanceled(loadEventData, mediaLoadData); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onLoadCanceled( + eventParameters.first, eventParameters.second, loadEventData, mediaLoadData)); } } @@ -578,8 +591,19 @@ import java.util.Set; MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.loadError(loadEventData, mediaLoadData, error, wasCanceled); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onLoadError( + eventParameters.first, + eventParameters.second, + loadEventData, + mediaLoadData, + error, + wasCanceled)); } } @@ -588,8 +612,14 @@ import java.util.Set; int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.upstreamDiscarded(mediaLoadData); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onUpstreamDiscarded( + eventParameters.first, checkNotNull(eventParameters.second), mediaLoadData)); } } @@ -598,8 +628,14 @@ import java.util.Set; int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - mediaSourceEventDispatcher.downstreamFormatChanged(mediaLoadData); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onDownstreamFormatChanged( + eventParameters.first, eventParameters.second, mediaLoadData)); } } @@ -610,75 +646,94 @@ import java.util.Set; int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, @DrmSession.State int state) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmSessionAcquired(state); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onDrmSessionAcquired( + eventParameters.first, eventParameters.second, state)); } } @Override public void onDrmKeysLoaded( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmKeysLoaded(); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> eventListener.onDrmKeysLoaded(eventParameters.first, eventParameters.second)); } } @Override public void onDrmSessionManagerError( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, Exception error) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmSessionManagerError(error); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onDrmSessionManagerError( + eventParameters.first, eventParameters.second, error)); } } @Override public void onDrmKeysRestored( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmKeysRestored(); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> eventListener.onDrmKeysRestored(eventParameters.first, eventParameters.second)); } } @Override public void onDrmKeysRemoved( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmKeysRemoved(); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> eventListener.onDrmKeysRemoved(eventParameters.first, eventParameters.second)); } } @Override public void onDrmSessionReleased( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - drmEventDispatcher.drmSessionReleased(); + @Nullable + Pair eventParameters = + getEventParameters(windowIndex, mediaPeriodId); + if (eventParameters != null) { + eventHandler.post( + () -> + eventListener.onDrmSessionReleased(eventParameters.first, eventParameters.second)); } } - /** Updates the event dispatcher and returns whether the event should be dispatched. */ - private boolean maybeUpdateEventDispatcher( + /** Updates the event parameters and returns whether the event should be dispatched. */ + @Nullable + private Pair getEventParameters( int childWindowIndex, @Nullable MediaSource.MediaPeriodId childMediaPeriodId) { @Nullable MediaSource.MediaPeriodId mediaPeriodId = null; if (childMediaPeriodId != null) { mediaPeriodId = getMediaPeriodIdForChildMediaPeriodId(id, childMediaPeriodId); if (mediaPeriodId == null) { // Media period not found. Ignore event. - return false; + return null; } } int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); - if (mediaSourceEventDispatcher.windowIndex != windowIndex - || !Util.areEqual(mediaSourceEventDispatcher.mediaPeriodId, mediaPeriodId)) { - mediaSourceEventDispatcher = - MediaSourceList.this.mediaSourceEventDispatcher.withParameters( - windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0L); - } - if (drmEventDispatcher.windowIndex != windowIndex - || !Util.areEqual(drmEventDispatcher.mediaPeriodId, mediaPeriodId)) { - drmEventDispatcher = - MediaSourceList.this.drmEventDispatcher.withParameters(windowIndex, mediaPeriodId); - } - return true; + return Pair.create(windowIndex, mediaPeriodId); } } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilities.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilities.java index 604b607842..dbc2fa059e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilities.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilities.java @@ -144,13 +144,13 @@ public interface RendererCapabilities { /** A mask to apply to {@link Capabilities} to obtain {@link DecoderSupport} only. */ int MODE_SUPPORT_MASK = 0b11 << 7; /** - * The renderer will use a decoder for fallback mimetype if possible as format's MIME type is - * unsupported + * The format's MIME type is unsupported and the renderer may use a decoder for a fallback MIME + * type. */ int DECODER_SUPPORT_FALLBACK_MIMETYPE = 0b10 << 7; /** The renderer is able to use the primary decoder for the format's MIME type. */ int DECODER_SUPPORT_PRIMARY = 0b1 << 7; - /** The renderer will use a fallback decoder. */ + /** The format exceeds the primary decoder's capabilities but is supported by fallback decoder */ int DECODER_SUPPORT_FALLBACK = 0; /** diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java index c5150e5c7f..5676ce7554 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java @@ -15,6 +15,8 @@ */ package androidx.media3.exoplayer; +import static androidx.annotation.VisibleForTesting.PROTECTED; + import android.content.Context; import android.media.AudioDeviceInfo; import android.os.Looper; @@ -1004,10 +1006,16 @@ public class SimpleExoPlayer extends BasePlayer return player.isLoading(); } + @SuppressWarnings("ForOverride") // Forwarding to ForOverride method in ExoPlayerImpl. @Override - public void seekTo(int mediaItemIndex, long positionMs) { + @VisibleForTesting(otherwise = PROTECTED) + public void seekTo( + int mediaItemIndex, + long positionMs, + @Player.Command int seekCommand, + boolean isRepeatingCurrentItem) { blockUntilConstructorFinished(); - player.seekTo(mediaItemIndex, positionMs); + player.seekTo(mediaItemIndex, positionMs, seekCommand, isRepeatingCurrentItem); } @Override diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java index 74062ab3e3..5044d8fa56 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollector.java @@ -96,6 +96,20 @@ public class DefaultAnalyticsCollector implements AnalyticsCollector { eventTimes = new SparseArray<>(); } + /** + * Sets whether methods throw when using the wrong thread. + * + *

    Do not use this method unless to support legacy use cases. + * + * @param throwsWhenUsingWrongThread Whether to throw when using the wrong thread. + * @deprecated Do not use this method and ensure all calls are made from the correct thread. + */ + @SuppressWarnings("deprecation") // Calling deprecated method. + @Deprecated + public void setThrowsWhenUsingWrongThread(boolean throwsWhenUsingWrongThread) { + listeners.setThrowsWhenUsingWrongThread(throwsWhenUsingWrongThread); + } + @Override @CallSuper public void addListener(AnalyticsListener listener) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java index bd77a50cac..e94d6d2416 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java @@ -60,6 +60,7 @@ import androidx.media3.extractor.Ac3Util; import androidx.media3.extractor.Ac4Util; import androidx.media3.extractor.DtsUtil; import androidx.media3.extractor.MpegAudioUtil; +import androidx.media3.extractor.OpusUtil; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.InlineMe; import com.google.errorprone.annotations.InlineMeValidationDisabled; @@ -203,6 +204,8 @@ public final class DefaultAudioSink implements AudioSink { * @param pcmFrameSize The size of the PCM frames if the {@code encoding} is PCM, 1 otherwise, * in bytes. * @param sampleRate The sample rate of the format, in Hz. + * @param bitrate The bitrate of the audio stream if the stream is compressed, or {@link + * Format#NO_VALUE} if {@code encoding} is PCM or the bitrate is not known. * @param maxAudioTrackPlaybackSpeed The maximum speed the content will be played using {@link * AudioTrack#setPlaybackParams}. 0.5 is 2x slow motion, 1 is real time, 2 is 2x fast * forward, etc. This will be {@code 1} unless {@link @@ -217,6 +220,7 @@ public final class DefaultAudioSink implements AudioSink { @OutputMode int outputMode, int pcmFrameSize, int sampleRate, + int bitrate, double maxAudioTrackPlaybackSpeed); } @@ -788,8 +792,9 @@ public final class DefaultAudioSink implements AudioSink { getAudioTrackMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding), outputEncoding, outputMode, - outputPcmFrameSize, + outputPcmFrameSize != C.LENGTH_UNSET ? outputPcmFrameSize : 1, outputSampleRate, + inputFormat.bitrate, enableAudioTrackPlaybackParams ? MAX_PLAYBACK_SPEED : DEFAULT_PLAYBACK_SPEED); offloadDisabledUntilNextConfiguration = false; @@ -1000,9 +1005,11 @@ public final class DefaultAudioSink implements AudioSink { getSubmittedFrames() - trimmingAudioProcessor.getTrimmedFrameCount()); if (!startMediaTimeUsNeedsSync && Math.abs(expectedPresentationTimeUs - presentationTimeUs) > 200000) { - listener.onAudioSinkError( - new AudioSink.UnexpectedDiscontinuityException( - presentationTimeUs, expectedPresentationTimeUs)); + if (listener != null) { + listener.onAudioSinkError( + new AudioSink.UnexpectedDiscontinuityException( + presentationTimeUs, expectedPresentationTimeUs)); + } startMediaTimeUsNeedsSync = true; } if (startMediaTimeUsNeedsSync) { @@ -1785,6 +1792,8 @@ public final class DefaultAudioSink implements AudioSink { ? 0 : (Ac3Util.parseTrueHdSyncframeAudioSampleCount(buffer, syncframeOffset) * Ac3Util.TRUEHD_RECHUNK_SAMPLE_COUNT); + case C.ENCODING_OPUS: + return OpusUtil.parsePacketAudioSampleCount(buffer); case C.ENCODING_PCM_16BIT: case C.ENCODING_PCM_16BIT_BIG_ENDIAN: case C.ENCODING_PCM_24BIT: diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java index 62c72a5722..ef40d2c4c1 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java @@ -19,6 +19,7 @@ import static androidx.media3.common.util.Util.constrainValue; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_OFFLOAD; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PCM; +import static com.google.common.math.IntMath.divide; import static com.google.common.primitives.Ints.checkedCast; import static java.lang.Math.max; @@ -32,7 +33,9 @@ import androidx.media3.extractor.Ac3Util; import androidx.media3.extractor.Ac4Util; import androidx.media3.extractor.DtsUtil; import androidx.media3.extractor.MpegAudioUtil; +import androidx.media3.extractor.OpusUtil; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.math.RoundingMode; /** Provide the buffer size to use when creating an {@link AudioTrack}. */ @UnstableApi @@ -173,10 +176,11 @@ public class DefaultAudioTrackBufferSizeProvider @OutputMode int outputMode, int pcmFrameSize, int sampleRate, + int bitrate, double maxAudioTrackPlaybackSpeed) { int bufferSize = get1xBufferSizeInBytes( - minBufferSizeInBytes, encoding, outputMode, pcmFrameSize, sampleRate); + minBufferSizeInBytes, encoding, outputMode, pcmFrameSize, sampleRate, bitrate); // Maintain the buffer duration by scaling the size accordingly. bufferSize = (int) (bufferSize * maxAudioTrackPlaybackSpeed); // Buffer size must not be lower than the AudioTrack min buffer size for this format. @@ -187,12 +191,17 @@ public class DefaultAudioTrackBufferSizeProvider /** Returns the buffer size for playback at 1x speed. */ protected int get1xBufferSizeInBytes( - int minBufferSizeInBytes, int encoding, int outputMode, int pcmFrameSize, int sampleRate) { + int minBufferSizeInBytes, + int encoding, + int outputMode, + int pcmFrameSize, + int sampleRate, + int bitrate) { switch (outputMode) { case OUTPUT_MODE_PCM: return getPcmBufferSizeInBytes(minBufferSizeInBytes, sampleRate, pcmFrameSize); case OUTPUT_MODE_PASSTHROUGH: - return getPassthroughBufferSizeInBytes(encoding); + return getPassthroughBufferSizeInBytes(encoding, bitrate); case OUTPUT_MODE_OFFLOAD: return getOffloadBufferSizeInBytes(encoding); default: @@ -209,13 +218,16 @@ public class DefaultAudioTrackBufferSizeProvider } /** Returns the buffer size for passthrough playback. */ - protected int getPassthroughBufferSizeInBytes(@C.Encoding int encoding) { + protected int getPassthroughBufferSizeInBytes(@C.Encoding int encoding, int bitrate) { int bufferSizeUs = passthroughBufferDurationUs; if (encoding == C.ENCODING_AC3) { bufferSizeUs *= ac3BufferMultiplicationFactor; } - int maxByteRate = getMaximumEncodedRateBytesPerSecond(encoding); - return checkedCast((long) bufferSizeUs * maxByteRate / C.MICROS_PER_SECOND); + int byteRate = + bitrate != Format.NO_VALUE + ? divide(bitrate, 8, RoundingMode.CEILING) + : getMaximumEncodedRateBytesPerSecond(encoding); + return checkedCast((long) bufferSizeUs * byteRate / C.MICROS_PER_SECOND); } /** Returns the buffer size for offload playback. */ @@ -255,6 +267,8 @@ public class DefaultAudioTrackBufferSizeProvider return DtsUtil.DTS_HD_MAX_RATE_BYTES_PER_SECOND; case C.ENCODING_DOLBY_TRUEHD: return Ac3Util.TRUEHD_MAX_RATE_BYTES_PER_SECOND; + case C.ENCODING_OPUS: + return OpusUtil.MAX_BYTES_PER_SECOND; case C.ENCODING_PCM_16BIT: case C.ENCODING_PCM_16BIT_BIG_ENDIAN: case C.ENCODING_PCM_24BIT: diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java index 72733a90e9..e49a9a5a3c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java @@ -245,7 +245,8 @@ public final class MediaCodecInfo { } /** - * Returns whether the decoder may support decoding the given {@code format}. + * Returns whether the decoder may support decoding the given {@code format} both functionally and + * performantly. * * @param format The input media format. * @return Whether the decoder may support decoding the given {@code format}. @@ -256,7 +257,7 @@ public final class MediaCodecInfo { return false; } - if (!isCodecProfileAndLevelSupported(format)) { + if (!isCodecProfileAndLevelSupported(format, /* checkPerformanceCapabilities= */ true)) { return false; } @@ -283,15 +284,24 @@ public final class MediaCodecInfo { } } + /** + * Returns whether the decoder may functionally support decoding the given {@code format}. + * + * @param format The input media format. + * @return Whether the decoder may functionally support decoding the given {@code format}. + */ + public boolean isFormatFunctionallySupported(Format format) { + return isSampleMimeTypeSupported(format) + && isCodecProfileAndLevelSupported(format, /* checkPerformanceCapabilities= */ false); + } + private boolean isSampleMimeTypeSupported(Format format) { return mimeType.equals(format.sampleMimeType) || mimeType.equals(MediaCodecUtil.getAlternativeCodecMimeType(format)); } - private boolean isCodecProfileAndLevelSupported(Format format) { - if (format.codecs == null) { - return true; - } + private boolean isCodecProfileAndLevelSupported( + Format format, boolean checkPerformanceCapabilities) { Pair codecProfileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(format); if (codecProfileAndLevel == null) { // If we don't know any better, we assume that the profile and level are supported. @@ -327,7 +337,7 @@ public final class MediaCodecInfo { for (CodecProfileLevel profileLevel : profileLevels) { if (profileLevel.profile == profile - && profileLevel.level >= level + && (profileLevel.level >= level || !checkPerformanceCapabilities) && !needsProfileExcludedWorkaround(mimeType, profile)) { return true; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java index 79c3b9ca7a..815b4c369e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java @@ -1113,6 +1113,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } codecInitializedTimestamp = SystemClock.elapsedRealtime(); + if (!codecInfo.isFormatSupported(inputFormat)) { + Log.w( + TAG, + Util.formatInvariant( + "Format exceeds selected codec's capabilities [%s, %s]", + Format.toLogString(inputFormat), codecName)); + } + this.codecInfo = codecInfo; this.codecOperatingRate = codecOperatingRate; codecInputFormat = inputFormat; @@ -2425,7 +2433,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { || (Util.SDK_INT <= 17 && "OMX.allwinner.video.decoder.avc".equals(name)) || (Util.SDK_INT <= 29 && ("OMX.broadcom.video_decoder.tunnel".equals(name) - || "OMX.broadcom.video_decoder.tunnel.secure".equals(name))) + || "OMX.broadcom.video_decoder.tunnel.secure".equals(name) + || "OMX.bcm.vdec.avc.tunnel".equals(name) + || "OMX.bcm.vdec.avc.tunnel.secure".equals(name) + || "OMX.bcm.vdec.hevc.tunnel".equals(name) + || "OMX.bcm.vdec.hevc.tunnel.secure".equals(name))) || ("Amazon".equals(Util.MANUFACTURER) && "AFTS".equals(Util.MODEL) && codecInfo.secure); } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java index c3200150d0..e97e053084 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecUtil.java @@ -190,22 +190,15 @@ public final class MediaCodecUtil { } /** - * Returns a copy of the provided decoder list sorted such that decoders with format support are - * listed first. The returned list is modifiable for convenience. + * Returns a copy of the provided decoder list sorted such that decoders with functional format + * support are listed first. The returned list is modifiable for convenience. */ @CheckResult public static List getDecoderInfosSortedByFormatSupport( List decoderInfos, Format format) { decoderInfos = new ArrayList<>(decoderInfos); sortByScore( - decoderInfos, - decoderInfo -> { - try { - return decoderInfo.isFormatSupported(format) ? 1 : 0; - } catch (DecoderQueryException e) { - return -1; - } - }); + decoderInfos, decoderInfo -> decoderInfo.isFormatFunctionallySupported(format) ? 1 : 0); return decoderInfos; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadService.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadService.java index e7fdf2dd46..9b6e63be00 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadService.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadService.java @@ -40,7 +40,16 @@ import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; -/** A {@link Service} for downloading media. */ +/** + * A {@link Service} for downloading media. + * + *

    Apps with target SDK 33 and greater need to add the {@code + * android.permission.POST_NOTIFICATIONS} permission to the manifest and request the permission at + * runtime before starting downloads. Without that permission granted by the user, notifications + * posted by this service are not displayed. See the + * official UI guide for more detailed information. + */ @UnstableApi public abstract class DownloadService extends Service { @@ -574,6 +583,17 @@ public abstract class DownloadService extends Service { Util.startForegroundService(context, intent); } + /** + * Clear all {@linkplain DownloadManagerHelper download manager helpers} before restarting the + * service. + * + *

    Calling this method is normally only required if an app supports downloading content for + * multiple users for which different download directories should be used. + */ + public static void clearDownloadManagerHelpers() { + downloadManagerHelpers.clear(); + } + @Override public void onCreate() { if (channelId != null) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource.java index b2f005c47d..8ad14b9cda 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource.java @@ -61,7 +61,7 @@ public final class ConcatenatingMediaSource extends CompositeMediaSourceThis class can only be used under the following conditions: + * + *

      + *
    • All sources must be non-empty. + *
    • All {@link Timeline.Window Windows} defined by the sources, except the first, must have an + * {@link Timeline.Window#getPositionInFirstPeriodUs() period offset} of zero. This excludes, + * for example, live streams or {@link ClippingMediaSource} with a non-zero start position. + *
    + */ +@UnstableApi +public final class ConcatenatingMediaSource2 extends CompositeMediaSource { + + /** A builder for {@link ConcatenatingMediaSource2} instances. */ + public static final class Builder { + + private final ImmutableList.Builder mediaSourceHoldersBuilder; + + private int index; + @Nullable private MediaItem mediaItem; + @Nullable private MediaSource.Factory mediaSourceFactory; + + /** Creates the builder. */ + public Builder() { + mediaSourceHoldersBuilder = ImmutableList.builder(); + } + + /** + * Instructs the builder to use a {@link DefaultMediaSourceFactory} to convert {@link MediaItem + * MediaItems} to {@link MediaSource MediaSources} for all future calls to {@link + * #add(MediaItem)} or {@link #add(MediaItem, long)}. + * + * @param context A {@link Context}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder useDefaultMediaSourceFactory(Context context) { + return setMediaSourceFactory(new DefaultMediaSourceFactory(context)); + } + + /** + * Sets a {@link MediaSource.Factory} that is used to convert {@link MediaItem MediaItems} to + * {@link MediaSource MediaSources} for all future calls to {@link #add(MediaItem)} or {@link + * #add(MediaItem, long)}. + * + * @param mediaSourceFactory A {@link MediaSource.Factory}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaSourceFactory(MediaSource.Factory mediaSourceFactory) { + this.mediaSourceFactory = checkNotNull(mediaSourceFactory); + return this; + } + + /** + * Sets the {@link MediaItem} to be used for the concatenated media source. + * + *

    This {@link MediaItem} will be used as {@link Timeline.Window#mediaItem} for the + * concatenated source and will be returned by {@link Player#getCurrentMediaItem()}. + * + *

    The default is {@code MediaItem.fromUri(Uri.EMPTY)}. + * + * @param mediaItem The {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + return this; + } + + /** + * Adds a {@link MediaItem} to the concatenation. + * + *

    {@link #useDefaultMediaSourceFactory(Context)} or {@link + * #setMediaSourceFactory(MediaSource.Factory)} must be called before this method. + * + *

    This method must not be used with media items for progressive media that can't provide + * their duration with their first {@link Timeline} update. Use {@link #add(MediaItem, long)} + * instead. + * + * @param mediaItem The {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaItem mediaItem) { + return add(mediaItem, /* initialPlaceholderDurationMs= */ C.TIME_UNSET); + } + + /** + * Adds a {@link MediaItem} to the concatenation and specifies its initial placeholder duration + * used while the actual duration is still unknown. + * + *

    {@link #useDefaultMediaSourceFactory(Context)} or {@link + * #setMediaSourceFactory(MediaSource.Factory)} must be called before this method. + * + *

    Setting a placeholder duration is required for media items for progressive media that + * can't provide their duration with their first {@link Timeline} update. It may also be used + * for other items to make the duration known immediately. + * + * @param mediaItem The {@link MediaItem}. + * @param initialPlaceholderDurationMs The initial placeholder duration in milliseconds used + * while the actual duration is still unknown, or {@link C#TIME_UNSET} to not define one. + * The placeholder duration is used for every {@link Timeline.Window} defined by {@link + * Timeline} of the {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaItem mediaItem, long initialPlaceholderDurationMs) { + checkNotNull(mediaItem); + checkStateNotNull( + mediaSourceFactory, + "Must use useDefaultMediaSourceFactory or setMediaSourceFactory first."); + return add(mediaSourceFactory.createMediaSource(mediaItem), initialPlaceholderDurationMs); + } + + /** + * Adds a {@link MediaSource} to the concatenation. + * + *

    This method must not be used for sources like {@link ProgressiveMediaSource} that can't + * provide their duration with their first {@link Timeline} update. Use {@link #add(MediaSource, + * long)} instead. + * + * @param mediaSource The {@link MediaSource}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaSource mediaSource) { + return add(mediaSource, /* initialPlaceholderDurationMs= */ C.TIME_UNSET); + } + + /** + * Adds a {@link MediaSource} to the concatenation and specifies its initial placeholder + * duration used while the actual duration is still unknown. + * + *

    Setting a placeholder duration is required for sources like {@link ProgressiveMediaSource} + * that can't provide their duration with their first {@link Timeline} update. It may also be + * used for other sources to make the duration known immediately. + * + * @param mediaSource The {@link MediaSource}. + * @param initialPlaceholderDurationMs The initial placeholder duration in milliseconds used + * while the actual duration is still unknown, or {@link C#TIME_UNSET} to not define one. + * The placeholder duration is used for every {@link Timeline.Window} defined by {@link + * Timeline} of the {@link MediaSource}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaSource mediaSource, long initialPlaceholderDurationMs) { + checkNotNull(mediaSource); + checkState( + !(mediaSource instanceof ProgressiveMediaSource) + || initialPlaceholderDurationMs != C.TIME_UNSET, + "Progressive media source must define an initial placeholder duration."); + mediaSourceHoldersBuilder.add( + new MediaSourceHolder(mediaSource, index++, Util.msToUs(initialPlaceholderDurationMs))); + return this; + } + + /** Builds the concatenating media source. */ + public ConcatenatingMediaSource2 build() { + checkArgument(index > 0, "Must add at least one source to the concatenation."); + if (mediaItem == null) { + mediaItem = MediaItem.fromUri(Uri.EMPTY); + } + return new ConcatenatingMediaSource2(mediaItem, mediaSourceHoldersBuilder.build()); + } + } + + private static final int MSG_UPDATE_TIMELINE = 0; + + private final MediaItem mediaItem; + private final ImmutableList mediaSourceHolders; + private final IdentityHashMap mediaSourceByMediaPeriod; + + @Nullable private Handler playbackThreadHandler; + private boolean timelineUpdateScheduled; + + private ConcatenatingMediaSource2( + MediaItem mediaItem, ImmutableList mediaSourceHolders) { + this.mediaItem = mediaItem; + this.mediaSourceHolders = mediaSourceHolders; + mediaSourceByMediaPeriod = new IdentityHashMap<>(); + } + + @Nullable + @Override + public Timeline getInitialTimeline() { + return maybeCreateConcatenatedTimeline(); + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + playbackThreadHandler = new Handler(/* callback= */ this::handleMessage); + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + prepareChildSource(/* id= */ i, holder.mediaSource); + } + scheduleTimelineUpdate(); + } + + @SuppressWarnings("MissingSuperCall") + @Override + protected void enableInternal() { + // Suppress enabling all child sources here as they can be lazily enabled when creating periods. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + int holderIndex = getChildIndex(id.periodUid); + MediaSourceHolder holder = mediaSourceHolders.get(holderIndex); + MediaPeriodId childMediaPeriodId = + id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)) + .copyWithWindowSequenceNumber( + getChildWindowSequenceNumber( + id.windowSequenceNumber, mediaSourceHolders.size(), holder.index)); + enableChildSource(holder.index); + holder.activeMediaPeriods++; + MediaPeriod mediaPeriod = + holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaSourceByMediaPeriod.put(mediaPeriod, holder); + disableUnusedMediaSources(); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MediaSourceHolder holder = checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + holder.mediaSource.releasePeriod(mediaPeriod); + holder.activeMediaPeriods--; + if (!mediaSourceByMediaPeriod.isEmpty()) { + disableUnusedMediaSources(); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + if (playbackThreadHandler != null) { + playbackThreadHandler.removeCallbacksAndMessages(null); + playbackThreadHandler = null; + } + timelineUpdateScheduled = false; + } + + @Override + protected void onChildSourceInfoRefreshed( + Integer childSourceId, MediaSource mediaSource, Timeline newTimeline) { + scheduleTimelineUpdate(); + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Integer childSourceId, MediaPeriodId mediaPeriodId) { + int childIndex = + getChildIndexFromChildWindowSequenceNumber( + mediaPeriodId.windowSequenceNumber, mediaSourceHolders.size()); + if (childSourceId != childIndex) { + // Ensure the reported media period id has the expected window sequence number. Otherwise it + // does not belong to this child source. + return null; + } + long windowSequenceNumber = + getWindowSequenceNumberFromChildWindowSequenceNumber( + mediaPeriodId.windowSequenceNumber, mediaSourceHolders.size()); + Object periodUid = getPeriodUid(childSourceId, mediaPeriodId.periodUid); + return mediaPeriodId + .copyWithPeriodUid(periodUid) + .copyWithWindowSequenceNumber(windowSequenceNumber); + } + + @Override + protected int getWindowIndexForChildWindowIndex(Integer childSourceId, int windowIndex) { + return 0; + } + + private boolean handleMessage(Message msg) { + if (msg.what == MSG_UPDATE_TIMELINE) { + updateTimeline(); + } + return true; + } + + private void scheduleTimelineUpdate() { + if (!timelineUpdateScheduled) { + checkNotNull(playbackThreadHandler).obtainMessage(MSG_UPDATE_TIMELINE).sendToTarget(); + timelineUpdateScheduled = true; + } + } + + private void updateTimeline() { + timelineUpdateScheduled = false; + @Nullable ConcatenatedTimeline timeline = maybeCreateConcatenatedTimeline(); + if (timeline != null) { + refreshSourceInfo(timeline); + } + } + + private void disableUnusedMediaSources() { + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + if (holder.activeMediaPeriods == 0) { + disableChildSource(holder.index); + } + } + } + + @Nullable + private ConcatenatedTimeline maybeCreateConcatenatedTimeline() { + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + ImmutableList.Builder timelinesBuilder = ImmutableList.builder(); + ImmutableList.Builder firstPeriodIndicesBuilder = ImmutableList.builder(); + ImmutableList.Builder periodOffsetsInWindowUsBuilder = ImmutableList.builder(); + int periodCount = 0; + boolean isSeekable = true; + boolean isDynamic = false; + long durationUs = 0; + long defaultPositionUs = 0; + long nextPeriodOffsetInWindowUs = 0; + boolean manifestsAreIdentical = true; + boolean hasInitialManifest = false; + @Nullable Object initialManifest = null; + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + Timeline timeline = holder.mediaSource.getTimeline(); + checkArgument(!timeline.isEmpty(), "Can't concatenate empty child Timeline."); + timelinesBuilder.add(timeline); + firstPeriodIndicesBuilder.add(periodCount); + periodCount += timeline.getPeriodCount(); + for (int j = 0; j < timeline.getWindowCount(); j++) { + timeline.getWindow(/* windowIndex= */ j, window); + if (!hasInitialManifest) { + initialManifest = window.manifest; + hasInitialManifest = true; + } + manifestsAreIdentical = + manifestsAreIdentical && Util.areEqual(initialManifest, window.manifest); + + long windowDurationUs = window.durationUs; + if (windowDurationUs == C.TIME_UNSET) { + if (holder.initialPlaceholderDurationUs == C.TIME_UNSET) { + // Source duration isn't known yet and we have no placeholder duration. + return null; + } + windowDurationUs = holder.initialPlaceholderDurationUs; + } + durationUs += windowDurationUs; + if (holder.index == 0 && j == 0) { + defaultPositionUs = window.defaultPositionUs; + nextPeriodOffsetInWindowUs = -window.positionInFirstPeriodUs; + } else { + checkArgument( + window.positionInFirstPeriodUs == 0, + "Can't concatenate windows. A window has a non-zero offset in a period."); + } + // Assume placeholder windows are seekable to not prevent seeking in other periods. + isSeekable &= window.isSeekable || window.isPlaceholder; + isDynamic |= window.isDynamic; + } + int childPeriodCount = timeline.getPeriodCount(); + for (int j = 0; j < childPeriodCount; j++) { + periodOffsetsInWindowUsBuilder.add(nextPeriodOffsetInWindowUs); + timeline.getPeriod(/* periodIndex= */ j, period); + long periodDurationUs = period.durationUs; + if (periodDurationUs == C.TIME_UNSET) { + checkArgument( + childPeriodCount == 1, + "Can't concatenate multiple periods with unknown duration in one window."); + long windowDurationUs = + window.durationUs != C.TIME_UNSET + ? window.durationUs + : holder.initialPlaceholderDurationUs; + periodDurationUs = windowDurationUs + window.positionInFirstPeriodUs; + } + nextPeriodOffsetInWindowUs += periodDurationUs; + } + } + return new ConcatenatedTimeline( + mediaItem, + timelinesBuilder.build(), + firstPeriodIndicesBuilder.build(), + periodOffsetsInWindowUsBuilder.build(), + isSeekable, + isDynamic, + durationUs, + defaultPositionUs, + manifestsAreIdentical ? initialManifest : null); + } + + /** + * Returns the period uid for the concatenated source from the child index and child period uid. + */ + private static Object getPeriodUid(int childIndex, Object childPeriodUid) { + return Pair.create(childIndex, childPeriodUid); + } + + /** Returns the child index from the period uid of the concatenated source. */ + @SuppressWarnings("unchecked") + private static int getChildIndex(Object periodUid) { + return ((Pair) periodUid).first; + } + + /** Returns the uid of child period from the period uid of the concatenated source. */ + @SuppressWarnings("unchecked") + private static Object getChildPeriodUid(Object periodUid) { + return ((Pair) periodUid).second; + } + + /** Returns the window sequence number used for the child source. */ + private static long getChildWindowSequenceNumber( + long windowSequenceNumber, int childCount, int childIndex) { + return windowSequenceNumber * childCount + childIndex; + } + + /** Returns the index of the child source from a child window sequence number. */ + private static int getChildIndexFromChildWindowSequenceNumber( + long childWindowSequenceNumber, int childCount) { + return (int) (childWindowSequenceNumber % childCount); + } + + /** Returns the concatenated window sequence number from a child window sequence number. */ + private static long getWindowSequenceNumberFromChildWindowSequenceNumber( + long childWindowSequenceNumber, int childCount) { + return childWindowSequenceNumber / childCount; + } + + /* package */ static final class MediaSourceHolder { + + public final MaskingMediaSource mediaSource; + public final int index; + public final long initialPlaceholderDurationUs; + + public int activeMediaPeriods; + + public MediaSourceHolder( + MediaSource mediaSource, int index, long initialPlaceholderDurationUs) { + this.mediaSource = new MaskingMediaSource(mediaSource, /* useLazyPreparation= */ false); + this.index = index; + this.initialPlaceholderDurationUs = initialPlaceholderDurationUs; + } + } + + private static final class ConcatenatedTimeline extends Timeline { + + private final MediaItem mediaItem; + private final ImmutableList timelines; + private final ImmutableList firstPeriodIndices; + private final ImmutableList periodOffsetsInWindowUs; + private final boolean isSeekable; + private final boolean isDynamic; + private final long durationUs; + private final long defaultPositionUs; + @Nullable private final Object manifest; + + public ConcatenatedTimeline( + MediaItem mediaItem, + ImmutableList timelines, + ImmutableList firstPeriodIndices, + ImmutableList periodOffsetsInWindowUs, + boolean isSeekable, + boolean isDynamic, + long durationUs, + long defaultPositionUs, + @Nullable Object manifest) { + this.mediaItem = mediaItem; + this.timelines = timelines; + this.firstPeriodIndices = firstPeriodIndices; + this.periodOffsetsInWindowUs = periodOffsetsInWindowUs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.durationUs = durationUs; + this.defaultPositionUs = defaultPositionUs; + this.manifest = manifest; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public int getPeriodCount() { + return periodOffsetsInWindowUs.size(); + } + + @Override + public final Window getWindow( + int windowIndex, Window window, long defaultPositionProjectionUs) { + return window.set( + Window.SINGLE_WINDOW_UID, + mediaItem, + manifest, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + isSeekable, + isDynamic, + /* liveConfiguration= */ null, + defaultPositionUs, + durationUs, + /* firstPeriodIndex= */ 0, + /* lastPeriodIndex= */ getPeriodCount() - 1, + /* positionInFirstPeriodUs= */ -periodOffsetsInWindowUs.get(0)); + } + + @Override + public final Period getPeriodByUid(Object periodUid, Period period) { + int childIndex = getChildIndex(periodUid); + Object childPeriodUid = getChildPeriodUid(periodUid); + Timeline timeline = timelines.get(childIndex); + int periodIndex = + firstPeriodIndices.get(childIndex) + timeline.getIndexOfPeriod(childPeriodUid); + timeline.getPeriodByUid(childPeriodUid, period); + period.windowIndex = 0; + period.positionInWindowUs = periodOffsetsInWindowUs.get(periodIndex); + period.uid = periodUid; + return period; + } + + @Override + public final Period getPeriod(int periodIndex, Period period, boolean setIds) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = firstPeriodIndices.get(childIndex); + timelines.get(childIndex).getPeriod(periodIndex - firstPeriodIndexInChild, period, setIds); + period.windowIndex = 0; + period.positionInWindowUs = periodOffsetsInWindowUs.get(periodIndex); + if (setIds) { + period.uid = getPeriodUid(childIndex, checkNotNull(period.uid)); + } + return period; + } + + @Override + public final int getIndexOfPeriod(Object uid) { + if (!(uid instanceof Pair) || !(((Pair) uid).first instanceof Integer)) { + return C.INDEX_UNSET; + } + int childIndex = getChildIndex(uid); + Object periodUid = getChildPeriodUid(uid); + int periodIndexInChild = timelines.get(childIndex).getIndexOfPeriod(periodUid); + return periodIndexInChild == C.INDEX_UNSET + ? C.INDEX_UNSET + : firstPeriodIndices.get(childIndex) + periodIndexInChild; + } + + @Override + public final Object getUidOfPeriod(int periodIndex) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = firstPeriodIndices.get(childIndex); + Object periodUidInChild = + timelines.get(childIndex).getUidOfPeriod(periodIndex - firstPeriodIndexInChild); + return getPeriodUid(childIndex, periodUidInChild); + } + + private int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor( + firstPeriodIndices, periodIndex + 1, /* inclusive= */ false, /* stayInBounds= */ false); + } + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaPeriod.java index c71b5cffd0..1cf6a3734f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaPeriod.java @@ -118,17 +118,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; for (int i = 0; i < selections.length; i++) { Integer streamChildIndex = streams[i] == null ? null : streamPeriodIndices.get(streams[i]); streamChildIndices[i] = streamChildIndex == null ? C.INDEX_UNSET : streamChildIndex; - selectionChildIndices[i] = C.INDEX_UNSET; if (selections[i] != null) { TrackGroup mergedTrackGroup = selections[i].getTrackGroup(); - TrackGroup childTrackGroup = - checkNotNull(childTrackGroupByMergedTrackGroup.get(mergedTrackGroup)); - for (int j = 0; j < periods.length; j++) { - if (periods[j].getTrackGroups().indexOf(childTrackGroup) != C.INDEX_UNSET) { - selectionChildIndices[i] = j; - break; - } - } + // mergedTrackGroup.id is 'periods array index' + ":" + childTrackGroup.id + selectionChildIndices[i] = + Integer.parseInt(mergedTrackGroup.id.substring(0, mergedTrackGroup.id.indexOf(":"))); + } else { + selectionChildIndices[i] = C.INDEX_UNSET; } } streamPeriodIndices.clear(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java index 27566036ed..fc708ba175 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java @@ -72,7 +72,7 @@ public final class MergingMediaSource extends CompositeMediaSource { } private static final int PERIOD_COUNT_UNSET = -1; - private static final MediaItem EMPTY_MEDIA_ITEM = + private static final MediaItem PLACEHOLDER_MEDIA_ITEM = new MediaItem.Builder().setMediaId("MergingMediaSource").build(); private final boolean adjustPeriodTimeOffsets; @@ -163,7 +163,7 @@ public final class MergingMediaSource extends CompositeMediaSource { @Override public MediaItem getMediaItem() { - return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : EMPTY_MEDIA_ITEM; + return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : PLACEHOLDER_MEDIA_ITEM; } @Override diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TrackGroupArray.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TrackGroupArray.java index 0bc5a014f6..befc158d25 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TrackGroupArray.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TrackGroupArray.java @@ -15,10 +15,7 @@ */ package androidx.media3.exoplayer.source; -import static java.lang.annotation.ElementType.TYPE_USE; - import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.Bundleable; import androidx.media3.common.C; @@ -26,11 +23,8 @@ import androidx.media3.common.TrackGroup; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.List; /** @@ -118,21 +112,13 @@ public final class TrackGroupArray implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_TRACK_GROUPS, - }) - private @interface FieldNumber {} - - private static final int FIELD_TRACK_GROUPS = 0; + private static final String FIELD_TRACK_GROUPS = Util.intToStringMaxRadix(0); @Override public Bundle toBundle() { Bundle bundle = new Bundle(); bundle.putParcelableArrayList( - keyForField(FIELD_TRACK_GROUPS), BundleableUtil.toBundleArrayList(trackGroups)); + FIELD_TRACK_GROUPS, BundleableUtil.toBundleArrayList(trackGroups)); return bundle; } @@ -140,8 +126,7 @@ public final class TrackGroupArray implements Bundleable { public static final Creator CREATOR = bundle -> { @Nullable - List trackGroupBundles = - bundle.getParcelableArrayList(keyForField(FIELD_TRACK_GROUPS)); + List trackGroupBundles = bundle.getParcelableArrayList(FIELD_TRACK_GROUPS); if (trackGroupBundles == null) { return new TrackGroupArray(); } @@ -163,8 +148,4 @@ public final class TrackGroupArray implements Bundleable { } } } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java index 2ddbd5908b..8cd8bb0c37 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java @@ -427,7 +427,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { @SideEffectFree private long getCurrentEventTimeUs(long positionUs) { int nextEventTimeIndex = subtitle.getNextEventTimeIndex(positionUs); - if (nextEventTimeIndex == 0) { + if (nextEventTimeIndex == 0 || subtitle.getEventTimeCount() == 0) { return subtitle.timeUs; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/DefaultTrackSelector.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/DefaultTrackSelector.java index 54bac6c44c..50a0ab216d 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/DefaultTrackSelector.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/DefaultTrackSelector.java @@ -827,69 +827,62 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Video setExceedVideoConstraintsIfNecessary( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY), + Parameters.FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY, defaultValue.exceedVideoConstraintsIfNecessary)); setAllowVideoMixedMimeTypeAdaptiveness( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS), + Parameters.FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS, defaultValue.allowVideoMixedMimeTypeAdaptiveness)); setAllowVideoNonSeamlessAdaptiveness( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS), + Parameters.FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS, defaultValue.allowVideoNonSeamlessAdaptiveness)); setAllowVideoMixedDecoderSupportAdaptiveness( bundle.getBoolean( - Parameters.keyForField( - Parameters.FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS), + Parameters.FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS, defaultValue.allowVideoMixedDecoderSupportAdaptiveness)); // Audio setExceedAudioConstraintsIfNecessary( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NCESSARY), + Parameters.FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NECESSARY, defaultValue.exceedAudioConstraintsIfNecessary)); setAllowAudioMixedMimeTypeAdaptiveness( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS), + Parameters.FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS, defaultValue.allowAudioMixedMimeTypeAdaptiveness)); setAllowAudioMixedSampleRateAdaptiveness( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS), + Parameters.FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS, defaultValue.allowAudioMixedSampleRateAdaptiveness)); setAllowAudioMixedChannelCountAdaptiveness( bundle.getBoolean( - Parameters.keyForField( - Parameters.FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS), + Parameters.FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS, defaultValue.allowAudioMixedChannelCountAdaptiveness)); setAllowAudioMixedDecoderSupportAdaptiveness( bundle.getBoolean( - Parameters.keyForField( - Parameters.FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS), + Parameters.FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS, defaultValue.allowAudioMixedDecoderSupportAdaptiveness)); setConstrainAudioChannelCountToDeviceCapabilities( bundle.getBoolean( - Parameters.keyForField( - Parameters.FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES), + Parameters.FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES, defaultValue.constrainAudioChannelCountToDeviceCapabilities)); // General setExceedRendererCapabilitiesIfNecessary( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY), + Parameters.FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY, defaultValue.exceedRendererCapabilitiesIfNecessary)); setTunnelingEnabled( - bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_TUNNELING_ENABLED), - defaultValue.tunnelingEnabled)); + bundle.getBoolean(Parameters.FIELD_TUNNELING_ENABLED, defaultValue.tunnelingEnabled)); setAllowMultipleAdaptiveSelections( bundle.getBoolean( - Parameters.keyForField(Parameters.FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS), + Parameters.FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS, defaultValue.allowMultipleAdaptiveSelections)); // Overrides selectionOverrides = new SparseArray<>(); setSelectionOverridesFromBundle(bundle); rendererDisabledFlags = makeSparseBooleanArrayFromTrueKeys( - bundle.getIntArray( - Parameters.keyForField(Parameters.FIELD_RENDERER_DISABLED_INDICES))); + bundle.getIntArray(Parameters.FIELD_RENDERER_DISABLED_INDICES)); } @CanIgnoreReturnValue @@ -1571,20 +1564,17 @@ public class DefaultTrackSelector extends MappingTrackSelector { private void setSelectionOverridesFromBundle(Bundle bundle) { @Nullable int[] rendererIndices = - bundle.getIntArray( - Parameters.keyForField(Parameters.FIELD_SELECTION_OVERRIDES_RENDERER_INDICES)); + bundle.getIntArray(Parameters.FIELD_SELECTION_OVERRIDES_RENDERER_INDICES); @Nullable ArrayList trackGroupArrayBundles = - bundle.getParcelableArrayList( - Parameters.keyForField(Parameters.FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS)); + bundle.getParcelableArrayList(Parameters.FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS); List trackGroupArrays = trackGroupArrayBundles == null ? ImmutableList.of() : BundleableUtil.fromBundleList(TrackGroupArray.CREATOR, trackGroupArrayBundles); @Nullable SparseArray selectionOverrideBundles = - bundle.getSparseParcelableArray( - Parameters.keyForField(Parameters.FIELD_SELECTION_OVERRIDES)); + bundle.getSparseParcelableArray(Parameters.FIELD_SELECTION_OVERRIDES); SparseArray selectionOverrides = selectionOverrideBundles == null ? new SparseArray<>() @@ -1874,32 +1864,40 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Bundleable implementation. - private static final int FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY = FIELD_CUSTOM_ID_BASE; - private static final int FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 1; - private static final int FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS = FIELD_CUSTOM_ID_BASE + 2; - private static final int FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NCESSARY = FIELD_CUSTOM_ID_BASE + 3; - private static final int FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 4; - private static final int FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 5; - private static final int FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 6; - private static final int FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY = - FIELD_CUSTOM_ID_BASE + 7; - private static final int FIELD_TUNNELING_ENABLED = FIELD_CUSTOM_ID_BASE + 8; - private static final int FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS = FIELD_CUSTOM_ID_BASE + 9; - private static final int FIELD_SELECTION_OVERRIDES_RENDERER_INDICES = FIELD_CUSTOM_ID_BASE + 10; - private static final int FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS = - FIELD_CUSTOM_ID_BASE + 11; - private static final int FIELD_SELECTION_OVERRIDES = FIELD_CUSTOM_ID_BASE + 12; - private static final int FIELD_RENDERER_DISABLED_INDICES = FIELD_CUSTOM_ID_BASE + 13; - private static final int FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 14; - private static final int FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS = - FIELD_CUSTOM_ID_BASE + 15; - private static final int FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES = - FIELD_CUSTOM_ID_BASE + 16; + private static final String FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE); + private static final String FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 1); + private static final String FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 2); + private static final String FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NECESSARY = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 3); + private static final String FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 4); + private static final String FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 5); + private static final String FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 6); + private static final String FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 7); + private static final String FIELD_TUNNELING_ENABLED = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 8); + private static final String FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 9); + private static final String FIELD_SELECTION_OVERRIDES_RENDERER_INDICES = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 10); + private static final String FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 11); + private static final String FIELD_SELECTION_OVERRIDES = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 12); + private static final String FIELD_RENDERER_DISABLED_INDICES = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 13); + private static final String FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 14); + private static final String FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 15); + private static final String FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES = + Util.intToStringMaxRadix(FIELD_CUSTOM_ID_BASE + 16); @Override public Bundle toBundle() { @@ -1907,49 +1905,40 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Video bundle.putBoolean( - keyForField(FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY), - exceedVideoConstraintsIfNecessary); + FIELD_EXCEED_VIDEO_CONSTRAINTS_IF_NECESSARY, exceedVideoConstraintsIfNecessary); bundle.putBoolean( - keyForField(FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS), - allowVideoMixedMimeTypeAdaptiveness); + FIELD_ALLOW_VIDEO_MIXED_MIME_TYPE_ADAPTIVENESS, allowVideoMixedMimeTypeAdaptiveness); bundle.putBoolean( - keyForField(FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS), - allowVideoNonSeamlessAdaptiveness); + FIELD_ALLOW_VIDEO_NON_SEAMLESS_ADAPTIVENESS, allowVideoNonSeamlessAdaptiveness); bundle.putBoolean( - keyForField(FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS), + FIELD_ALLOW_VIDEO_MIXED_DECODER_SUPPORT_ADAPTIVENESS, allowVideoMixedDecoderSupportAdaptiveness); // Audio bundle.putBoolean( - keyForField(FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NCESSARY), - exceedAudioConstraintsIfNecessary); + FIELD_EXCEED_AUDIO_CONSTRAINTS_IF_NECESSARY, exceedAudioConstraintsIfNecessary); bundle.putBoolean( - keyForField(FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS), - allowAudioMixedMimeTypeAdaptiveness); + FIELD_ALLOW_AUDIO_MIXED_MIME_TYPE_ADAPTIVENESS, allowAudioMixedMimeTypeAdaptiveness); bundle.putBoolean( - keyForField(FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS), - allowAudioMixedSampleRateAdaptiveness); + FIELD_ALLOW_AUDIO_MIXED_SAMPLE_RATE_ADAPTIVENESS, allowAudioMixedSampleRateAdaptiveness); bundle.putBoolean( - keyForField(FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS), + FIELD_ALLOW_AUDIO_MIXED_CHANNEL_COUNT_ADAPTIVENESS, allowAudioMixedChannelCountAdaptiveness); bundle.putBoolean( - keyForField(FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS), + FIELD_ALLOW_AUDIO_MIXED_DECODER_SUPPORT_ADAPTIVENESS, allowAudioMixedDecoderSupportAdaptiveness); bundle.putBoolean( - keyForField(FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES), + FIELD_CONSTRAIN_AUDIO_CHANNEL_COUNT_TO_DEVICE_CAPABILITIES, constrainAudioChannelCountToDeviceCapabilities); // General bundle.putBoolean( - keyForField(FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY), - exceedRendererCapabilitiesIfNecessary); - bundle.putBoolean(keyForField(FIELD_TUNNELING_ENABLED), tunnelingEnabled); - bundle.putBoolean( - keyForField(FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS), allowMultipleAdaptiveSelections); + FIELD_EXCEED_RENDERER_CAPABILITIES_IF_NECESSARY, exceedRendererCapabilitiesIfNecessary); + bundle.putBoolean(FIELD_TUNNELING_ENABLED, tunnelingEnabled); + bundle.putBoolean(FIELD_ALLOW_MULTIPLE_ADAPTIVE_SELECTIONS, allowMultipleAdaptiveSelections); putSelectionOverridesToBundle(bundle, selectionOverrides); // Only true values are put into rendererDisabledFlags. bundle.putIntArray( - keyForField(FIELD_RENDERER_DISABLED_INDICES), - getKeysFromSparseBooleanArray(rendererDisabledFlags)); + FIELD_RENDERER_DISABLED_INDICES, getKeysFromSparseBooleanArray(rendererDisabledFlags)); return bundle; } @@ -1982,12 +1971,12 @@ public class DefaultTrackSelector extends MappingTrackSelector { rendererIndices.add(rendererIndex); } bundle.putIntArray( - keyForField(FIELD_SELECTION_OVERRIDES_RENDERER_INDICES), Ints.toArray(rendererIndices)); + FIELD_SELECTION_OVERRIDES_RENDERER_INDICES, Ints.toArray(rendererIndices)); bundle.putParcelableArrayList( - keyForField(FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS), + FIELD_SELECTION_OVERRIDES_TRACK_GROUP_ARRAYS, BundleableUtil.toBundleArrayList(trackGroupArrays)); bundle.putSparseParcelableArray( - keyForField(FIELD_SELECTION_OVERRIDES), BundleableUtil.toBundleSparseArray(selections)); + FIELD_SELECTION_OVERRIDES, BundleableUtil.toBundleSparseArray(selections)); } } @@ -2116,27 +2105,17 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_GROUP_INDEX, - FIELD_TRACKS, - FIELD_TRACK_TYPE, - }) - private @interface FieldNumber {} - - private static final int FIELD_GROUP_INDEX = 0; - private static final int FIELD_TRACKS = 1; - private static final int FIELD_TRACK_TYPE = 2; + private static final String FIELD_GROUP_INDEX = Util.intToStringMaxRadix(0); + private static final String FIELD_TRACKS = Util.intToStringMaxRadix(1); + private static final String FIELD_TRACK_TYPE = Util.intToStringMaxRadix(2); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_GROUP_INDEX), groupIndex); - bundle.putIntArray(keyForField(FIELD_TRACKS), tracks); - bundle.putInt(keyForField(FIELD_TRACK_TYPE), type); + bundle.putInt(FIELD_GROUP_INDEX, groupIndex); + bundle.putIntArray(FIELD_TRACKS, tracks); + bundle.putInt(FIELD_TRACK_TYPE, type); return bundle; } @@ -2144,17 +2123,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { @UnstableApi public static final Creator CREATOR = bundle -> { - int groupIndex = bundle.getInt(keyForField(FIELD_GROUP_INDEX), -1); - @Nullable int[] tracks = bundle.getIntArray(keyForField(FIELD_TRACKS)); - int trackType = bundle.getInt(keyForField(FIELD_TRACK_TYPE), -1); + int groupIndex = bundle.getInt(FIELD_GROUP_INDEX, -1); + @Nullable int[] tracks = bundle.getIntArray(FIELD_TRACKS); + int trackType = bundle.getInt(FIELD_TRACK_TYPE, -1); Assertions.checkArgument(groupIndex >= 0 && trackType >= 0); Assertions.checkNotNull(tracks); return new SelectionOverride(groupIndex, tracks, trackType); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } /** diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/DefaultBandwidthMeter.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/DefaultBandwidthMeter.java index 04f73c76ee..924b9d3ac0 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/DefaultBandwidthMeter.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/DefaultBandwidthMeter.java @@ -48,27 +48,27 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** Default initial Wifi bitrate estimate in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = - ImmutableList.of(4_800_000L, 3_100_000L, 2_100_000L, 1_500_000L, 800_000L); + ImmutableList.of(4_400_000L, 3_200_000L, 2_300_000L, 1_600_000L, 810_000L); /** Default initial 2G bitrate estimates in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = - ImmutableList.of(1_500_000L, 1_000_000L, 730_000L, 440_000L, 170_000L); + ImmutableList.of(1_400_000L, 990_000L, 730_000L, 510_000L, 230_000L); /** Default initial 3G bitrate estimates in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = - ImmutableList.of(2_200_000L, 1_400_000L, 1_100_000L, 910_000L, 620_000L); + ImmutableList.of(2_100_000L, 1_400_000L, 1_000_000L, 890_000L, 640_000L); /** Default initial 4G bitrate estimates in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = - ImmutableList.of(3_000_000L, 1_900_000L, 1_400_000L, 1_000_000L, 660_000L); + ImmutableList.of(2_600_000L, 1_700_000L, 1_300_000L, 1_000_000L, 700_000L); /** Default initial 5G-NSA bitrate estimates in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_5G_NSA = - ImmutableList.of(6_000_000L, 4_100_000L, 3_200_000L, 1_800_000L, 1_000_000L); + ImmutableList.of(5_700_000L, 3_700_000L, 2_300_000L, 1_700_000L, 990_000L); /** Default initial 5G-SA bitrate estimates in bits per second. */ public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_5G_SA = - ImmutableList.of(2_800_000L, 2_400_000L, 1_600_000L, 1_100_000L, 950_000L); + ImmutableList.of(2_800_000L, 1_800_000L, 1_400_000L, 1_100_000L, 870_000L); /** * Default initial bitrate estimate used when the device is offline or the network type cannot be @@ -483,394 +483,410 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList */ private static int[] getInitialBitrateCountryGroupAssignment(String country) { switch (country) { + case "AD": + case "CW": + return new int[] {2, 2, 0, 0, 2, 2}; case "AE": - return new int[] {1, 4, 4, 4, 4, 0}; + return new int[] {1, 4, 3, 4, 4, 2}; case "AG": - return new int[] {2, 4, 1, 2, 2, 2}; - case "AI": - return new int[] {0, 2, 0, 3, 2, 2}; + return new int[] {2, 4, 3, 4, 2, 2}; + case "AL": + return new int[] {1, 1, 1, 3, 2, 2}; case "AM": return new int[] {2, 3, 2, 3, 2, 2}; case "AO": - return new int[] {4, 4, 3, 2, 2, 2}; + return new int[] {4, 4, 4, 3, 2, 2}; case "AS": return new int[] {2, 2, 3, 3, 2, 2}; case "AT": - return new int[] {1, 0, 1, 1, 0, 0}; + return new int[] {1, 2, 1, 4, 1, 4}; case "AU": - return new int[] {0, 1, 1, 1, 2, 0}; - case "AW": - return new int[] {1, 3, 4, 4, 2, 2}; - case "BA": - return new int[] {1, 2, 1, 1, 2, 2}; - case "BD": - return new int[] {2, 1, 3, 3, 2, 2}; + return new int[] {0, 2, 1, 1, 3, 0}; case "BE": return new int[] {0, 1, 4, 4, 3, 2}; - case "BF": - return new int[] {4, 3, 4, 3, 2, 2}; case "BH": - return new int[] {1, 2, 1, 3, 4, 2}; + return new int[] {1, 3, 1, 4, 4, 2}; case "BJ": - return new int[] {4, 4, 3, 3, 2, 2}; + return new int[] {4, 4, 2, 3, 2, 2}; + case "BN": + return new int[] {3, 2, 0, 1, 2, 2}; case "BO": return new int[] {1, 2, 3, 2, 2, 2}; - case "BS": - return new int[] {4, 4, 2, 2, 2, 2}; - case "BT": - return new int[] {3, 1, 3, 2, 2, 2}; + case "BR": + return new int[] {1, 1, 2, 1, 1, 0}; case "BW": return new int[] {3, 2, 1, 0, 2, 2}; case "BY": - return new int[] {0, 1, 2, 3, 2, 2}; - case "BZ": - return new int[] {2, 4, 2, 1, 2, 2}; + return new int[] {1, 1, 2, 3, 2, 2}; case "CA": - return new int[] {0, 2, 2, 2, 3, 2}; - case "CD": - return new int[] {4, 2, 3, 2, 2, 2}; + return new int[] {0, 2, 3, 3, 3, 3}; case "CH": - return new int[] {0, 0, 0, 1, 0, 2}; + return new int[] {0, 0, 0, 0, 0, 3}; + case "BZ": + case "CK": + return new int[] {2, 2, 2, 1, 2, 2}; + case "CL": + return new int[] {1, 1, 2, 1, 3, 2}; case "CM": - return new int[] {3, 3, 3, 3, 2, 2}; + return new int[] {4, 3, 3, 4, 2, 2}; case "CN": - return new int[] {2, 0, 1, 1, 3, 2}; + return new int[] {2, 0, 4, 3, 3, 1}; case "CO": - return new int[] {2, 3, 4, 3, 2, 2}; + return new int[] {2, 3, 4, 2, 2, 2}; case "CR": - return new int[] {2, 3, 4, 4, 2, 2}; + return new int[] {2, 4, 4, 4, 2, 2}; case "CV": - return new int[] {2, 1, 0, 0, 2, 2}; - case "BN": - case "CW": - return new int[] {2, 2, 0, 0, 2, 2}; + return new int[] {2, 3, 0, 1, 2, 2}; + case "CZ": + return new int[] {0, 0, 2, 0, 1, 2}; case "DE": - return new int[] {0, 1, 2, 2, 2, 3}; - case "DK": - return new int[] {0, 0, 3, 2, 0, 2}; + return new int[] {0, 1, 3, 2, 2, 2}; case "DO": return new int[] {3, 4, 4, 4, 4, 2}; + case "AZ": + case "BF": + case "DZ": + return new int[] {3, 3, 4, 4, 2, 2}; case "EC": - return new int[] {2, 3, 2, 1, 2, 2}; - case "ET": - return new int[] {4, 3, 3, 1, 2, 2}; + return new int[] {1, 3, 2, 1, 2, 2}; + case "CI": + case "EG": + return new int[] {3, 4, 3, 3, 2, 2}; case "FI": - return new int[] {0, 0, 0, 3, 0, 2}; + return new int[] {0, 0, 0, 2, 0, 2}; case "FJ": - return new int[] {3, 1, 2, 2, 2, 2}; + return new int[] {3, 1, 2, 3, 2, 2}; case "FM": - return new int[] {4, 2, 4, 1, 2, 2}; - case "FR": - return new int[] {1, 2, 3, 1, 0, 2}; - case "GB": - return new int[] {0, 0, 1, 1, 1, 1}; - case "GE": - return new int[] {1, 1, 1, 2, 2, 2}; + return new int[] {4, 2, 3, 0, 2, 2}; + case "AI": case "BB": + case "BM": + case "BQ": case "DM": case "FO": - case "GI": return new int[] {0, 2, 0, 0, 2, 2}; - case "AF": - case "GM": - return new int[] {4, 3, 3, 4, 2, 2}; - case "GN": - return new int[] {4, 3, 4, 2, 2, 2}; - case "GQ": - return new int[] {4, 2, 1, 4, 2, 2}; - case "GT": - return new int[] {2, 3, 2, 2, 2, 2}; + case "FR": + return new int[] {1, 1, 2, 1, 1, 2}; + case "GB": + return new int[] {0, 1, 1, 2, 1, 2}; + case "GE": + return new int[] {1, 0, 0, 2, 2, 2}; + case "GG": + return new int[] {0, 2, 1, 0, 2, 2}; case "CG": - case "EG": + case "GH": + return new int[] {3, 3, 3, 3, 2, 2}; + case "GM": + return new int[] {4, 3, 2, 4, 2, 2}; + case "GN": + return new int[] {4, 4, 4, 2, 2, 2}; + case "GP": + return new int[] {3, 1, 1, 3, 2, 2}; + case "GQ": + return new int[] {4, 4, 3, 3, 2, 2}; + case "GT": + return new int[] {2, 2, 2, 1, 1, 2}; + case "AW": + case "GU": + return new int[] {1, 2, 4, 4, 2, 2}; case "GW": - return new int[] {3, 4, 3, 3, 2, 2}; + return new int[] {4, 4, 2, 2, 2, 2}; case "GY": - return new int[] {3, 2, 2, 1, 2, 2}; + return new int[] {3, 0, 1, 1, 2, 2}; case "HK": - return new int[] {0, 1, 2, 3, 2, 0}; - case "HU": - return new int[] {0, 0, 0, 1, 3, 2}; + return new int[] {0, 1, 1, 3, 2, 0}; + case "HN": + return new int[] {3, 3, 2, 2, 2, 2}; case "ID": - return new int[] {3, 1, 2, 2, 3, 2}; - case "ES": + return new int[] {3, 1, 1, 2, 3, 2}; + case "BA": case "IE": - return new int[] {0, 1, 1, 1, 2, 2}; - case "CL": + return new int[] {1, 1, 1, 1, 2, 2}; case "IL": - return new int[] {1, 2, 2, 2, 3, 2}; + return new int[] {1, 2, 2, 3, 4, 2}; + case "IM": + return new int[] {0, 2, 0, 1, 2, 2}; case "IN": - return new int[] {1, 1, 3, 2, 3, 3}; - case "IQ": - return new int[] {3, 2, 2, 3, 2, 2}; + return new int[] {1, 1, 2, 1, 2, 1}; case "IR": - return new int[] {3, 0, 1, 1, 4, 1}; + return new int[] {4, 2, 3, 3, 4, 2}; + case "IS": + return new int[] {0, 0, 1, 0, 0, 2}; case "IT": - return new int[] {0, 0, 0, 1, 1, 2}; + return new int[] {0, 0, 1, 1, 1, 2}; + case "GI": + case "JE": + return new int[] {1, 2, 0, 1, 2, 2}; case "JM": - return new int[] {2, 4, 3, 2, 2, 2}; + return new int[] {2, 4, 2, 1, 2, 2}; case "JO": - return new int[] {2, 1, 1, 2, 2, 2}; + return new int[] {2, 0, 1, 1, 2, 2}; case "JP": - return new int[] {0, 1, 1, 2, 2, 4}; - case "KH": - return new int[] {2, 1, 4, 2, 2, 2}; - case "CF": - case "KI": - return new int[] {4, 2, 4, 2, 2, 2}; - case "FK": + return new int[] {0, 3, 3, 3, 4, 4}; case "KE": - case "KP": - return new int[] {3, 2, 2, 2, 2, 2}; + return new int[] {3, 2, 2, 1, 2, 2}; + case "KH": + return new int[] {1, 0, 4, 2, 2, 2}; + case "CU": + case "KI": + return new int[] {4, 2, 4, 3, 2, 2}; + case "CD": + case "KM": + return new int[] {4, 3, 3, 2, 2, 2}; case "KR": - return new int[] {0, 1, 1, 3, 4, 4}; - case "CY": + return new int[] {0, 2, 2, 4, 4, 4}; case "KW": - return new int[] {1, 0, 0, 0, 0, 2}; + return new int[] {1, 0, 1, 0, 0, 2}; + case "BD": case "KZ": return new int[] {2, 1, 2, 2, 2, 2}; case "LA": return new int[] {1, 2, 1, 3, 2, 2}; + case "BS": case "LB": - return new int[] {3, 3, 2, 4, 2, 2}; + return new int[] {3, 2, 1, 2, 2, 2}; case "LK": - return new int[] {3, 1, 3, 3, 4, 2}; - case "CI": - case "DZ": + return new int[] {3, 2, 3, 4, 4, 2}; case "LR": - return new int[] {3, 4, 4, 4, 2, 2}; - case "LS": - return new int[] {3, 3, 2, 2, 2, 2}; - case "LT": - return new int[] {0, 0, 0, 0, 2, 2}; + return new int[] {3, 4, 3, 4, 2, 2}; case "LU": - return new int[] {1, 0, 3, 2, 1, 4}; + return new int[] {1, 1, 4, 2, 0, 2}; + case "CY": + case "HR": + case "LV": + return new int[] {1, 0, 0, 0, 0, 2}; case "MA": - return new int[] {3, 3, 1, 1, 2, 2}; + return new int[] {3, 3, 2, 1, 2, 2}; case "MC": return new int[] {0, 2, 2, 0, 2, 2}; + case "MD": + return new int[] {1, 0, 0, 0, 2, 2}; case "ME": - return new int[] {2, 0, 0, 1, 2, 2}; + return new int[] {2, 0, 0, 1, 1, 2}; + case "MH": + return new int[] {4, 2, 1, 3, 2, 2}; case "MK": - return new int[] {1, 0, 0, 1, 3, 2}; + return new int[] {2, 0, 0, 1, 3, 2}; case "MM": - return new int[] {2, 4, 2, 3, 2, 2}; + return new int[] {2, 2, 2, 3, 4, 2}; case "MN": return new int[] {2, 0, 1, 2, 2, 2}; case "MO": - case "MP": - return new int[] {0, 2, 4, 4, 2, 2}; - case "GP": + return new int[] {0, 2, 4, 4, 4, 2}; + case "KG": case "MQ": - return new int[] {2, 1, 2, 3, 2, 2}; - case "MU": - return new int[] {3, 1, 1, 2, 2, 2}; + return new int[] {2, 1, 1, 2, 2, 2}; + case "MR": + return new int[] {4, 2, 3, 4, 2, 2}; + case "DK": + case "EE": + case "HU": + case "LT": + case "MT": + return new int[] {0, 0, 0, 0, 0, 2}; case "MV": - return new int[] {3, 4, 1, 4, 2, 2}; + return new int[] {3, 4, 1, 3, 3, 2}; case "MW": return new int[] {4, 2, 3, 3, 2, 2}; case "MX": - return new int[] {2, 4, 3, 4, 2, 2}; + return new int[] {3, 4, 4, 4, 2, 2}; case "MY": - return new int[] {1, 0, 3, 1, 3, 2}; - case "MZ": - return new int[] {3, 1, 2, 1, 2, 2}; + return new int[] {1, 0, 4, 1, 2, 2}; + case "NA": + return new int[] {3, 4, 3, 2, 2, 2}; case "NC": - return new int[] {3, 3, 4, 4, 2, 2}; + return new int[] {3, 2, 3, 4, 2, 2}; case "NG": return new int[] {3, 4, 2, 1, 2, 2}; + case "NI": + return new int[] {2, 3, 4, 3, 2, 2}; case "NL": - return new int[] {0, 2, 2, 3, 0, 3}; - case "CZ": + return new int[] {0, 2, 3, 3, 0, 4}; case "NO": - return new int[] {0, 0, 2, 0, 1, 2}; + return new int[] {0, 1, 2, 1, 1, 2}; case "NP": - return new int[] {2, 2, 4, 3, 2, 2}; + return new int[] {2, 1, 4, 3, 2, 2}; case "NR": + return new int[] {4, 0, 3, 2, 2, 2}; case "NU": return new int[] {4, 2, 2, 1, 2, 2}; + case "NZ": + return new int[] {1, 0, 2, 2, 4, 2}; case "OM": return new int[] {2, 3, 1, 3, 4, 2}; - case "GU": + case "PA": + return new int[] {2, 3, 3, 3, 2, 2}; case "PE": - return new int[] {1, 2, 4, 4, 4, 2}; - case "CK": - case "PF": - return new int[] {2, 2, 2, 1, 2, 2}; - case "ML": + return new int[] {1, 2, 4, 4, 3, 2}; + case "AF": case "PG": - return new int[] {4, 3, 3, 2, 2, 2}; + return new int[] {4, 3, 3, 3, 2, 2}; case "PH": - return new int[] {2, 1, 3, 3, 3, 0}; - case "NZ": + return new int[] {2, 1, 3, 2, 2, 0}; case "PL": - return new int[] {1, 1, 2, 2, 4, 2}; + return new int[] {2, 1, 2, 2, 4, 2}; case "PR": - return new int[] {2, 0, 2, 1, 2, 1}; + return new int[] {2, 0, 2, 0, 2, 1}; case "PS": - return new int[] {3, 4, 1, 2, 2, 2}; + return new int[] {3, 4, 1, 4, 2, 2}; + case "PT": + return new int[] {1, 0, 0, 0, 1, 2}; case "PW": - return new int[] {2, 2, 4, 1, 2, 2}; - case "QA": - return new int[] {2, 4, 4, 4, 4, 2}; + return new int[] {2, 2, 4, 2, 2, 2}; + case "BL": case "MF": + case "PY": + return new int[] {1, 2, 2, 2, 2, 2}; + case "QA": + return new int[] {1, 4, 4, 4, 4, 2}; case "RE": - return new int[] {1, 2, 1, 2, 2, 2}; + return new int[] {1, 2, 2, 3, 1, 2}; case "RO": return new int[] {0, 0, 1, 2, 1, 2}; - case "MD": case "RS": - return new int[] {1, 0, 0, 0, 2, 2}; + return new int[] {2, 0, 0, 0, 2, 2}; case "RU": - return new int[] {1, 0, 0, 0, 4, 3}; + return new int[] {1, 0, 0, 0, 3, 3}; case "RW": - return new int[] {3, 4, 2, 0, 2, 2}; + return new int[] {3, 3, 1, 0, 2, 2}; + case "MU": case "SA": - return new int[] {3, 1, 1, 1, 2, 2}; + return new int[] {3, 1, 1, 2, 2, 2}; + case "CF": case "SB": - return new int[] {4, 2, 4, 3, 2, 2}; + return new int[] {4, 2, 4, 2, 2, 2}; + case "SC": + return new int[] {4, 3, 1, 1, 2, 2}; + case "SD": + return new int[] {4, 3, 4, 2, 2, 2}; + case "SE": + return new int[] {0, 1, 1, 1, 0, 2}; case "SG": - return new int[] {1, 1, 2, 2, 2, 1}; + return new int[] {2, 3, 3, 3, 3, 3}; case "AQ": case "ER": case "SH": return new int[] {4, 2, 2, 2, 2, 2}; - case "GR": - case "HR": - case "SI": - return new int[] {1, 0, 0, 0, 1, 2}; case "BG": - case "MT": - case "SK": + case "ES": + case "GR": + case "SI": return new int[] {0, 0, 0, 0, 1, 2}; - case "AX": - case "LI": - case "MS": - case "PM": - case "SM": - return new int[] {0, 2, 2, 2, 2, 2}; + case "IQ": + case "SJ": + return new int[] {3, 2, 2, 2, 2, 2}; + case "SK": + return new int[] {1, 1, 1, 1, 3, 2}; + case "GF": + case "PK": + case "SL": + return new int[] {3, 2, 3, 3, 2, 2}; + case "ET": case "SN": - return new int[] {4, 4, 4, 3, 2, 2}; + return new int[] {4, 4, 3, 2, 2, 2}; + case "SO": + return new int[] {3, 2, 2, 4, 4, 2}; case "SR": return new int[] {2, 4, 3, 0, 2, 2}; - case "SS": - return new int[] {4, 3, 2, 3, 2, 2}; case "ST": return new int[] {2, 2, 1, 2, 2, 2}; - case "NI": - case "PA": + case "PF": case "SV": - return new int[] {2, 3, 3, 3, 2, 2}; + return new int[] {2, 3, 3, 1, 2, 2}; case "SZ": - return new int[] {3, 3, 3, 4, 2, 2}; - case "SX": + return new int[] {4, 4, 3, 4, 2, 2}; case "TC": - return new int[] {1, 2, 1, 0, 2, 2}; + return new int[] {2, 2, 1, 3, 2, 2}; case "GA": case "TG": return new int[] {3, 4, 1, 0, 2, 2}; case "TH": - return new int[] {0, 2, 2, 3, 3, 4}; - case "TK": - return new int[] {2, 2, 2, 4, 2, 2}; - case "CU": + return new int[] {0, 1, 2, 1, 2, 2}; case "DJ": case "SY": case "TJ": - case "TL": return new int[] {4, 3, 4, 4, 2, 2}; - case "SC": + case "GL": + case "TK": + return new int[] {2, 2, 2, 4, 2, 2}; + case "TL": + return new int[] {4, 2, 4, 4, 2, 2}; + case "SS": case "TM": - return new int[] {4, 2, 1, 1, 2, 2}; - case "AZ": - case "GF": - case "LY": - case "PK": - case "SO": - case "TO": - return new int[] {3, 2, 3, 3, 2, 2}; + return new int[] {4, 2, 2, 3, 2, 2}; case "TR": - return new int[] {1, 1, 0, 0, 2, 2}; + return new int[] {1, 0, 0, 1, 3, 2}; case "TT": - return new int[] {1, 4, 1, 3, 2, 2}; - case "EE": - case "IS": - case "LV": - case "PT": - case "SE": + return new int[] {1, 4, 0, 0, 2, 2}; case "TW": - return new int[] {0, 0, 0, 0, 0, 2}; + return new int[] {0, 2, 0, 0, 0, 0}; + case "ML": case "TZ": - return new int[] {3, 4, 3, 2, 2, 2}; - case "IM": + return new int[] {3, 4, 2, 2, 2, 2}; case "UA": - return new int[] {0, 2, 1, 1, 2, 2}; - case "SL": + return new int[] {0, 1, 1, 2, 4, 2}; + case "LS": case "UG": - return new int[] {3, 3, 4, 3, 2, 2}; + return new int[] {3, 3, 3, 2, 2, 2}; case "US": - return new int[] {1, 0, 2, 2, 3, 1}; - case "AR": - case "KG": + return new int[] {1, 1, 4, 1, 3, 1}; case "TN": case "UY": return new int[] {2, 1, 1, 1, 2, 2}; case "UZ": - return new int[] {2, 2, 3, 4, 2, 2}; - case "BL": + return new int[] {2, 2, 3, 4, 3, 2}; + case "AX": case "CX": + case "LI": + case "MP": + case "MS": + case "PM": + case "SM": case "VA": - return new int[] {1, 2, 2, 2, 2, 2}; - case "AD": - case "BM": - case "BQ": + return new int[] {0, 2, 2, 2, 2, 2}; case "GD": - case "GL": case "KN": case "KY": case "LC": + case "SX": case "VC": return new int[] {1, 2, 0, 0, 2, 2}; case "VG": - return new int[] {2, 2, 1, 1, 2, 2}; - case "GG": + return new int[] {2, 2, 0, 1, 2, 2}; case "VI": - return new int[] {0, 2, 0, 1, 2, 2}; + return new int[] {0, 2, 1, 2, 2, 2}; case "VN": - return new int[] {0, 3, 3, 4, 2, 2}; - case "GH": - case "NA": + return new int[] {0, 0, 1, 2, 2, 1}; case "VU": - return new int[] {3, 3, 3, 2, 2, 2}; + return new int[] {4, 3, 3, 1, 2, 2}; case "IO": - case "MH": case "TV": case "WF": return new int[] {4, 2, 2, 4, 2, 2}; + case "BT": + case "MZ": case "WS": - return new int[] {3, 1, 3, 1, 2, 2}; - case "AL": + return new int[] {3, 1, 2, 1, 2, 2}; case "XK": - return new int[] {1, 1, 1, 1, 2, 2}; + return new int[] {1, 2, 1, 1, 2, 2}; case "BI": case "HT": - case "KM": case "MG": case "NE": - case "SD": case "TD": case "VE": case "YE": return new int[] {4, 4, 4, 4, 2, 2}; - case "JE": case "YT": - return new int[] {4, 2, 2, 3, 2, 2}; + return new int[] {2, 3, 3, 4, 2, 2}; case "ZA": - return new int[] {3, 2, 2, 1, 1, 2}; + return new int[] {2, 3, 2, 1, 2, 2}; case "ZM": - return new int[] {3, 3, 4, 2, 2, 2}; - case "MR": + return new int[] {4, 4, 4, 3, 3, 2}; + case "LY": + case "TO": case "ZW": - return new int[] {4, 2, 4, 4, 2, 2}; + return new int[] {3, 2, 4, 3, 2, 2}; default: return new int[] {2, 2, 2, 2, 2, 2}; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java index 437062c570..1bd45fc24a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java @@ -2050,7 +2050,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } private void handleFrameRendered(long presentationTimeUs) { - if (this != tunnelingOnFrameRenderedListener) { + if (this != tunnelingOnFrameRenderedListener || getCodec() == null) { // Stale event. return; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 31a0726ef7..fb2ae47b59 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -95,7 +95,6 @@ import androidx.media3.common.AdPlaybackState; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.Format; -import androidx.media3.common.IllegalSeekPositionException; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Metadata; @@ -112,6 +111,7 @@ import androidx.media3.common.TrackGroup; import androidx.media3.common.Tracks; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Clock; +import androidx.media3.common.util.SystemClock; import androidx.media3.common.util.Util; import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.analytics.AnalyticsListener; @@ -930,31 +930,100 @@ public final class ExoPlayerTest { } @Test - public void illegalSeekPositionDoesThrow() throws Exception { - final IllegalSeekPositionException[] exception = new IllegalSeekPositionException[1]; - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .waitForPlaybackState(Player.STATE_BUFFERING) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(ExoPlayer player) { - try { - player.seekTo(/* mediaItemIndex= */ 100, /* positionMs= */ 0); - } catch (IllegalSeekPositionException e) { - exception[0] = e; - } - } - }) - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - new ExoPlayerTestRunner.Builder(context) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - assertThat(exception[0]).isNotNull(); + public void seekTo_indexLargerThanPlaylist_isIgnored() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.setMediaItem(MediaItem.fromUri("http://test")); + + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 1000); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + player.release(); + } + + @Test + public void addMediaItems_indexLargerThanPlaylist_addsToEndOfPlaylist() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.setMediaItem(MediaItem.fromUri("http://test")); + ImmutableList addedItems = + ImmutableList.of(MediaItem.fromUri("http://new1"), MediaItem.fromUri("http://new2")); + + player.addMediaItems(/* index= */ 5000, addedItems); + + assertThat(player.getMediaItemCount()).isEqualTo(3); + assertThat(player.getMediaItemAt(1)).isEqualTo(addedItems.get(0)); + assertThat(player.getMediaItemAt(2)).isEqualTo(addedItems.get(1)); + player.release(); + } + + @Test + public void removeMediaItems_fromIndexLargerThanPlaylist_isIgnored() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.setMediaItems( + ImmutableList.of(MediaItem.fromUri("http://item1"), MediaItem.fromUri("http://item2"))); + + player.removeMediaItems(/* fromIndex= */ 5000, /* toIndex= */ 6000); + + assertThat(player.getMediaItemCount()).isEqualTo(2); + player.release(); + } + + @Test + public void removeMediaItems_toIndexLargerThanPlaylist_removesUpToEndOfPlaylist() + throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.setMediaItems( + ImmutableList.of(MediaItem.fromUri("http://item1"), MediaItem.fromUri("http://item2"))); + + player.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 6000); + + assertThat(player.getMediaItemCount()).isEqualTo(1); + assertThat(player.getMediaItemAt(0).localConfiguration.uri.toString()) + .isEqualTo("http://item1"); + player.release(); + } + + @Test + public void moveMediaItems_fromIndexLargerThanPlaylist_isIgnored() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + ImmutableList items = + ImmutableList.of(MediaItem.fromUri("http://item1"), MediaItem.fromUri("http://item2")); + player.setMediaItems(items); + + player.moveMediaItems(/* fromIndex= */ 5000, /* toIndex= */ 6000, /* newIndex= */ 0); + + assertThat(player.getMediaItemAt(0)).isEqualTo(items.get(0)); + assertThat(player.getMediaItemAt(1)).isEqualTo(items.get(1)); + player.release(); + } + + @Test + public void moveMediaItems_toIndexLargerThanPlaylist_movesItemsUpToEndOfPlaylist() + throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + ImmutableList items = + ImmutableList.of(MediaItem.fromUri("http://item1"), MediaItem.fromUri("http://item2")); + player.setMediaItems(items); + + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 6000, /* newIndex= */ 0); + + assertThat(player.getMediaItemAt(0)).isEqualTo(items.get(1)); + assertThat(player.getMediaItemAt(1)).isEqualTo(items.get(0)); + player.release(); + } + + @Test + public void moveMediaItems_newIndexLargerThanPlaylist_movesItemsUpToEndOfPlaylist() + throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + ImmutableList items = + ImmutableList.of(MediaItem.fromUri("http://item1"), MediaItem.fromUri("http://item2")); + player.setMediaItems(items); + + player.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 1, /* newIndex= */ 5000); + + assertThat(player.getMediaItemAt(0)).isEqualTo(items.get(1)); + assertThat(player.getMediaItemAt(1)).isEqualTo(items.get(0)); + player.release(); } @Test @@ -2523,7 +2592,7 @@ public final class ExoPlayerTest { .build() .start() .blockUntilEnded(TIMEOUT_MS); - assertThat(target.positionMs).isEqualTo(C.POSITION_UNSET); + assertThat(target.positionMs).isEqualTo(C.TIME_UNSET); } @Test @@ -2545,7 +2614,7 @@ public final class ExoPlayerTest { .build() .start() .blockUntilEnded(TIMEOUT_MS); - assertThat(target.positionMs).isEqualTo(C.POSITION_UNSET); + assertThat(target.positionMs).isEqualTo(C.TIME_UNSET); } @Test @@ -10407,7 +10476,9 @@ public final class ExoPlayerTest { new Metadata( new BinaryFrame(/* id= */ "", /* data= */ new byte[0]), new TextInformationFrame( - /* id= */ "TT2", /* description= */ null, /* value= */ "title"))) + /* id= */ "TT2", + /* description= */ null, + /* values= */ ImmutableList.of("title")))) .build(); // Set multiple values together. @@ -11897,7 +11968,11 @@ public final class ExoPlayerTest { new TestExoPlayerBuilder(context) .setRenderersFactory( (handler, videoListener, audioListener, textOutput, metadataOutput) -> { - videoRenderer.set(new FakeVideoRenderer(handler, videoListener)); + videoRenderer.set( + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler( + handler.getLooper(), /* callback= */ null), + videoListener)); return new Renderer[] {videoRenderer.get()}; }) .build(); @@ -11999,10 +12074,20 @@ public final class ExoPlayerTest { @Test @Config(sdk = Config.ALL_SDKS) - public void builder_inBackgroundThread_doesNotThrow() throws Exception { + public void builder_inBackgroundThreadWithAllowedAnyThreadMethods_doesNotThrow() + throws Exception { Thread builderThread = new Thread( - () -> new ExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build()); + () -> { + ExoPlayer player = + new ExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build(); + player.addListener(new Listener() {}); + player.addAnalyticsListener(new AnalyticsListener() {}); + player.addAudioOffloadListener(new ExoPlayer.AudioOffloadListener() {}); + player.getClock(); + player.getApplicationLooper(); + player.getPlaybackLooper(); + }); AtomicReference builderThrow = new AtomicReference<>(); builderThread.setUncaughtExceptionHandler((thread, throwable) -> builderThrow.set(throwable)); @@ -12034,7 +12119,12 @@ public final class ExoPlayerTest { new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()) .setRenderersFactory( (handler, videoListener, audioListener, textOutput, metadataOutput) -> - new Renderer[] {new FakeVideoRenderer(handler, videoListener)}) + new Renderer[] { + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler( + handler.getLooper(), /* callback= */ null), + videoListener) + }) .build(); AnalyticsListener listener = mock(AnalyticsListener.class); player.addAnalyticsListener(listener); @@ -12059,7 +12149,12 @@ public final class ExoPlayerTest { new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()) .setRenderersFactory( (handler, videoListener, audioListener, textOutput, metadataOutput) -> - new Renderer[] {new FakeVideoRenderer(handler, videoListener)}) + new Renderer[] { + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler( + handler.getLooper(), /* callback= */ null), + videoListener) + }) .build(); Player.Listener listener = mock(Player.Listener.class); player.addListener(listener); @@ -12275,7 +12370,7 @@ public final class ExoPlayerTest { public PositionGrabbingMessageTarget() { mediaItemIndex = C.INDEX_UNSET; - positionMs = C.POSITION_UNSET; + positionMs = C.TIME_UNSET; } @Override diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java index ca3bd02eeb..2ab4681030 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -25,7 +25,6 @@ import static org.mockito.Mockito.mock; import static org.robolectric.Shadows.shadowOf; import android.net.Uri; -import android.os.Handler; import android.os.Looper; import android.util.Pair; import androidx.media3.common.AdPlaybackState; @@ -36,6 +35,7 @@ import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.Tracks; import androidx.media3.common.util.Clock; +import androidx.media3.common.util.HandlerWrapper; import androidx.media3.exoplayer.analytics.AnalyticsCollector; import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector; import androidx.media3.exoplayer.analytics.PlayerId; @@ -97,13 +97,14 @@ public final class MediaPeriodQueueTest { analyticsCollector.setPlayer( new ExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build(), Looper.getMainLooper()); - mediaPeriodQueue = - new MediaPeriodQueue(analyticsCollector, new Handler(Looper.getMainLooper())); + HandlerWrapper handler = + Clock.DEFAULT.createHandler(Looper.getMainLooper(), /* callback= */ null); + mediaPeriodQueue = new MediaPeriodQueue(analyticsCollector, handler); mediaSourceList = new MediaSourceList( mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), analyticsCollector, - new Handler(Looper.getMainLooper()), + handler, PlayerId.UNSET); rendererCapabilities = new RendererCapabilities[0]; trackSelector = mock(TrackSelector.class); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java index edb874091c..ea3c6ce33d 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java @@ -67,7 +67,7 @@ public class MediaSourceListTest { new MediaSourceList( mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), analyticsCollector, - Util.createHandlerForCurrentOrMainLooper(), + Clock.DEFAULT.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null), PlayerId.UNSET); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java index c25ddebdcf..ceefe172c7 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java @@ -49,6 +49,12 @@ import static androidx.media3.exoplayer.analytics.AnalyticsListener.EVENT_VIDEO_ import static androidx.media3.exoplayer.analytics.AnalyticsListener.EVENT_VIDEO_SIZE_CHANGED; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.playUntilPosition; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilError; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilIsLoading; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlaybackState; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilTimelineChanged; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -63,6 +69,8 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.robolectric.shadows.ShadowLooper.idleMainLooper; +import static org.robolectric.shadows.ShadowLooper.runMainLooperToNextTask; import android.graphics.SurfaceTexture; import android.os.Looper; @@ -85,6 +93,7 @@ import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.util.Clock; import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.DecoderCounters; import androidx.media3.exoplayer.ExoPlaybackException; @@ -102,8 +111,6 @@ import androidx.media3.exoplayer.source.LoadEventInfo; import androidx.media3.exoplayer.source.MediaLoadData; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.test.utils.ActionSchedule; -import androidx.media3.test.utils.ActionSchedule.PlayerRunnable; import androidx.media3.test.utils.ExoPlayerTestRunner; import androidx.media3.test.utils.FakeAudioRenderer; import androidx.media3.test.utils.FakeClock; @@ -132,14 +139,11 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; -import org.robolectric.shadows.ShadowLooper; /** Integration test for {@link DefaultAnalyticsCollector}. */ @RunWith(AndroidJUnit4.class) public final class DefaultAnalyticsCollectorTest { - private static final String TAG = "DefaultAnalyticsCollectorTest"; - // Deprecated event constants. private static final long EVENT_PLAYER_STATE_CHANGED = 1L << 63; private static final long EVENT_SEEK_STARTED = 1L << 62; @@ -167,7 +171,6 @@ public final class DefaultAnalyticsCollectorTest { private static final Format VIDEO_FORMAT_DRM_1 = ExoPlayerTestRunner.VIDEO_FORMAT.buildUpon().setDrmInitData(DRM_DATA_1).build(); - private static final int TIMEOUT_MS = 10_000; private static final Timeline SINGLE_PERIOD_TIMELINE = new FakeTimeline(); private static final EventWindowAndPeriodId WINDOW_0 = new EventWindowAndPeriodId(/* windowIndex= */ 0, /* mediaPeriodId= */ null); @@ -217,7 +220,14 @@ public final class DefaultAnalyticsCollectorTest { FakeMediaSource mediaSource = new FakeMediaSource( Timeline.EMPTY, ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( @@ -236,7 +246,14 @@ public final class DefaultAnalyticsCollectorTest { SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) @@ -247,7 +264,7 @@ public final class DefaultAnalyticsCollectorTest { period0 /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */) .inOrder(); assertThat(listener.getEvents(EVENT_IS_LOADING_CHANGED)) .containsExactly(period0 /* started */, period0 /* stopped */) @@ -297,7 +314,14 @@ public final class DefaultAnalyticsCollectorTest { SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT)); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) @@ -378,7 +402,14 @@ public final class DefaultAnalyticsCollectorTest { new ConcatenatingMediaSource( new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT), new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.AUDIO_FORMAT)); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) @@ -449,23 +480,23 @@ public final class DefaultAnalyticsCollectorTest { ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT), new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.AUDIO_FORMAT)); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - // Wait until second period has fully loaded to assert loading events without flakiness. - .waitForIsLoading(true) - .waitForIsLoading(false) - .seek(/* mediaItemIndex= */ 1, /* positionMs= */ 0) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(mediaSource); + player.prepare(); + // Wait until second period has fully loaded to assert loading events. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + player.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 0); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, WINDOW_0 /* BUFFERING */, - WINDOW_0 /* setPlayWhenReady=false */, period0 /* READY */, period1 /* BUFFERING */, period1 /* setPlayWhenReady=true */, @@ -542,23 +573,24 @@ public final class DefaultAnalyticsCollectorTest { SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT)); - long periodDurationMs = + long windowDurationMs = SINGLE_PERIOD_TIMELINE.getWindow(/* windowIndex= */ 0, new Window()).getDurationMs(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* mediaItemIndex= */ 0, periodDurationMs) - .seekAndWait(/* positionMs= */ 0) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + playUntilPosition(player, /* mediaItemIndex= */ 0, windowDurationMs - 100); + player.seekTo(/* positionMs= */ 0); + runUntilPlaybackState(player, Player.STATE_READY); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, period0 /* READY */, period0 /* setPlayWhenReady=true */, @@ -653,17 +685,19 @@ public final class DefaultAnalyticsCollectorTest { new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT); MediaSource mediaSource2 = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .setMediaSources(/* resetPosition= */ false, mediaSource2) - .waitForTimelineChanged() - // Wait until loading started to prevent flakiness caused by loading finishing too fast. - .waitForIsLoading(true) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource1, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(mediaSource1); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.setMediaSource(mediaSource2, /* resetPosition= */ false); + runUntilTimelineChanged(player); + // Wait until loading started to assert loading events. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); // Populate all event ids with last timeline (after second prepare). populateEventIds(listener.lastReportedTimeline); @@ -676,9 +710,7 @@ public final class DefaultAnalyticsCollectorTest { /* windowSequenceNumber= */ 0)); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, WINDOW_0 /* BUFFERING */, - WINDOW_0 /* setPlayWhenReady=false */, period0Seq0 /* READY */, WINDOW_0 /* BUFFERING */, period0Seq1 /* setPlayWhenReady=true */, @@ -688,9 +720,9 @@ public final class DefaultAnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGE */, - period0Seq0 /* SOURCE_UPDATE */, + WINDOW_0 /* SOURCE_UPDATE */, WINDOW_0 /* PLAYLIST_CHANGE */, - period0Seq1 /* SOURCE_UPDATE */); + WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly(WINDOW_0 /* REMOVE */); assertThat(listener.getEvents(EVENT_IS_LOADING_CHANGED)) @@ -753,28 +785,31 @@ public final class DefaultAnalyticsCollectorTest { public void reprepareAfterError() throws Exception { MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .throwPlaybackException( - ExoPlaybackException.createForSource( - new IOException(), PlaybackException.ERROR_CODE_IO_UNSPECIFIED)) - .waitForPlaybackState(Player.STATE_IDLE) - .seek(/* positionMs= */ 0) - .prepare() - // Wait until loading started to assert loading events without flakiness. - .waitForIsLoading(true) - .play() - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player + .createMessage( + (message, payload) -> { + throw ExoPlaybackException.createForSource( + new IOException(), PlaybackException.ERROR_CODE_IO_UNSPECIFIED); + }) + .send(); + runUntilError(player); + player.seekTo(/* positionMs= */ 0); + player.prepare(); + // Wait until loading started to assert loading events. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, period0Seq0 /* READY */, period0Seq0 /* IDLE */, @@ -784,7 +819,7 @@ public final class DefaultAnalyticsCollectorTest { period0Seq0 /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* prepared */, period0Seq0 /* prepared */); + .containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* prepared */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period0Seq0); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0Seq0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0Seq0); @@ -835,36 +870,33 @@ public final class DefaultAnalyticsCollectorTest { new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT); final ConcatenatingMediaSource concatenatedMediaSource = new ConcatenatingMediaSource(childMediaSource, childMediaSource); - long periodDurationMs = + long windowDurationMs = SINGLE_PERIOD_TIMELINE.getWindow(/* windowIndex= */ 0, new Window()).getDurationMs(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - // Ensure second period is already being read from. - .playUntilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ periodDurationMs) - .executeRunnable( - () -> - concatenatedMediaSource.moveMediaSource( - /* currentIndex= */ 0, /* newIndex= */ 1)) - .waitForTimelineChanged() - .waitForPlaybackState(Player.STATE_READY) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(concatenatedMediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(concatenatedMediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + // Ensure second period is already being read from. + playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ windowDurationMs - 100); + concatenatedMediaSource.moveMediaSource(/* currentIndex= */ 0, /* newIndex= */ 1); + runUntilTimelineChanged(player); + runUntilPlaybackState(player, Player.STATE_READY); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, window0Period1Seq0 /* READY */, window0Period1Seq0 /* setPlayWhenReady=true */, window0Period1Seq0 /* setPlayWhenReady=false */, - period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* BUFFERING */, period1Seq0 /* READY */, + period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) @@ -926,20 +958,22 @@ public final class DefaultAnalyticsCollectorTest { public void playlistOperations() throws Exception { MediaSource fakeMediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .addMediaSources(fakeMediaSource) - // Wait until second period has fully loaded to assert loading events without flakiness. - .waitForIsLoading(true) - .waitForIsLoading(false) - .removeMediaItem(/* index= */ 0) - .waitForPlaybackState(Player.STATE_BUFFERING) - .waitForPlaybackState(Player.STATE_READY) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(fakeMediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(fakeMediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.addMediaSource(fakeMediaSource); + // Wait until second period has fully loaded to assert loading events. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + player.removeMediaItem(/* index= */ 0); + runUntilPlaybackState(player, Player.STATE_BUFFERING); + runUntilPlaybackState(player, Player.STATE_READY); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); // Populate event ids with second to last timeline that still contained both periods. populateEventIds(listener.reportedTimelines.get(listener.reportedTimelines.size() - 2)); @@ -953,8 +987,6 @@ public final class DefaultAnalyticsCollectorTest { /* windowSequenceNumber= */ 1)); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, period0Seq0 /* READY */, period0Seq1 /* BUFFERING */, @@ -965,7 +997,7 @@ public final class DefaultAnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, - period0Seq0 /* SOURCE_UPDATE (first item) */, + WINDOW_0 /* SOURCE_UPDATE (first item) */, period0Seq0 /* PLAYLIST_CHANGED (add) */, period0Seq0 /* SOURCE_UPDATE (second item) */, period0Seq1 /* PLAYLIST_CHANGED (remove) */) @@ -1063,60 +1095,53 @@ public final class DefaultAnalyticsCollectorTest { } }, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(ExoPlayer player) { - player.addListener( - new Player.Listener() { - @Override - public void onPositionDiscontinuity( - Player.PositionInfo oldPosition, - Player.PositionInfo newPosition, - @Player.DiscontinuityReason int reason) { - if (!player.isPlayingAd() - && reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - // Finished playing ad. Marked as played. - adPlaybackState.set( - adPlaybackState - .get() - .withPlayedAd( - /* adGroupIndex= */ playedAdCount.getAndIncrement(), - /* adIndexInAdGroup= */ 0)); - fakeMediaSource.setNewSourceInfo( - new FakeTimeline( - new TimelineWindowDefinition( - /* periodCount= */ 1, - /* id= */ 0, - /* isSeekable= */ true, - /* isDynamic= */ false, - contentDurationsUs, - adPlaybackState.get())), - /* sendManifestLoadEvents= */ false); - } - } - }); - } - }) - .pause() - // Ensure everything is preloaded. - .waitForIsLoading(true) - .waitForIsLoading(false) - .waitForPlaybackState(Player.STATE_READY) - // Wait in each content part to ensure previously triggered events get a chance to be - // delivered. This prevents flakiness caused by playback progressing too fast. - .playUntilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 3_000) - .waitForPendingPlayerCommands() - .playUntilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 8_000) - .waitForPendingPlayerCommands() - .play() - .waitForPlaybackState(Player.STATE_ENDED) - // Wait for final timeline change that marks post-roll played. - .waitForTimelineChanged() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(fakeMediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + player.addListener( + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (!player.isPlayingAd() && reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { + // Finished playing ad. Marked as played. + adPlaybackState.set( + adPlaybackState + .get() + .withPlayedAd( + /* adGroupIndex= */ playedAdCount.getAndIncrement(), + /* adIndexInAdGroup= */ 0)); + fakeMediaSource.setNewSourceInfo( + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + contentDurationsUs, + adPlaybackState.get())), + /* sendManifestLoadEvents= */ false); + } + } + }); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(fakeMediaSource); + player.prepare(); + // Ensure everything is preloaded. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + runUntilPlaybackState(player, Player.STATE_READY); + // Wait in each content part to ensure previously triggered events get a chance to be delivered. + playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 3_000); + runUntilPendingCommandsAreFullyHandled(player); + playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 8_000); + runUntilPendingCommandsAreFullyHandled(player); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + // Wait for final timeline change that marks post-roll played. + runUntilTimelineChanged(player); Object periodUid = listener.lastReportedTimeline.getUidOfPeriod(/* periodIndex= */ 0); EventWindowAndPeriodId prerollAd = @@ -1158,8 +1183,6 @@ public final class DefaultAnalyticsCollectorTest { periodUid, /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ C.INDEX_UNSET)); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, prerollAd /* READY */, prerollAd /* setPlayWhenReady=true */, @@ -1172,7 +1195,7 @@ public final class DefaultAnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, - prerollAd /* SOURCE_UPDATE (initial) */, + WINDOW_0 /* SOURCE_UPDATE (initial) */, contentAfterPreroll /* SOURCE_UPDATE (played preroll) */, contentAfterMidroll /* SOURCE_UPDATE (played midroll) */, contentAfterPostroll /* SOURCE_UPDATE (played postroll) */) @@ -1322,20 +1345,21 @@ public final class DefaultAnalyticsCollectorTest { } }, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - // Ensure everything is preloaded. - .waitForIsLoading(true) - .waitForIsLoading(false) - // Seek behind the midroll. - .seek(6 * C.MICROS_PER_SECOND) - // Wait until loading started again to assert loading events without flakiness. - .waitForIsLoading(true) - .play() - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - TestAnalyticsListener listener = runAnalyticsTest(fakeMediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(fakeMediaSource); + player.prepare(); + // Ensure everything is preloaded. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + // Seek behind the midroll. + player.seekTo(/* positionMs= */ 6_000); + // Wait until loading started again to assert loading events. + runUntilIsLoading(player, /* expectedIsLoading= */ true); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); Object periodUid = listener.lastReportedTimeline.getUidOfPeriod(/* periodIndex= */ 0); EventWindowAndPeriodId midrollAd = @@ -1357,8 +1381,6 @@ public final class DefaultAnalyticsCollectorTest { periodUid, /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ C.INDEX_UNSET)); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady=true */, - WINDOW_0 /* setPlayWhenReady=false */, WINDOW_0 /* BUFFERING */, contentBeforeMidroll /* READY */, contentAfterMidroll /* BUFFERING */, @@ -1367,7 +1389,7 @@ public final class DefaultAnalyticsCollectorTest { contentAfterMidroll /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, contentBeforeMidroll /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly( contentAfterMidroll /* seek */, @@ -1435,21 +1457,17 @@ public final class DefaultAnalyticsCollectorTest { @Test public void notifyExternalEvents() throws Exception { MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(ExoPlayer player) { - player.getAnalyticsCollector().notifySeekStarted(); - } - }) - .seek(/* positionMs= */ 0) - .play() - .build(); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + player.getAnalyticsCollector().notifySeekStarted(); + player.seekTo(/* positionMs= */ 0); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); @@ -1460,7 +1478,14 @@ public final class DefaultAnalyticsCollectorTest { public void drmEvents_singlePeriod() throws Exception { MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_SESSION_MANAGER_ERROR)).isEmpty(); @@ -1488,18 +1513,21 @@ public final class DefaultAnalyticsCollectorTest { SINGLE_PERIOD_TIMELINE, blockingDrmSessionManager, VIDEO_FORMAT_DRM_1), new FakeMediaSource( SINGLE_PERIOD_TIMELINE, blockingDrmSessionManager, VIDEO_FORMAT_DRM_1)); - TestAnalyticsListener listener = - runAnalyticsTest( - mediaSource, - // Wait for the media to be fully buffered before unblocking the DRM key request. This - // ensures both periods report the same load event (because period1's DRM session is - // already preacquired by the time the key load completes). - new ActionSchedule.Builder(TAG) - .waitForIsLoading(false) - .waitForIsLoading(true) - .waitForIsLoading(false) - .executeRunnable(mediaDrmCallback.keyCondition::open) - .build()); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + // Wait for the media to be fully buffered before unblocking the DRM key request. This + // ensures both periods report the same load event (because period1's DRM session is + // already preacquired by the time the key load completes). + runUntilIsLoading(player, /* expectedIsLoading= */ false); + runUntilIsLoading(player, /* expectedIsLoading= */ true); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + mediaDrmCallback.keyCondition.open(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_SESSION_MANAGER_ERROR)).isEmpty(); @@ -1525,7 +1553,14 @@ public final class DefaultAnalyticsCollectorTest { SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1.buildUpon().setDrmInitData(DRM_DATA_2).build())); - TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_ENDED); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_SESSION_MANAGER_ERROR)).isEmpty(); @@ -1552,13 +1587,16 @@ public final class DefaultAnalyticsCollectorTest { .build(mediaDrmCallback); MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, failingDrmSessionManager, VIDEO_FORMAT_DRM_1); - TestAnalyticsListener listener = - runAnalyticsTest( - mediaSource, - new ActionSchedule.Builder(TAG) - .waitForIsLoading(false) - .executeRunnable(mediaDrmCallback.keyCondition::open) - .build()); + ExoPlayer player = setupPlayer(); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); + + player.play(); + player.setMediaSource(mediaSource); + player.prepare(); + runUntilIsLoading(player, /* expectedIsLoading= */ false); + mediaDrmCallback.keyCondition.open(); + runUntilError(player); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_DRM_SESSION_MANAGER_ERROR)).containsExactly(period0); @@ -1588,12 +1626,14 @@ public final class DefaultAnalyticsCollectorTest { } } }; + ExoPlayer player = setupPlayer(renderersFactory); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); - TestAnalyticsListener listener = - runAnalyticsTest( - new ConcatenatingMediaSource(source0, source1), - /* actionSchedule= */ null, - renderersFactory); + player.play(); + player.setMediaSource(new ConcatenatingMediaSource(source0, source1)); + player.prepare(); + runUntilError(player); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); @@ -1622,12 +1662,14 @@ public final class DefaultAnalyticsCollectorTest { } } }; + ExoPlayer player = setupPlayer(renderersFactory); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); - TestAnalyticsListener listener = - runAnalyticsTest( - new ConcatenatingMediaSource(source0, source1), - /* actionSchedule= */ null, - renderersFactory); + player.play(); + player.setMediaSource(new ConcatenatingMediaSource(source0, source1)); + player.prepare(); + runUntilError(player); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); @@ -1660,12 +1702,14 @@ public final class DefaultAnalyticsCollectorTest { } } }; + ExoPlayer player = setupPlayer(renderersFactory); + TestAnalyticsListener listener = new TestAnalyticsListener(); + player.addAnalyticsListener(listener); - TestAnalyticsListener listener = - runAnalyticsTest( - new ConcatenatingMediaSource(source, source), - /* actionSchedule= */ null, - renderersFactory); + player.play(); + player.setMediaSource(new ConcatenatingMediaSource(source, source)); + player.prepare(); + runUntilError(player); populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); @@ -1673,11 +1717,7 @@ public final class DefaultAnalyticsCollectorTest { @Test public void onEvents_isReportedWithCorrectEventTimes() throws Exception { - ExoPlayer player = - new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); - Surface surface = new Surface(new SurfaceTexture(/* texName= */ 0)); - player.setVideoSurface(surface); - + ExoPlayer player = setupPlayer(); AnalyticsListener listener = mock(AnalyticsListener.class); Format[] formats = new Format[] { @@ -1690,20 +1730,18 @@ public final class DefaultAnalyticsCollectorTest { player.setMediaSource(new FakeMediaSource(new FakeTimeline(), formats)); player.seekTo(2_000); player.setPlaybackParameters(new PlaybackParameters(/* speed= */ 2.0f)); - ShadowLooper.runMainLooperToNextTask(); - + runMainLooperToNextTask(); // Move to another item and fail with a third one to trigger events with different EventTimes. player.prepare(); - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY); + runUntilPlaybackState(player, Player.STATE_READY); player.addMediaSource(new FakeMediaSource(new FakeTimeline(), formats)); player.play(); TestPlayerRunHelper.runUntilPositionDiscontinuity( player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); player.setMediaItem(MediaItem.fromUri("http://this-will-throw-an-exception.mp4")); - TestPlayerRunHelper.runUntilError(player); - ShadowLooper.runMainLooperToNextTask(); + runUntilError(player); + runMainLooperToNextTask(); player.release(); - surface.release(); // Verify that expected individual callbacks have been called and capture EventTimes. ArgumentCaptor individualTimelineChangedEventTimes = @@ -1928,48 +1966,6 @@ public final class DefaultAnalyticsCollectorTest { .inOrder(); } - private void populateEventIds(Timeline timeline) { - period0 = - new EventWindowAndPeriodId( - /* windowIndex= */ 0, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); - period0Seq0 = period0; - period0Seq1 = - new EventWindowAndPeriodId( - /* windowIndex= */ 0, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1)); - window1Period0Seq1 = - new EventWindowAndPeriodId( - /* windowIndex= */ 1, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1)); - if (timeline.getPeriodCount() > 1) { - period1 = - new EventWindowAndPeriodId( - /* windowIndex= */ 1, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); - period1Seq1 = period1; - period1Seq0 = - new EventWindowAndPeriodId( - /* windowIndex= */ 1, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); - period1Seq2 = - new EventWindowAndPeriodId( - /* windowIndex= */ 1, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 2)); - window0Period1Seq0 = - new EventWindowAndPeriodId( - /* windowIndex= */ 0, - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); - } - } - @Test public void recursiveListenerInvocation_arrivesInCorrectOrder() { AnalyticsCollector analyticsCollector = new DefaultAnalyticsCollector(Clock.DEFAULT); @@ -2027,13 +2023,12 @@ public final class DefaultAnalyticsCollectorTest { exoPlayer.setMediaSource( new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT)); exoPlayer.prepare(); - TestPlayerRunHelper.runUntilPlaybackState(exoPlayer, Player.STATE_READY); - + runUntilPlaybackState(exoPlayer, Player.STATE_READY); // Release and add delay on releasing thread to verify timestamps of events. exoPlayer.release(); long releaseTimeMs = fakeClock.currentTimeMillis(); fakeClock.advanceTime(1); - ShadowLooper.idleMainLooper(); + idleMainLooper(); // Verify video disable events and release events arrived in order. ArgumentCaptor videoDisabledEventTime = @@ -2059,49 +2054,79 @@ public final class DefaultAnalyticsCollectorTest { assertThat(releasedEventTime.getValue().realtimeMs).isGreaterThan(videoDisableTimeMs); } - private static TestAnalyticsListener runAnalyticsTest(MediaSource mediaSource) throws Exception { - return runAnalyticsTest(mediaSource, /* actionSchedule= */ null); + private void populateEventIds(Timeline timeline) { + period0 = + new EventWindowAndPeriodId( + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + period0Seq0 = period0; + period0Seq1 = + new EventWindowAndPeriodId( + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1)); + window1Period0Seq1 = + new EventWindowAndPeriodId( + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1)); + if (timeline.getPeriodCount() > 1) { + period1 = + new EventWindowAndPeriodId( + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); + period1Seq1 = period1; + period1Seq0 = + new EventWindowAndPeriodId( + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); + period1Seq2 = + new EventWindowAndPeriodId( + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 2)); + window0Period1Seq0 = + new EventWindowAndPeriodId( + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); + } } - private static TestAnalyticsListener runAnalyticsTest( - MediaSource mediaSource, @Nullable ActionSchedule actionSchedule) throws Exception { - RenderersFactory renderersFactory = - (eventHandler, + private static ExoPlayer setupPlayer() { + Clock clock = new FakeClock(/* isAutoAdvancing= */ true); + return setupPlayer( + /* renderersFactory= */ (eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, - metadataRendererOutput) -> - new Renderer[] { - new FakeVideoRenderer(eventHandler, videoRendererEventListener), - new FakeAudioRenderer(eventHandler, audioRendererEventListener) - }; - return runAnalyticsTest(mediaSource, actionSchedule, renderersFactory); + metadataRendererOutput) -> { + HandlerWrapper clockAwareHandler = + clock.createHandler(eventHandler.getLooper(), /* callback= */ null); + return new Renderer[] { + new FakeVideoRenderer(clockAwareHandler, videoRendererEventListener), + new FakeAudioRenderer(clockAwareHandler, audioRendererEventListener) + }; + }, + clock); } - private static TestAnalyticsListener runAnalyticsTest( - MediaSource mediaSource, - @Nullable ActionSchedule actionSchedule, - RenderersFactory renderersFactory) - throws Exception { + private static ExoPlayer setupPlayer(RenderersFactory renderersFactory) { + return setupPlayer(renderersFactory, new FakeClock(/* isAutoAdvancing= */ true)); + } + + private static ExoPlayer setupPlayer(RenderersFactory renderersFactory, Clock clock) { Surface surface = new Surface(new SurfaceTexture(/* texName= */ 0)); - TestAnalyticsListener listener = new TestAnalyticsListener(); - try { - new ExoPlayerTestRunner.Builder(ApplicationProvider.getApplicationContext()) - .setMediaSources(mediaSource) - .setRenderersFactory(renderersFactory) - .setVideoSurface(surface) - .setAnalyticsListener(listener) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - } catch (ExoPlaybackException e) { - // Ignore ExoPlaybackException as these may be expected. - } finally { - surface.release(); - } - return listener; + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()) + .setClock(clock) + .setRenderersFactory(renderersFactory) + .build(); + player.setVideoSurface(surface); + return player; } private static final class EventWindowAndPeriodId { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderAC3Test.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderAC3Test.java index 7f6f41314f..fae6430f8b 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderAC3Test.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderAC3Test.java @@ -21,6 +21,7 @@ import static androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvide import static com.google.common.truth.Truth.assertThat; import androidx.media3.common.C; +import androidx.media3.common.Format; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,7 +35,7 @@ public class DefaultAudioTrackBufferSizeProviderAC3Test { @Test public void - getBufferSizeInBytes_passthroughAC3_isPassthroughBufferSizeTimesMultiplicationFactor() { + getBufferSizeInBytes_passthroughAc3AndNoBitrate_assumesMaxByteRateTimesMultiplicationFactor() { int bufferSize = DEFAULT.getBufferSizeInBytes( /* minBufferSizeInBytes= */ 0, @@ -42,6 +43,7 @@ public class DefaultAudioTrackBufferSizeProviderAC3Test { /* outputMode= */ OUTPUT_MODE_PASSTHROUGH, /* pcmFrameSize= */ 1, /* sampleRate= */ 0, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -50,6 +52,23 @@ public class DefaultAudioTrackBufferSizeProviderAC3Test { * DEFAULT.ac3BufferMultiplicationFactor); } + @Test + public void + getBufferSizeInBytes_passthroughAC3At256Kbits_isPassthroughBufferSizeTimesMultiplicationFactor() { + int bufferSize = + DEFAULT.getBufferSizeInBytes( + /* minBufferSizeInBytes= */ 0, + /* encoding= */ C.ENCODING_AC3, + /* outputMode= */ OUTPUT_MODE_PASSTHROUGH, + /* pcmFrameSize= */ 1, + /* sampleRate= */ 0, + /* bitrate= */ 256_000, + /* maxAudioTrackPlaybackSpeed= */ 1); + + // Default buffer duration 0.25s => 0.25 * 256000 / 8 = 8000 + assertThat(bufferSize).isEqualTo(8000 * DEFAULT.ac3BufferMultiplicationFactor); + } + private static int durationUsToAc3MaxBytes(long durationUs) { return (int) (durationUs * getMaximumEncodedRateBytesPerSecond(C.ENCODING_AC3) / MICROS_PER_SECOND); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderEncodedTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderEncodedTest.java index 638dbf5661..0d2723bb54 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderEncodedTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderEncodedTest.java @@ -15,10 +15,13 @@ */ package androidx.media3.exoplayer.audio; +import static androidx.media3.common.C.MICROS_PER_SECOND; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; +import static androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond; import static com.google.common.truth.Truth.assertThat; import androidx.media3.common.C; +import androidx.media3.common.Format; import com.google.common.collect.ImmutableList; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,6 +46,8 @@ public class DefaultAudioTrackBufferSizeProviderEncodedTest { C.ENCODING_MP3, C.ENCODING_AAC_LC, C.ENCODING_AAC_HE_V1, + C.ENCODING_E_AC3, + C.ENCODING_E_AC3_JOC, C.ENCODING_AC4, C.ENCODING_DTS, C.ENCODING_DOLBY_TRUEHD); @@ -57,8 +62,46 @@ public class DefaultAudioTrackBufferSizeProviderEncodedTest { /* outputMode= */ OUTPUT_MODE_PASSTHROUGH, /* pcmFrameSize= */ 1, /* sampleRate= */ 0, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 0); assertThat(bufferSize).isEqualTo(123456789); } + + @Test + public void + getBufferSizeInBytes_passThroughAndBitrateNotSet_returnsBufferSizeWithAssumedBitrate() { + int bufferSize = + DEFAULT.getBufferSizeInBytes( + /* minBufferSizeInBytes= */ 0, + /* encoding= */ encoding, + /* outputMode= */ OUTPUT_MODE_PASSTHROUGH, + /* pcmFrameSize= */ 1, + /* sampleRate= */ 0, + /* bitrate= */ Format.NO_VALUE, + /* maxAudioTrackPlaybackSpeed= */ 1); + + assertThat(bufferSize) + .isEqualTo(durationUsToMaxBytes(encoding, DEFAULT.passthroughBufferDurationUs)); + } + + @Test + public void getBufferSizeInBytes_passthroughAndBitrateDefined() { + int bufferSize = + DEFAULT.getBufferSizeInBytes( + /* minBufferSizeInBytes= */ 0, + /* encoding= */ encoding, + /* outputMode= */ OUTPUT_MODE_PASSTHROUGH, + /* pcmFrameSize= */ 1, + /* sampleRate= */ 0, + /* bitrate= */ 256_000, + /* maxAudioTrackPlaybackSpeed= */ 1); + + // Default buffer duration is 250ms => 0.25 * 256000 / 8 = 8000 + assertThat(bufferSize).isEqualTo(8000); + } + + private static int durationUsToMaxBytes(@C.Encoding int encoding, long durationUs) { + return (int) (durationUs * getMaximumEncodedRateBytesPerSecond(encoding) / MICROS_PER_SECOND); + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderPcmTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderPcmTest.java index 0b922a9c3e..d27999ed65 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderPcmTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProviderPcmTest.java @@ -21,6 +21,7 @@ import static com.google.common.truth.Truth.assertThat; import static java.lang.Math.ceil; import androidx.media3.common.C; +import androidx.media3.common.Format; import androidx.media3.common.util.Util; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -89,6 +90,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize).isEqualTo(roundUpToFrame(1234567890)); @@ -103,6 +105,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -121,6 +124,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -139,6 +143,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -157,6 +162,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -175,6 +181,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1); assertThat(bufferSize) @@ -190,6 +197,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 1 / 5F); assertThat(bufferSize) @@ -205,6 +213,7 @@ public class DefaultAudioTrackBufferSizeProviderPcmTest { /* outputMode= */ OUTPUT_MODE_PCM, /* pcmFrameSize= */ getPcmFrameSize(), /* sampleRate= */ sampleRate, + /* bitrate= */ Format.NO_VALUE, /* maxAudioTrackPlaybackSpeed= */ 8F); int expected = roundUpToFrame(durationUsToBytes(DEFAULT.minPcmBufferDurationUs) * 8); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/metadata/MetadataRendererTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/metadata/MetadataRendererTest.java index 8409d3f4d9..9e248def39 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/metadata/MetadataRendererTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/metadata/MetadataRendererTest.java @@ -108,7 +108,7 @@ public class MetadataRendererTest { assertThat(metadata).hasSize(1); assertThat(metadata.get(0).length()).isEqualTo(1); TextInformationFrame expectedId3Frame = - new TextInformationFrame("TXXX", "Test description", "Test value"); + new TextInformationFrame("TXXX", "Test description", ImmutableList.of("Test value")); assertThat(metadata.get(0).get(0)).isEqualTo(expectedId3Frame); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java new file mode 100644 index 0000000000..14d4e94306 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java @@ -0,0 +1,911 @@ +/* + * Copyright 2021 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 androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlaybackState; +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.max; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.Looper; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.util.EventLogger; +import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.FakeTimeline; +import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.test.core.app.ApplicationProvider; +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; + +/** Unit tests for {@link ConcatenatingMediaSource2}. */ +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class ConcatenatingMediaSource2Test { + + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + public static ImmutableList params() { + ImmutableList.Builder builder = ImmutableList.builder(); + + // Full example with an offset in the initial window, MediaSource with multiple windows and + // periods, and sources with ad insertion. + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ 123, /* adGroupTimesUs...= */ 0, 300_000) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdDurationsUs(new long[][] {new long[] {2_000_000}, new long[] {4_000_000}}); + builder.add( + new TestConfig( + "initial_offset_multiple_windows_and_ads", + buildConcatenatingMediaSource( + buildMediaSource( + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50), + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 2500)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 500, + adPlaybackState)), + buildMediaSource( + buildWindow( + /* periodCount= */ 3, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1800))), + /* expectedAdDiscontinuities= */ 3, + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {550, 500, 1250, 1250, 500, 600, 600, 600}, + /* periodOffsetsInWindowMs= */ new long[] { + -50, 500, 1000, 2250, 3500, 4000, 4600, 5200 + }, + /* periodIsPlaceholder= */ new boolean[] { + false, false, false, false, false, false, false, false + }, + /* windowDurationMs= */ 5800, + /* manifest= */ null) + .withAdPlaybackState(/* periodIndex= */ 4, adPlaybackState))); + + builder.add( + new TestConfig( + "multipleMediaSource_sameManifest", + buildConcatenatingMediaSource( + buildMediaSource( + new Object[] {"manifest"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000)), + buildMediaSource( + new Object[] {"manifest"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 1000}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 2000, + /* manifest= */ "manifest"))); + + builder.add( + new TestConfig( + "multipleMediaSource_differentManifest", + buildConcatenatingMediaSource( + buildMediaSource( + new Object[] {"manifest1"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000)), + buildMediaSource( + new Object[] {"manifest2"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 1000}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 2000, + /* manifest= */ null))); + + // Counter-example for isSeekable and isDynamic. + builder.add( + new TestConfig( + "isSeekable_isDynamic_counter_example", + buildConcatenatingMediaSource( + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 500))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 500}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 1500, + /* manifest= */ null))); + + // Unknown window and period durations. + builder.add( + new TestConfig( + "unknown_window_and_period_durations", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 420, + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ C.TIME_UNSET, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ C.TIME_UNSET))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 420}, + /* periodIsPlaceholder= */ new boolean[] {true, true}, + /* windowDurationMs= */ 840, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 420}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 840, + /* manifest= */ null))); + + // Duplicate sources and nested concatenation. + builder.add( + new TestConfig( + "duplicated_and_nested_sources", + () -> { + MediaSource duplicateSource = + buildMediaSource( + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000)) + .get(); + Supplier duplicateSourceSupplier = () -> duplicateSource; + return buildConcatenatingMediaSource( + duplicateSourceSupplier, + buildConcatenatingMediaSource( + duplicateSourceSupplier, duplicateSourceSupplier), + buildConcatenatingMediaSource( + duplicateSourceSupplier, duplicateSourceSupplier), + duplicateSourceSupplier) + .get(); + }, + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ false, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] { + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500 + }, + /* periodOffsetsInWindowMs= */ new long[] { + 0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500 + }, + /* periodIsPlaceholder= */ new boolean[] { + false, false, false, false, false, false, false, false, false, false, false, false + }, + /* windowDurationMs= */ 6000, + /* manifest= */ null))); + + // Concatenation with initial placeholder durations and delayed timeline updates. + builder.add( + new TestConfig( + "initial_placeholder_and_delayed_preparation", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 5000, + buildMediaSource( + /* preparationDelayCount= */ 1, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 4000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + /* preparationDelayCount= */ 3, + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 7000)), + buildMediaSource( + /* preparationDelayCount= */ 2, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 6000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 5000, 10000}, + /* periodIsPlaceholder= */ new boolean[] {true, true, true}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, true}, + /* windowDurationMs= */ 14000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, false}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, 3500, 3500, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 7500, 11000}, + /* periodIsPlaceholder= */ new boolean[] {false, false, false, false}, + /* windowDurationMs= */ 17000, + /* manifest= */ null))); + + // Concatenation with initial placeholder durations and some immediate timeline updates. + builder.add( + new TestConfig( + "initial_placeholder_and_immediate_partial_preparation", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 5000, + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 4000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + /* preparationDelayCount= */ 1, + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 7000)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 6000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 5000, 10000}, + /* periodIsPlaceholder= */ new boolean[] {true, true, true}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, false}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, 3500, 3500, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 7500, 11000}, + /* periodIsPlaceholder= */ new boolean[] {false, false, false, false}, + /* windowDurationMs= */ 17000, + /* manifest= */ null))); + return builder.build(); + } + + @ParameterizedRobolectricTestRunner.Parameter public TestConfig config; + + private static final String TEST_MEDIA_ITEM_ID = "test_media_item_id"; + + @Test + public void prepareSource_reportsExpectedTimelines() throws Exception { + MediaSource mediaSource = config.mediaSourceSupplier.get(); + ArrayList timelines = new ArrayList<>(); + mediaSource.prepareSource( + (source, timeline) -> timelines.add(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + for (int i = 0; i < config.expectedTimelineData.size(); i++) { + Timeline timeline = timelines.get(i); + ExpectedTimelineData expectedData = config.expectedTimelineData.get(i); + assertThat(timeline.getWindowCount()).isEqualTo(1); + assertThat(timeline.getPeriodCount()).isEqualTo(expectedData.periodDurationsMs.length); + + Timeline.Window window = timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()); + assertThat(window.getDurationMs()).isEqualTo(expectedData.windowDurationMs); + assertThat(window.isDynamic).isEqualTo(expectedData.isDynamic); + assertThat(window.isSeekable).isEqualTo(expectedData.isSeekable); + assertThat(window.getDefaultPositionMs()).isEqualTo(expectedData.defaultPositionMs); + assertThat(window.getPositionInFirstPeriodMs()) + .isEqualTo(-expectedData.periodOffsetsInWindowMs[0]); + assertThat(window.firstPeriodIndex).isEqualTo(0); + assertThat(window.lastPeriodIndex).isEqualTo(expectedData.periodDurationsMs.length - 1); + assertThat(window.uid).isEqualTo(Timeline.Window.SINGLE_WINDOW_UID); + assertThat(window.mediaItem.mediaId).isEqualTo(TEST_MEDIA_ITEM_ID); + assertThat(window.isPlaceholder).isFalse(); + assertThat(window.elapsedRealtimeEpochOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(window.presentationStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.windowStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.liveConfiguration).isNull(); + assertThat(window.manifest).isEqualTo(expectedData.manifest); + + HashSet uidSet = new HashSet<>(); + for (int j = 0; j < timeline.getPeriodCount(); j++) { + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ j, new Timeline.Period(), /* setIds= */ true); + assertThat(period.getDurationMs()).isEqualTo(expectedData.periodDurationsMs[j]); + assertThat(period.windowIndex).isEqualTo(0); + assertThat(period.getPositionInWindowMs()) + .isEqualTo(expectedData.periodOffsetsInWindowMs[j]); + assertThat(period.isPlaceholder).isEqualTo(expectedData.periodIsPlaceholder[j]); + uidSet.add(period.uid); + assertThat(timeline.getIndexOfPeriod(period.uid)).isEqualTo(j); + assertThat(timeline.getUidOfPeriod(j)).isEqualTo(period.uid); + assertThat(timeline.getPeriodByUid(period.uid, new Timeline.Period())).isEqualTo(period); + } + assertThat(uidSet).hasSize(timeline.getPeriodCount()); + } + } + + @Test + public void prepareSource_afterRelease_reportsSameFinalTimeline() throws Exception { + // Fully prepare source once. + MediaSource mediaSource = config.mediaSourceSupplier.get(); + ArrayList timelines = new ArrayList<>(); + MediaSource.MediaSourceCaller caller = (source, timeline) -> timelines.add(timeline); + mediaSource.prepareSource(caller, /* mediaTransferListener= */ null, PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + // Release and re-prepare. + mediaSource.releaseSource(caller); + AtomicReference secondTimeline = new AtomicReference<>(); + MediaSource.MediaSourceCaller secondCaller = (source, timeline) -> secondTimeline.set(timeline); + mediaSource.prepareSource(secondCaller, /* mediaTransferListener= */ null, PlayerId.UNSET); + + // Assert that we receive the same final timeline. + runMainLooperUntil(() -> Iterables.getLast(timelines).equals(secondTimeline.get())); + } + + @Test + public void preparePeriod_reportsExpectedPeriodLoadEvents() throws Exception { + // Prepare source and register listener. + MediaSource mediaSource = config.mediaSourceSupplier.get(); + MediaSourceEventListener eventListener = mock(MediaSourceEventListener.class); + mediaSource.addEventListener(new Handler(Looper.myLooper()), eventListener); + ArrayList timelines = new ArrayList<>(); + mediaSource.prepareSource( + (source, timeline) -> timelines.add(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + // Iterate through all periods and ads. Create and prepare them twice, because the MediaSource + // should support creating the same period more than once. + ArrayList mediaPeriods = new ArrayList<>(); + ArrayList mediaPeriodIds = new ArrayList<>(); + Timeline timeline = Iterables.getLast(timelines); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ i, new Timeline.Period(), /* setIds= */ true); + MediaSource.MediaPeriodId mediaPeriodId = + new MediaSource.MediaPeriodId(period.uid, /* windowSequenceNumber= */ 15); + MediaPeriod mediaPeriod = + mediaSource.createPeriod(mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + mediaPeriodId = mediaPeriodId.copyWithWindowSequenceNumber(/* windowSequenceNumber= */ 25); + mediaPeriod = + mediaSource.createPeriod(mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + for (int j = 0; j < period.getAdGroupCount(); j++) { + for (int k = 0; k < period.getAdCountInAdGroup(j); k++) { + mediaPeriodId = + new MediaSource.MediaPeriodId( + period.uid, + /* adGroupIndex= */ j, + /* adIndexInAdGroup= */ k, + /* windowSequenceNumber= */ 35); + mediaPeriod = + mediaSource.createPeriod( + mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + mediaPeriodId = + mediaPeriodId.copyWithWindowSequenceNumber(/* windowSequenceNumber= */ 45); + mediaPeriod = + mediaSource.createPeriod( + mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + } + } + } + // Release all periods again. + for (MediaPeriod mediaPeriod : mediaPeriods) { + mediaSource.releasePeriod(mediaPeriod); + } + + // Verify each load started and completed event is called with the correct mediaPeriodId. + for (MediaSource.MediaPeriodId mediaPeriodId : mediaPeriodIds) { + verify(eventListener) + .onLoadStarted( + /* windowIndex= */ eq(0), + /* mediaPeriodId= */ eq(mediaPeriodId), + /* loadEventInfo= */ any(), + /* mediaLoadData= */ any()); + verify(eventListener) + .onLoadCompleted( + /* windowIndex= */ eq(0), + /* mediaPeriodId= */ eq(mediaPeriodId), + /* loadEventInfo= */ any(), + /* mediaLoadData= */ any()); + } + } + + @Test + public void playback_fromDefaultPosition_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + player.setMediaSource(config.mediaSourceSupplier.get()); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + long positionAfterPrepareMs = player.getCurrentPosition(); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(positionAfterPrepareMs).isEqualTo(expectedData.defaultPositionMs); + if (!isDynamic) { + verify( + eventListener, + times(config.expectedAdDiscontinuities + expectedData.periodDurationsMs.length - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } + + @Test + public void + playback_fromSpecificPeriodPositionInFirstPeriod_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + player.setMediaSource(mediaSource); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + long startWindowPositionMs = 24; + player.seekTo(startWindowPositionMs); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + long windowPositionAfterPrepareMs = player.getCurrentPosition(); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(windowPositionAfterPrepareMs).isEqualTo(startWindowPositionMs); + if (!isDynamic) { + verify( + eventListener, + times(expectedData.periodDurationsMs.length - 1 + config.expectedAdDiscontinuities)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } + + @Test + public void + playback_fromSpecificPeriodPositionInSubsequentPeriod_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + Timeline.Period period = new Timeline.Period(); + Timeline.Window window = new Timeline.Window(); + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + player.setMediaSource(mediaSource); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + ExpectedTimelineData initialTimelineData = config.expectedTimelineData.get(0); + int startPeriodIndex = max(1, initialTimelineData.periodDurationsMs.length - 2); + long startPeriodPositionMs = 24; + long startWindowPositionMs = + initialTimelineData.periodOffsetsInWindowMs[startPeriodIndex] + startPeriodPositionMs; + player.seekTo(startWindowPositionMs); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + Timeline timeline = player.getCurrentTimeline(); + long windowPositionAfterPrepareMs = player.getContentPosition(); + Pair periodPositionUs = + timeline.getPeriodPositionUs(window, period, 0, Util.msToUs(windowPositionAfterPrepareMs)); + int periodIndexAfterPrepare = timeline.getIndexOfPeriod(periodPositionUs.first); + long periodPositionAfterPrepareMs = Util.usToMs(periodPositionUs.second); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(periodPositionAfterPrepareMs).isEqualTo(startPeriodPositionMs); + if (timeline.getPeriod(periodIndexAfterPrepare, period).getAdGroupCount() == 0) { + assertThat(periodIndexAfterPrepare).isEqualTo(startPeriodIndex); + if (!isDynamic) { + verify(eventListener, times(expectedData.periodDurationsMs.length - startPeriodIndex - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } else { + // Seek beyond ad period: assert roll forward to un-played ad period. + assertThat(periodIndexAfterPrepare).isLessThan(startPeriodIndex); + verify(eventListener, atLeast(expectedData.periodDurationsMs.length - startPeriodIndex - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + timeline.getPeriod(periodIndexAfterPrepare, period); + assertThat(period.getAdGroupIndexForPositionUs(period.durationUs)) + .isNotEqualTo(C.INDEX_UNSET); + } + } + + private static void blockingPrepareMediaPeriod(MediaPeriod mediaPeriod) { + ConditionVariable mediaPeriodPrepared = new ConditionVariable(); + mediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + mediaPeriodPrepared.open(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + mediaPeriod.continueLoading(/* positionUs= */ 0); + } + }, + /* positionUs= */ 0); + mediaPeriodPrepared.block(); + } + + private static Supplier buildConcatenatingMediaSource( + Supplier... sources) { + return buildConcatenatingMediaSource(/* placeholderDurationMs= */ C.TIME_UNSET, sources); + } + + private static Supplier buildConcatenatingMediaSource( + long placeholderDurationMs, Supplier... sources) { + return () -> { + ConcatenatingMediaSource2.Builder builder = new ConcatenatingMediaSource2.Builder(); + builder.setMediaItem(new MediaItem.Builder().setMediaId(TEST_MEDIA_ITEM_ID).build()); + for (Supplier source : sources) { + builder.add(source.get(), placeholderDurationMs); + } + return builder.build(); + }; + } + + private static Supplier buildMediaSource( + FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(/* preparationDelayCount= */ 0, windows); + } + + private static Supplier buildMediaSource( + int preparationDelayCount, FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(preparationDelayCount, /* manifests= */ null, windows); + } + + private static Supplier buildMediaSource( + Object[] manifests, FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(/* preparationDelayCount= */ 0, manifests, windows); + } + + private static Supplier buildMediaSource( + int preparationDelayCount, + @Nullable Object[] manifests, + FakeTimeline.TimelineWindowDefinition... windows) { + + return () -> { + // Simulate delay by repeatedly sending messages to self. This ensures that all other message + // handling trigger by source preparation finishes before the new timeline update arrives. + AtomicInteger delayCount = new AtomicInteger(10 * preparationDelayCount); + return new FakeMediaSource( + /* timeline= */ null, + new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()) { + @Override + public synchronized void prepareSourceInternal( + @Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + Handler delayHandler = new Handler(Looper.myLooper()); + Runnable handleDelay = + new Runnable() { + @Override + public void run() { + if (delayCount.getAndDecrement() == 0) { + setNewSourceInfo( + manifests != null + ? new FakeTimeline(manifests, windows) + : new FakeTimeline(windows)); + } else { + delayHandler.post(this); + } + } + }; + delayHandler.post(handleDelay); + } + }; + }; + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, boolean isSeekable, boolean isDynamic, long durationMs) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + /* defaultPositionMs= */ 0, + /* windowOffsetInFirstPeriodMs= */ 0); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + long defaultPositionMs, + long windowOffsetInFirstPeriodMs) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + defaultPositionMs, + windowOffsetInFirstPeriodMs, + AdPlaybackState.NONE); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + AdPlaybackState adPlaybackState) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + /* defaultPositionMs= */ 0, + /* windowOffsetInFirstPeriodMs= */ 0, + adPlaybackState); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + long defaultPositionMs, + long windowOffsetInFirstPeriodMs, + AdPlaybackState adPlaybackState) { + return new FakeTimeline.TimelineWindowDefinition( + periodCount, + /* id= */ new Object(), + isSeekable, + isDynamic, + /* isLive= */ false, + /* isPlaceholder= */ false, + Util.msToUs(durationMs), + Util.msToUs(defaultPositionMs), + Util.msToUs(windowOffsetInFirstPeriodMs), + ImmutableList.of(adPlaybackState), + new MediaItem.Builder().setMediaId("").build()); + } + + private static final class TestConfig { + + public final Supplier mediaSourceSupplier; + public final ImmutableList expectedTimelineData; + + private final int expectedAdDiscontinuities; + private final String tag; + + public TestConfig( + String tag, + Supplier mediaSourceSupplier, + int expectedAdDiscontinuities, + ExpectedTimelineData... expectedTimelineData) { + this.tag = tag; + this.mediaSourceSupplier = mediaSourceSupplier; + this.expectedTimelineData = ImmutableList.copyOf(expectedTimelineData); + this.expectedAdDiscontinuities = expectedAdDiscontinuities; + } + + @Override + public String toString() { + return tag; + } + } + + private static final class ExpectedTimelineData { + + public final boolean isSeekable; + public final boolean isDynamic; + public final long defaultPositionMs; + public final long[] periodDurationsMs; + public final long[] periodOffsetsInWindowMs; + public final boolean[] periodIsPlaceholder; + public final long windowDurationMs; + public final AdPlaybackState[] adPlaybackState; + @Nullable public final Object manifest; + + public ExpectedTimelineData( + boolean isSeekable, + boolean isDynamic, + long defaultPositionMs, + long[] periodDurationsMs, + long[] periodOffsetsInWindowMs, + boolean[] periodIsPlaceholder, + long windowDurationMs, + @Nullable Object manifest) { + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.defaultPositionMs = defaultPositionMs; + this.periodDurationsMs = periodDurationsMs; + this.periodOffsetsInWindowMs = periodOffsetsInWindowMs; + this.periodIsPlaceholder = periodIsPlaceholder; + this.windowDurationMs = windowDurationMs; + this.adPlaybackState = new AdPlaybackState[periodDurationsMs.length]; + this.manifest = manifest; + } + + @CanIgnoreReturnValue + public ExpectedTimelineData withAdPlaybackState( + int periodIndex, AdPlaybackState adPlaybackState) { + this.adPlaybackState[periodIndex] = adPlaybackState; + return this; + } + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaPeriodTest.java index 740e468c23..2ef9252c64 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaPeriodTest.java @@ -198,6 +198,39 @@ public final class MergingMediaPeriodTest { assertThat(firstSelectionChild2).isEqualTo(secondSelectionChild2); } + // https://github.com/google/ExoPlayer/issues/10930 + @Test + public void selectTracks_withIdenticalFormats_selectsMatchingPeriod() throws Exception { + MergingMediaPeriod mergingMediaPeriod = + prepareMergingPeriod( + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 123_000, childFormat11), + new MergingPeriodDefinition( + /* timeOffsetUs= */ -3000, /* singleSampleTimeUs= */ 456_000, childFormat11)); + + ExoTrackSelection[] selectionArray = { + new FixedTrackSelection(mergingMediaPeriod.getTrackGroups().get(1), /* track= */ 0) + }; + + SampleStream[] streams = new SampleStream[1]; + mergingMediaPeriod.selectTracks( + selectionArray, + /* mayRetainStreamFlags= */ new boolean[2], + streams, + /* streamResetFlags= */ new boolean[2], + /* positionUs= */ 0); + mergingMediaPeriod.continueLoading(/* positionUs= */ 0); + + FormatHolder formatHolder = new FormatHolder(); + DecoderInputBuffer inputBuffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + streams[0].readData(formatHolder, inputBuffer, FLAG_REQUIRE_FORMAT); + + assertThat(streams[0].readData(formatHolder, inputBuffer, /* readFlags= */ 0)) + .isEqualTo(C.RESULT_BUFFER_READ); + assertThat(inputBuffer.timeUs).isEqualTo(456_000 - 3000); + } + private MergingMediaPeriod prepareMergingPeriod(MergingPeriodDefinition... definitions) throws Exception { MediaPeriod[] mediaPeriods = new MediaPeriod[definitions.length]; diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/MediaCodecVideoRendererTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/MediaCodecVideoRendererTest.java index 4e3f48ea06..d42ab38fe0 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/MediaCodecVideoRendererTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/MediaCodecVideoRendererTest.java @@ -58,6 +58,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; import java.util.Collections; +import java.util.List; import java.util.stream.Collectors; import org.junit.After; import org.junit.Before; @@ -84,6 +85,32 @@ public class MediaCodecVideoRendererTest { .setHeight(1080) .build(); + private static final MediaCodecInfo H264_PROFILE8_LEVEL4_HW_MEDIA_CODEC_INFO = + MediaCodecInfo.newInstance( + /* name= */ "h264-codec-hw", + /* mimeType= */ MimeTypes.VIDEO_H264, + /* codecMimeType= */ MimeTypes.VIDEO_H264, + /* capabilities= */ createCodecCapabilities( + CodecProfileLevel.AVCProfileHigh, CodecProfileLevel.AVCLevel4), + /* hardwareAccelerated= */ true, + /* softwareOnly= */ false, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false); + + private static final MediaCodecInfo H264_PROFILE8_LEVEL5_SW_MEDIA_CODEC_INFO = + MediaCodecInfo.newInstance( + /* name= */ "h264-codec-sw", + /* mimeType= */ MimeTypes.VIDEO_H264, + /* codecMimeType= */ MimeTypes.VIDEO_H264, + /* capabilities= */ createCodecCapabilities( + CodecProfileLevel.AVCProfileHigh, CodecProfileLevel.AVCLevel5), + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false); + private Looper testMainLooper; private Surface surface; private MediaCodecVideoRenderer mediaCodecVideoRenderer; @@ -711,6 +738,100 @@ public class MediaCodecVideoRendererTest { .isEqualTo(RendererCapabilities.DECODER_SUPPORT_PRIMARY); } + @Test + public void getDecoderInfo_withNonPerformantHardwareDecoder_returnsHardwareDecoderFirst() + throws Exception { + // AVC Format, Profile: 8, Level: 8192 + Format avcFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setCodecs("avc1.64002a") + .build(); + // Provide hardware and software AVC decoders + MediaCodecSelector mediaCodecSelector = + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> { + if (!mimeType.equals(MimeTypes.VIDEO_H264)) { + return ImmutableList.of(); + } + // Hardware decoder supports above format functionally but not performantly as + // it supports MIME type & Profile but not Level + // Software decoder supports format functionally and peformantly as it supports + // MIME type, Profile, and Level(assuming resolution/frame rate support too) + return ImmutableList.of( + H264_PROFILE8_LEVEL4_HW_MEDIA_CODEC_INFO, H264_PROFILE8_LEVEL5_SW_MEDIA_CODEC_INFO); + }; + MediaCodecVideoRenderer renderer = + new MediaCodecVideoRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* allowedJoiningTimeMs= */ 0, + /* eventHandler= */ new Handler(testMainLooper), + /* eventListener= */ eventListener, + /* maxDroppedFramesToNotify= */ 1); + renderer.init(/* index= */ 0, PlayerId.UNSET); + + List mediaCodecInfoList = + renderer.getDecoderInfos(mediaCodecSelector, avcFormat, false); + @Capabilities int capabilities = renderer.supportsFormat(avcFormat); + + assertThat(mediaCodecInfoList).hasSize(2); + assertThat(mediaCodecInfoList.get(0).hardwareAccelerated).isTrue(); + assertThat(RendererCapabilities.getFormatSupport(capabilities)).isEqualTo(C.FORMAT_HANDLED); + assertThat(RendererCapabilities.getDecoderSupport(capabilities)) + .isEqualTo(RendererCapabilities.DECODER_SUPPORT_FALLBACK); + } + + @Test + public void getDecoderInfo_softwareDecoderPreferred_returnsSoftwareDecoderFirst() + throws Exception { + // AVC Format, Profile: 8, Level: 8192 + Format avcFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setCodecs("avc1.64002a") + .build(); + // Provide software and hardware AVC decoders + MediaCodecSelector mediaCodecSelector = + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> { + if (!mimeType.equals(MimeTypes.VIDEO_H264)) { + return ImmutableList.of(); + } + // Hardware decoder supports above format functionally but not performantly as + // it supports MIME type & Profile but not Level + // Software decoder supports format functionally and peformantly as it supports + // MIME type, Profile, and Level(assuming resolution/frame rate support too) + return ImmutableList.of( + H264_PROFILE8_LEVEL5_SW_MEDIA_CODEC_INFO, H264_PROFILE8_LEVEL4_HW_MEDIA_CODEC_INFO); + }; + MediaCodecVideoRenderer renderer = + new MediaCodecVideoRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* allowedJoiningTimeMs= */ 0, + /* eventHandler= */ new Handler(testMainLooper), + /* eventListener= */ eventListener, + /* maxDroppedFramesToNotify= */ 1); + renderer.init(/* index= */ 0, PlayerId.UNSET); + + List mediaCodecInfoList = + renderer.getDecoderInfos(mediaCodecSelector, avcFormat, false); + @Capabilities int capabilities = renderer.supportsFormat(avcFormat); + + assertThat(mediaCodecInfoList).hasSize(2); + assertThat(mediaCodecInfoList.get(0).hardwareAccelerated).isFalse(); + assertThat(RendererCapabilities.getFormatSupport(capabilities)).isEqualTo(C.FORMAT_HANDLED); + assertThat(RendererCapabilities.getDecoderSupport(capabilities)) + .isEqualTo(RendererCapabilities.DECODER_SUPPORT_PRIMARY); + } + + private static CodecCapabilities createCodecCapabilities(int profile, int level) { + CodecCapabilities capabilities = new CodecCapabilities(); + capabilities.profileLevels = new CodecProfileLevel[] {new CodecProfileLevel()}; + capabilities.profileLevels[0].profile = profile; + capabilities.profileLevels[0].level = level; + return capabilities; + } + @Test public void getCodecMaxInputSize_videoH263() { MediaCodecInfo codecInfo = createMediaCodecInfo(MimeTypes.VIDEO_H263); diff --git a/libraries/exoplayer_dash/README.md b/libraries/exoplayer_dash/README.md index 8c52a83963..3f7ec5035f 100644 --- a/libraries/exoplayer_dash/README.md +++ b/libraries/exoplayer_dash/README.md @@ -33,3 +33,11 @@ the module and build `DashDownloader` instances to download DASH content. For advanced playback use cases, applications can build `DashMediaSource` instances and pass them directly to the player. For advanced download use cases, `DashDownloader` can be used directly. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java index d9603d742f..00a96572a8 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java @@ -1058,9 +1058,11 @@ public final class DashMediaSource extends BaseMediaSource { for (int i = 0; i < period.adaptationSets.size(); i++) { AdaptationSet adaptationSet = period.adaptationSets.get(i); List representations = adaptationSet.representations; - // Exclude text adaptation sets from duration calculations, if we have at least one audio - // or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029 - if ((haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) + // Exclude other adaptation sets from duration calculations, if we have at least one audio or + // video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029. + boolean adaptationSetIsNotAudioVideo = + adaptationSet.type != C.TRACK_TYPE_AUDIO && adaptationSet.type != C.TRACK_TYPE_VIDEO; + if ((haveAudioVideoAdaptationSets && adaptationSetIsNotAudioVideo) || representations.isEmpty()) { continue; } @@ -1090,9 +1092,11 @@ public final class DashMediaSource extends BaseMediaSource { for (int i = 0; i < period.adaptationSets.size(); i++) { AdaptationSet adaptationSet = period.adaptationSets.get(i); List representations = adaptationSet.representations; - // Exclude text adaptation sets from duration calculations, if we have at least one audio - // or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029 - if ((haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) + // Exclude other adaptation sets from duration calculations, if we have at least one audio or + // video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029 + boolean adaptationSetIsNotAudioVideo = + adaptationSet.type != C.TRACK_TYPE_AUDIO && adaptationSet.type != C.TRACK_TYPE_VIDEO; + if ((haveAudioVideoAdaptationSets && adaptationSetIsNotAudioVideo) || representations.isEmpty()) { continue; } diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java index c5006ad7b7..0e0bb927b9 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java @@ -557,7 +557,9 @@ public class DashManifestParser extends DefaultHandler ? C.TRACK_TYPE_VIDEO : MimeTypes.BASE_TYPE_TEXT.equals(contentType) ? C.TRACK_TYPE_TEXT - : C.TRACK_TYPE_UNKNOWN; + : MimeTypes.BASE_TYPE_IMAGE.equals(contentType) + ? C.TRACK_TYPE_IMAGE + : C.TRACK_TYPE_UNKNOWN; } /** @@ -810,6 +812,7 @@ public class DashManifestParser extends DefaultHandler roleFlags |= parseRoleFlagsFromAccessibilityDescriptors(accessibilityDescriptors); roleFlags |= parseRoleFlagsFromProperties(essentialProperties); roleFlags |= parseRoleFlagsFromProperties(supplementalProperties); + @Nullable Pair tileCounts = parseTileCountFromProperties(essentialProperties); Format.Builder formatBuilder = new Format.Builder() @@ -820,7 +823,9 @@ public class DashManifestParser extends DefaultHandler .setPeakBitrate(bitrate) .setSelectionFlags(selectionFlags) .setRoleFlags(roleFlags) - .setLanguage(language); + .setLanguage(language) + .setTileCountHorizontal(tileCounts != null ? tileCounts.first : Format.NO_VALUE) + .setTileCountVertical(tileCounts != null ? tileCounts.second : Format.NO_VALUE); if (MimeTypes.isVideo(sampleMimeType)) { formatBuilder.setWidth(width).setHeight(height).setFrameRate(frameRate); @@ -1629,6 +1634,41 @@ public class DashManifestParser extends DefaultHandler return attributeValue.split(","); } + // Thumbnail tile information parsing + + /** + * Parses given descriptors for thumbnail tile information. + * + * @param essentialProperties List of descriptors that contain thumbnail tile information. + * @return A pair of Integer values, where the first is the count of horizontal tiles and the + * second is the count of vertical tiles, or null if no thumbnail tile information is found. + */ + @Nullable + protected Pair parseTileCountFromProperties( + List essentialProperties) { + for (int i = 0; i < essentialProperties.size(); i++) { + Descriptor descriptor = essentialProperties.get(i); + if ((Ascii.equalsIgnoreCase("http://dashif.org/thumbnail_tile", descriptor.schemeIdUri) + || Ascii.equalsIgnoreCase( + "http://dashif.org/guidelines/thumbnail_tile", descriptor.schemeIdUri)) + && descriptor.value != null) { + String size = descriptor.value; + String[] sizeSplit = Util.split(size, "x"); + if (sizeSplit.length != 2) { + continue; + } + try { + int tileCountHorizontal = Integer.parseInt(sizeSplit[0]); + int tileCountVertical = Integer.parseInt(sizeSplit[1]); + return Pair.create(tileCountHorizontal, tileCountVertical); + } catch (NumberFormatException e) { + // Ignore property if it's malformed. + } + } + } + return null; + } + // Utility methods. /** diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java index 04d53b1841..29510717d7 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java @@ -252,11 +252,19 @@ public class DashManifestParserTest { ApplicationProvider.getApplicationContext(), SAMPLE_MPD_IMAGES)); AdaptationSet adaptationSet = manifest.getPeriod(0).adaptationSets.get(0); - Format format = adaptationSet.representations.get(0).format; + Format format0 = adaptationSet.representations.get(0).format; + Format format1 = adaptationSet.representations.get(1).format; - assertThat(format.sampleMimeType).isEqualTo("image/jpeg"); - assertThat(format.width).isEqualTo(320); - assertThat(format.height).isEqualTo(180); + assertThat(format0.sampleMimeType).isEqualTo("image/jpeg"); + assertThat(format0.width).isEqualTo(320); + assertThat(format0.height).isEqualTo(180); + assertThat(format0.tileCountHorizontal).isEqualTo(12); + assertThat(format0.tileCountVertical).isEqualTo(16); + assertThat(format1.sampleMimeType).isEqualTo("image/jpeg"); + assertThat(format1.width).isEqualTo(640); + assertThat(format1.height).isEqualTo(360); + assertThat(format1.tileCountHorizontal).isEqualTo(2); + assertThat(format1.tileCountVertical).isEqualTo(4); } @Test diff --git a/libraries/exoplayer_hls/README.md b/libraries/exoplayer_hls/README.md index 34f31c312d..f89d324d06 100644 --- a/libraries/exoplayer_hls/README.md +++ b/libraries/exoplayer_hls/README.md @@ -32,3 +32,11 @@ the module and build `HlsDownloader` instances to download HLS content. For advanced playback use cases, applications can build `HlsMediaSource` instances and pass them directly to the player. For advanced download use cases, `HlsDownloader` can be used directly. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer_ima/README.md b/libraries/exoplayer_ima/README.md index b2143d26e3..06ada682a7 100644 --- a/libraries/exoplayer_ima/README.md +++ b/libraries/exoplayer_ima/README.md @@ -49,3 +49,11 @@ You can try the IMA module in the ExoPlayer demo app, which has test content in the "IMA sample ad tags" section of the sample chooser. The demo app's `PlayerActivity` also shows how to persist the `ImaAdsLoader` instance and the player position when backgrounded during ad playback. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer_ima/build.gradle b/libraries/exoplayer_ima/build.gradle index cc8d1ef0e7..446fe90a29 100644 --- a/libraries/exoplayer_ima/build.gradle +++ b/libraries/exoplayer_ima/build.gradle @@ -25,7 +25,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.28.1' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.29.0' implementation project(modulePrefix + 'lib-exoplayer') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java index e5467d7a54..e029b74578 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java @@ -30,15 +30,14 @@ import static androidx.media3.exoplayer.ima.ImaUtil.splitAdGroup; import static androidx.media3.exoplayer.ima.ImaUtil.splitAdPlaybackStateForPeriods; import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationInAdGroup; import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; -import static java.lang.annotation.ElementType.TYPE_USE; import android.content.Context; import android.net.Uri; import android.os.Bundle; import android.os.Handler; +import android.os.Looper; import android.util.Pair; import android.view.ViewGroup; -import androidx.annotation.IntDef; import androidx.annotation.MainThread; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -58,6 +57,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.ima.ImaUtil.ServerSideAdInsertionConfiguration; import androidx.media3.exoplayer.source.CompositeMediaSource; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.ForwardingTimeline; @@ -97,10 +97,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -198,6 +194,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Nullable private AdErrorEvent.AdErrorListener adErrorListener; private State state; private ImmutableList companionAdSlots; + private boolean focusSkipButtonWhenAvailable; /** * Creates an instance. @@ -210,12 +207,15 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou this.adViewProvider = adViewProvider; companionAdSlots = ImmutableList.of(); state = new State(ImmutableMap.of()); + focusSkipButtonWhenAvailable = true; } /** * Sets the IMA SDK settings. * - *

    If this method is not called the default settings will be used. + *

    If this method is not called, the {@linkplain ImaSdkFactory#createImaSdkSettings() + * default settings} will be used with the language set to {@linkplain + * Util#getSystemLanguageCodes() the preferred system language}. * * @param imaSdkSettings The {@link ImaSdkSettings}. * @return This builder, for convenience. @@ -279,6 +279,22 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou return this; } + /** + * Sets whether to focus the skip button (when available) on Android TV devices. The default + * setting is {@code true}. + * + * @param focusSkipButtonWhenAvailable Whether to focus the skip button (when available) on + * Android TV devices. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setFocusSkipButtonWhenAvailable(boolean) + */ + @CanIgnoreReturnValue + public AdsLoader.Builder setFocusSkipButtonWhenAvailable( + boolean focusSkipButtonWhenAvailable) { + this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + return this; + } + /** Returns a new {@link AdsLoader}. */ public AdsLoader build() { @Nullable ImaSdkSettings imaSdkSettings = this.imaSdkSettings; @@ -286,13 +302,14 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings(); imaSdkSettings.setLanguage(Util.getSystemLanguageCodes()[0]); } - ImaUtil.ServerSideAdInsertionConfiguration configuration = - new ImaUtil.ServerSideAdInsertionConfiguration( + ServerSideAdInsertionConfiguration configuration = + new ServerSideAdInsertionConfiguration( adViewProvider, imaSdkSettings, adEventListener, adErrorListener, companionAdSlots, + focusSkipButtonWhenAvailable, imaSdkSettings.isDebugMode()); return new AdsLoader(context, configuration, state); } @@ -327,13 +344,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({FIELD_AD_PLAYBACK_STATES}) - private @interface FieldNumber {} - - private static final int FIELD_AD_PLAYBACK_STATES = 1; + private static final String FIELD_AD_PLAYBACK_STATES = Util.intToStringMaxRadix(1); @Override public Bundle toBundle() { @@ -342,7 +353,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou for (Map.Entry entry : adPlaybackStates.entrySet()) { adPlaybackStatesBundle.putBundle(entry.getKey(), entry.getValue().toBundle()); } - bundle.putBundle(keyForField(FIELD_AD_PLAYBACK_STATES), adPlaybackStatesBundle); + bundle.putBundle(FIELD_AD_PLAYBACK_STATES, adPlaybackStatesBundle); return bundle; } @@ -353,8 +364,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Nullable ImmutableMap.Builder adPlaybackStateMap = new ImmutableMap.Builder<>(); - Bundle adPlaybackStateBundle = - checkNotNull(bundle.getBundle(keyForField(FIELD_AD_PLAYBACK_STATES))); + Bundle adPlaybackStateBundle = checkNotNull(bundle.getBundle(FIELD_AD_PLAYBACK_STATES)); for (String key : adPlaybackStateBundle.keySet()) { AdPlaybackState adPlaybackState = AdPlaybackState.CREATOR.fromBundle( @@ -364,22 +374,17 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou } return new State(adPlaybackStateMap.buildOrThrow()); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } - private final ImaUtil.ServerSideAdInsertionConfiguration configuration; + private final ServerSideAdInsertionConfiguration configuration; private final Context context; - private final Map - mediaSourceResources; + private final Map mediaSourceResources; private final Map adPlaybackStateMap; @Nullable private Player player; private AdsLoader( - Context context, ImaUtil.ServerSideAdInsertionConfiguration configuration, State state) { + Context context, ServerSideAdInsertionConfiguration configuration, State state) { this.context = context.getApplicationContext(); this.configuration = configuration; mediaSourceResources = new HashMap<>(); @@ -399,6 +404,35 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou this.player = player; } + /** + * Puts the focus on the skip button, if a skip button is present and an ad is playing. + * + * @see StreamManager#focus() + */ + public void focusSkipButton() { + if (player == null) { + return; + } + if (player.getPlaybackState() != Player.STATE_IDLE + && player.getPlaybackState() != Player.STATE_ENDED + && player.getMediaItemCount() > 0) { + int currentPeriodIndex = player.getCurrentPeriodIndex(); + Object adsId = + player + .getCurrentTimeline() + .getPeriod(currentPeriodIndex, new Timeline.Period()) + .getAdsId(); + if (adsId instanceof String) { + MediaSourceResourceHolder mediaSourceResourceHolder = mediaSourceResources.get(adsId); + if (mediaSourceResourceHolder != null + && mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager + != null) { + mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager.focus(); + } + } + } + } + /** * Releases resources. * @@ -425,7 +459,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou StreamPlayer streamPlayer, com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { mediaSourceResources.put( - mediaSource, new MediaSourceResourceHolder(mediaSource, streamPlayer, adsLoader)); + mediaSource.adsId, new MediaSourceResourceHolder(mediaSource, streamPlayer, adsLoader)); } private AdPlaybackState getAdPlaybackState(String adsId) { @@ -495,7 +529,8 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou this.applicationAdEventListener = applicationAdEventListener; this.applicationAdErrorListener = applicationAdErrorListener; componentListener = new ComponentListener(); - mainHandler = Util.createHandlerForCurrentLooper(); + Assertions.checkArgument(player.getApplicationLooper() == Looper.getMainLooper()); + mainHandler = new Handler(Looper.getMainLooper()); Uri streamRequestUri = checkNotNull(mediaItem.localConfiguration).uri; isLiveStream = ImaServerSideAdInsertionUriBuilder.isLiveStream(streamRequestUri); adsId = ImaServerSideAdInsertionUriBuilder.getAdsId(streamRequestUri); @@ -519,6 +554,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou StreamManagerLoadable streamManagerLoadable = new StreamManagerLoadable( sdkAdsLoader, + adsLoader.configuration, streamRequest, streamPlayer, applicationAdErrorListener, @@ -572,8 +608,11 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou super.releaseSourceInternal(); if (loader != null) { loader.release(); - player.removeListener(componentListener); - mainHandler.post(() -> setStreamManager(/* streamManager= */ null)); + mainHandler.post( + () -> { + player.removeListener(componentListener); + setStreamManager(/* streamManager= */ null); + }); loader = null; } } @@ -813,7 +852,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou if (entry instanceof TextInformationFrame) { TextInformationFrame textFrame = (TextInformationFrame) entry; if ("TXXX".equals(textFrame.id)) { - streamPlayer.triggerUserTextReceived(textFrame.value); + streamPlayer.triggerUserTextReceived(textFrame.values.get(0)); } } else if (entry instanceof EventMessage) { EventMessage eventMessage = (EventMessage) entry; @@ -944,6 +983,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou implements Loadable, AdsLoadedListener, AdErrorListener { private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final ServerSideAdInsertionConfiguration serverSideAdInsertionConfiguration; private final StreamRequest request; private final StreamPlayer streamPlayer; @Nullable private final AdErrorListener adErrorListener; @@ -960,11 +1000,13 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou /** Creates an instance. */ private StreamManagerLoadable( com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader, + ServerSideAdInsertionConfiguration serverSideAdInsertionConfiguration, StreamRequest request, StreamPlayer streamPlayer, @Nullable AdErrorListener adErrorListener, int loadVideoTimeoutMs) { this.adsLoader = adsLoader; + this.serverSideAdInsertionConfiguration = serverSideAdInsertionConfiguration; this.request = request; this.streamPlayer = streamPlayer; this.adErrorListener = adErrorListener; @@ -1041,6 +1083,8 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou AdsRenderingSettings adsRenderingSettings = ImaSdkFactory.getInstance().createAdsRenderingSettings(); adsRenderingSettings.setLoadVideoTimeout(loadVideoTimeoutMs); + adsRenderingSettings.setFocusSkipButtonWhenAvailable( + serverSideAdInsertionConfiguration.focusSkipButtonWhenAvailable); // After initialization completed the streamUri will be reported to the streamPlayer. streamManager.init(adsRenderingSettings); this.streamManager = streamManager; @@ -1273,7 +1317,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou private static StreamDisplayContainer createStreamDisplayContainer( ImaSdkFactory imaSdkFactory, - ImaUtil.ServerSideAdInsertionConfiguration config, + ServerSideAdInsertionConfiguration config, StreamPlayer streamPlayer) { StreamDisplayContainer container = ImaSdkFactory.createStreamDisplayContainer( diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java index 9c24e62009..bd19af60f2 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaUtil.java @@ -166,6 +166,7 @@ import java.util.Set; @Nullable public final AdEvent.AdEventListener applicationAdEventListener; @Nullable public final AdErrorEvent.AdErrorListener applicationAdErrorListener; public final ImmutableList companionAdSlots; + public final boolean focusSkipButtonWhenAvailable; public final boolean debugModeEnabled; public ServerSideAdInsertionConfiguration( @@ -174,12 +175,14 @@ import java.util.Set; @Nullable AdEvent.AdEventListener applicationAdEventListener, @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener, List companionAdSlots, + boolean focusSkipButtonWhenAvailable, boolean debugModeEnabled) { this.imaSdkSettings = imaSdkSettings; this.adViewProvider = adViewProvider; this.applicationAdEventListener = applicationAdEventListener; this.applicationAdErrorListener = applicationAdErrorListener; this.companionAdSlots = ImmutableList.copyOf(companionAdSlots); + this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; this.debugModeEnabled = debugModeEnabled; } } @@ -267,6 +270,7 @@ import java.util.Set; } /** Returns a human-readable representation of a video progress update. */ + @SuppressWarnings("RestrictedApi") // VideoProgressUpdate.equals() is annotated as hidden. public static String getStringForVideoProgressUpdate(VideoProgressUpdate videoProgressUpdate) { if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { return "not ready"; diff --git a/libraries/exoplayer_rtsp/README.md b/libraries/exoplayer_rtsp/README.md index 04bfa67662..f83220fe1d 100644 --- a/libraries/exoplayer_rtsp/README.md +++ b/libraries/exoplayer_rtsp/README.md @@ -27,3 +27,11 @@ and convert a RTSP `MediaItem` into a `RtspMediaSource` for playback. For advanced playback use cases, applications can build `RtspMediaSource` instances and pass them directly to the player. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer_smoothstreaming/README.md b/libraries/exoplayer_smoothstreaming/README.md index 1650a22881..076985bee7 100644 --- a/libraries/exoplayer_smoothstreaming/README.md +++ b/libraries/exoplayer_smoothstreaming/README.md @@ -32,3 +32,11 @@ content. For advanced playback use cases, applications can build `SsMediaSource` instances and pass them directly to the player. For advanced download use cases, `SsDownloader` can be used directly. + +## Links + + + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/exoplayer_workmanager/README.md b/libraries/exoplayer_workmanager/README.md index ed3d5dd3a5..7fa6c6d267 100644 --- a/libraries/exoplayer_workmanager/README.md +++ b/libraries/exoplayer_workmanager/README.md @@ -19,3 +19,9 @@ Alternatively, you can clone this GitHub project and depend on the module locally. Instructions for doing this can be found in the [top level README][]. [top level README]: ../../README.md + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/extractor/README.md b/libraries/extractor/README.md index cd15318192..22b82fa5b5 100644 --- a/libraries/extractor/README.md +++ b/libraries/extractor/README.md @@ -2,3 +2,9 @@ Provides media container extractors and related utilities. Application code will not normally need to depend on this module directly. + +## Links + +* [Javadoc][] + +[Javadoc]: https://developer.android.com/reference/androidx/media3/packages diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/AacUtil.java b/libraries/extractor/src/main/java/androidx/media3/extractor/AacUtil.java index 9c72d87966..82f561561b 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/AacUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/AacUtil.java @@ -332,11 +332,16 @@ public final class AacUtil { int samplingFrequency; int frequencyIndex = bitArray.readBits(4); if (frequencyIndex == AUDIO_SPECIFIC_CONFIG_FREQUENCY_INDEX_ARBITRARY) { + if (bitArray.bitsLeft() < 24) { + throw ParserException.createForMalformedContainer( + /* message= */ "AAC header insufficient data", /* cause= */ null); + } samplingFrequency = bitArray.readBits(24); } else if (frequencyIndex < 13) { samplingFrequency = AUDIO_SPECIFIC_CONFIG_SAMPLING_RATE_TABLE[frequencyIndex]; } else { - throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null); + throw ParserException.createForMalformedContainer( + /* message= */ "AAC header wrong Sampling Frequency Index", /* cause= */ null); } return samplingFrequency; } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/Ac3Util.java b/libraries/extractor/src/main/java/androidx/media3/extractor/Ac3Util.java index e4a61f3e0b..9fe613aac2 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/Ac3Util.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/Ac3Util.java @@ -80,6 +80,8 @@ public final class Ac3Util { public final int frameSize; /** Number of audio samples in the frame. */ public final int sampleCount; + /** The bitrate of audio samples. */ + public final int bitrate; private SyncFrameInfo( @Nullable String mimeType, @@ -87,13 +89,15 @@ public final class Ac3Util { int channelCount, int sampleRate, int frameSize, - int sampleCount) { + int sampleCount, + int bitrate) { this.mimeType = mimeType; this.streamType = streamType; this.channelCount = channelCount; this.sampleRate = sampleRate; this.frameSize = frameSize; this.sampleCount = sampleCount; + this.bitrate = bitrate; } } @@ -151,13 +155,21 @@ public final class Ac3Util { */ public static Format parseAc3AnnexFFormat( ParsableByteArray data, String trackId, String language, @Nullable DrmInitData drmInitData) { - int fscod = (data.readUnsignedByte() & 0xC0) >> 6; + ParsableBitArray dataBitArray = new ParsableBitArray(); + dataBitArray.reset(data); + + int fscod = dataBitArray.readBits(2); int sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; - int nextByte = data.readUnsignedByte(); - int channelCount = CHANNEL_COUNT_BY_ACMOD[(nextByte & 0x38) >> 3]; - if ((nextByte & 0x04) != 0) { // lfeon + dataBitArray.skipBits(8); // bsid, bsmod + int channelCount = CHANNEL_COUNT_BY_ACMOD[dataBitArray.readBits(3)]; // acmod + if (dataBitArray.readBits(1) != 0) { // lfeon channelCount++; } + int halfFrmsizecod = dataBitArray.readBits(5); // bit_rate_code + int constantBitrate = BITRATE_BY_HALF_FRMSIZECOD[halfFrmsizecod] * 1000; + // Update data position + dataBitArray.byteAlign(); + data.setPosition(dataBitArray.getBytePosition()); return new Format.Builder() .setId(trackId) .setSampleMimeType(MimeTypes.AUDIO_AC3) @@ -165,6 +177,8 @@ public final class Ac3Util { .setSampleRate(sampleRate) .setDrmInitData(drmInitData) .setLanguage(language) + .setAverageBitrate(constantBitrate) + .setPeakBitrate(constantBitrate) .build(); } @@ -180,35 +194,45 @@ public final class Ac3Util { */ public static Format parseEAc3AnnexFFormat( ParsableByteArray data, String trackId, String language, @Nullable DrmInitData drmInitData) { - data.skipBytes(2); // data_rate, num_ind_sub + ParsableBitArray dataBitArray = new ParsableBitArray(); + dataBitArray.reset(data); + + int peakBitrate = dataBitArray.readBits(13) * 1000; // data_rate + dataBitArray.skipBits(3); // num_ind_sub // Read the first independent substream. - int fscod = (data.readUnsignedByte() & 0xC0) >> 6; + int fscod = dataBitArray.readBits(2); int sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; - int nextByte = data.readUnsignedByte(); - int channelCount = CHANNEL_COUNT_BY_ACMOD[(nextByte & 0x0E) >> 1]; - if ((nextByte & 0x01) != 0) { // lfeon + dataBitArray.skipBits(10); // bsid, reserved, asvc, bsmod + int channelCount = CHANNEL_COUNT_BY_ACMOD[dataBitArray.readBits(3)]; // acmod + if (dataBitArray.readBits(1) != 0) { // lfeon channelCount++; } // Read the first dependent substream. - nextByte = data.readUnsignedByte(); - int numDepSub = ((nextByte & 0x1E) >> 1); + dataBitArray.skipBits(3); // reserved + int numDepSub = dataBitArray.readBits(4); // num_dep_sub + dataBitArray.skipBits(1); // numDepSub > 0 ? LFE2 : reserved if (numDepSub > 0) { - int lowByteChanLoc = data.readUnsignedByte(); + dataBitArray.skipBytes(6); // other channel configurations // Read Lrs/Rrs pair // TODO: Read other channel configuration - if ((lowByteChanLoc & 0x02) != 0) { + if (dataBitArray.readBits(1) != 0) { channelCount += 2; } + dataBitArray.skipBits(1); // Lc/Rc pair } + String mimeType = MimeTypes.AUDIO_E_AC3; - if (data.bytesLeft() > 0) { - nextByte = data.readUnsignedByte(); - if ((nextByte & 0x01) != 0) { // flag_ec3_extension_type_a + if (dataBitArray.bitsLeft() > 7) { + dataBitArray.skipBits(7); // reserved + if (dataBitArray.readBits(1) != 0) { // flag_ec3_extension_type_a mimeType = MimeTypes.AUDIO_E_AC3_JOC; } } + // Update data position + dataBitArray.byteAlign(); + data.setPosition(dataBitArray.getBytePosition()); return new Format.Builder() .setId(trackId) .setSampleMimeType(mimeType) @@ -216,6 +240,7 @@ public final class Ac3Util { .setSampleRate(sampleRate) .setDrmInitData(drmInitData) .setLanguage(language) + .setPeakBitrate(peakBitrate) .build(); } @@ -240,6 +265,7 @@ public final class Ac3Util { int sampleCount; boolean lfeon; int channelCount; + int bitrate; if (isEac3) { // Subsection E.1.2. data.skipBits(16); // syncword @@ -272,6 +298,7 @@ public final class Ac3Util { sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; } sampleCount = AUDIO_SAMPLES_PER_AUDIO_BLOCK * audioBlocks; + bitrate = calculateEac3Bitrate(frameSize, sampleRate, audioBlocks); acmod = data.readBits(3); lfeon = data.readBit(); channelCount = CHANNEL_COUNT_BY_ACMOD[acmod] + (lfeon ? 1 : 0); @@ -427,6 +454,7 @@ public final class Ac3Util { mimeType = null; } int frmsizecod = data.readBits(6); + bitrate = BITRATE_BY_HALF_FRMSIZECOD[frmsizecod / 2] * 1000; frameSize = getAc3SyncframeSize(fscod, frmsizecod); data.skipBits(5 + 3); // bsid, bsmod acmod = data.readBits(3); @@ -446,7 +474,7 @@ public final class Ac3Util { channelCount = CHANNEL_COUNT_BY_ACMOD[acmod] + (lfeon ? 1 : 0); } return new SyncFrameInfo( - mimeType, streamType, channelCount, sampleRate, frameSize, sampleCount); + mimeType, streamType, channelCount, sampleRate, frameSize, sampleCount, bitrate); } /** @@ -568,5 +596,15 @@ public final class Ac3Util { } } + /** + * Derived from the formula defined in F.6.2.2 to calculate data_rate for the (E-)AC3 bitstream. + * Note: The formula is based on frmsiz read from the spec. We already do some modifications to it + * when deriving frameSize from the read value. The formula used here is adapted to accommodate + * that modification. + */ + private static int calculateEac3Bitrate(int frameSize, int sampleRate, int audioBlocks) { + return (frameSize * sampleRate) / (audioBlocks * 32); + } + private Ac3Util() {} } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java b/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java index 0b4e9da76d..992221c889 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/DefaultExtractorsFactory.java @@ -22,6 +22,7 @@ import android.net.Uri; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.FileTypes; +import androidx.media3.common.Format; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; import androidx.media3.common.util.TimestampAdjuster; @@ -44,6 +45,7 @@ import androidx.media3.extractor.ts.PsExtractor; import androidx.media3.extractor.ts.TsExtractor; import androidx.media3.extractor.ts.TsPayloadReader; import androidx.media3.extractor.wav.WavExtractor; +import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -128,11 +130,13 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { private @Mp3Extractor.Flags int mp3Flags; private @TsExtractor.Mode int tsMode; private @DefaultTsPayloadReaderFactory.Flags int tsFlags; + private ImmutableList tsSubtitleFormats; private int tsTimestampSearchBytes; public DefaultExtractorsFactory() { tsMode = TsExtractor.MODE_SINGLE_PMT; tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES; + tsSubtitleFormats = ImmutableList.of(); } /** @@ -303,6 +307,20 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { return this; } + /** + * Sets a list of subtitle formats to pass to the {@link DefaultTsPayloadReaderFactory} used by + * {@link TsExtractor} instances created by the factory. + * + * @see DefaultTsPayloadReaderFactory#DefaultTsPayloadReaderFactory(int, List) + * @param subtitleFormats The subtitle formats. + * @return The factory, for convenience. + */ + @CanIgnoreReturnValue + public synchronized DefaultExtractorsFactory setTsSubtitleFormats(List subtitleFormats) { + tsSubtitleFormats = ImmutableList.copyOf(subtitleFormats); + return this; + } + /** * Sets the number of bytes searched to find a timestamp for {@link TsExtractor} instances created * by the factory. @@ -416,7 +434,12 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { extractors.add(new PsExtractor()); break; case FileTypes.TS: - extractors.add(new TsExtractor(tsMode, tsFlags, tsTimestampSearchBytes)); + extractors.add( + new TsExtractor( + tsMode, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(tsFlags, tsSubtitleFormats), + tsTimestampSearchBytes)); break; case FileTypes.WAV: extractors.add(new WavExtractor()); diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/OpusUtil.java b/libraries/extractor/src/main/java/androidx/media3/extractor/OpusUtil.java index 81a1adedd1..a1ecff461e 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/OpusUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/OpusUtil.java @@ -29,6 +29,9 @@ public class OpusUtil { /** Opus streams are always 48000 Hz. */ public static final int SAMPLE_RATE = 48_000; + /** Maximum achievable Opus bitrate. */ + public static final int MAX_BYTES_PER_SECOND = 510 * 1000 / 8; // See RFC 6716. Section 2.1.1 + private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; private static final int FULL_CODEC_INITIALIZATION_DATA_BUFFER_COUNT = 3; @@ -63,6 +66,62 @@ public class OpusUtil { return initializationData; } + /** + * Returns the number of audio samples in the given audio packet. + * + *

    The buffer's position is not modified. + * + * @param buffer The audio packet. + * @return Returns the number of audio samples in the packet. + */ + public static int parsePacketAudioSampleCount(ByteBuffer buffer) { + long packetDurationUs = + getPacketDurationUs(buffer.get(0), buffer.limit() > 1 ? buffer.get(1) : 0); + return (int) (packetDurationUs * SAMPLE_RATE / C.MICROS_PER_SECOND); + } + + /** + * Returns the duration of the given audio packet. + * + * @param buffer The audio packet. + * @return Returns the duration of the given audio packet, in microseconds. + */ + public static long getPacketDurationUs(byte[] buffer) { + return getPacketDurationUs(buffer[0], buffer.length > 1 ? buffer[1] : 0); + } + + private static long getPacketDurationUs(byte packetByte0, byte packetByte1) { + // See RFC6716, Sections 3.1 and 3.2. + int toc = packetByte0 & 0xFF; + int frames; + switch (toc & 0x3) { + case 0: + frames = 1; + break; + case 1: + case 2: + frames = 2; + break; + default: + frames = packetByte1 & 0x3F; + break; + } + + int config = toc >> 3; + int length = config & 0x3; + int frameDurationUs; + if (config >= 16) { + frameDurationUs = 2500 << length; + } else if (config >= 12) { + frameDurationUs = 10000 << (length & 0x1); + } else if (length == 3) { + frameDurationUs = 60000; + } else { + frameDurationUs = 10000 << length; + } + return (long) frames * frameDurationUs; + } + private static int getPreSkipSamples(byte[] header) { return ((header[11] & 0xFF) << 8) | (header[10] & 0xFF); } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Decoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Decoder.java index 4dacad9b75..0a6f8bb3b4 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Decoder.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/Id3Decoder.java @@ -26,8 +26,10 @@ import androidx.media3.common.util.Util; import androidx.media3.extractor.metadata.MetadataInputBuffer; import androidx.media3.extractor.metadata.SimpleMetadataDecoder; import com.google.common.base.Ascii; -import java.io.UnsupportedEncodingException; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; import java.nio.ByteBuffer; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -435,100 +437,110 @@ public final class Id3Decoder extends SimpleMetadataDecoder { + frameSize); } return frame; - } catch (UnsupportedEncodingException e) { - Log.w(TAG, "Unsupported character encoding"); - return null; } finally { id3Data.setPosition(nextFramePosition); } } @Nullable - private static TextInformationFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static TextInformationFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) { if (frameSize < 1) { // Frame is malformed. return null; } int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); - int descriptionEndIndex = indexOfEos(data, 0, encoding); - String description = new String(data, 0, descriptionEndIndex, charset); + int descriptionEndIndex = indexOfTerminator(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, getCharset(encoding)); - int valueStartIndex = descriptionEndIndex + delimiterLength(encoding); - int valueEndIndex = indexOfEos(data, valueStartIndex, encoding); - String value = decodeStringIfValid(data, valueStartIndex, valueEndIndex, charset); - - return new TextInformationFrame("TXXX", description, value); + ImmutableList values = + decodeTextInformationFrameValues( + data, encoding, descriptionEndIndex + delimiterLength(encoding)); + return new TextInformationFrame("TXXX", description, values); } @Nullable private static TextInformationFrame decodeTextInformationFrame( - ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException { + ParsableByteArray id3Data, int frameSize, String id) { if (frameSize < 1) { // Frame is malformed. return null; } int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); - int valueEndIndex = indexOfEos(data, 0, encoding); - String value = new String(data, 0, valueEndIndex, charset); + ImmutableList values = decodeTextInformationFrameValues(data, encoding, 0); + return new TextInformationFrame(id, null, values); + } - return new TextInformationFrame(id, null, value); + private static ImmutableList decodeTextInformationFrameValues( + byte[] data, final int encoding, final int index) { + if (index >= data.length) { + return ImmutableList.of(""); + } + + ImmutableList.Builder values = ImmutableList.builder(); + int valueStartIndex = index; + int valueEndIndex = indexOfTerminator(data, valueStartIndex, encoding); + while (valueStartIndex < valueEndIndex) { + String value = + new String(data, valueStartIndex, valueEndIndex - valueStartIndex, getCharset(encoding)); + values.add(value); + + valueStartIndex = valueEndIndex + delimiterLength(encoding); + valueEndIndex = indexOfTerminator(data, valueStartIndex, encoding); + } + + ImmutableList result = values.build(); + return result.isEmpty() ? ImmutableList.of("") : result; } @Nullable - private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize) { if (frameSize < 1) { // Frame is malformed. return null; } int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); - int descriptionEndIndex = indexOfEos(data, 0, encoding); - String description = new String(data, 0, descriptionEndIndex, charset); + int descriptionEndIndex = indexOfTerminator(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, getCharset(encoding)); int urlStartIndex = descriptionEndIndex + delimiterLength(encoding); int urlEndIndex = indexOfZeroByte(data, urlStartIndex); - String url = decodeStringIfValid(data, urlStartIndex, urlEndIndex, "ISO-8859-1"); + String url = decodeStringIfValid(data, urlStartIndex, urlEndIndex, Charsets.ISO_8859_1); return new UrlLinkFrame("WXXX", description, url); } private static UrlLinkFrame decodeUrlLinkFrame( - ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException { + ParsableByteArray id3Data, int frameSize, String id) { byte[] data = new byte[frameSize]; id3Data.readBytes(data, 0, frameSize); int urlEndIndex = indexOfZeroByte(data, 0); - String url = new String(data, 0, urlEndIndex, "ISO-8859-1"); + String url = new String(data, 0, urlEndIndex, Charsets.ISO_8859_1); return new UrlLinkFrame(id, null, url); } - private static PrivFrame decodePrivFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static PrivFrame decodePrivFrame(ParsableByteArray id3Data, int frameSize) { byte[] data = new byte[frameSize]; id3Data.readBytes(data, 0, frameSize); int ownerEndIndex = indexOfZeroByte(data, 0); - String owner = new String(data, 0, ownerEndIndex, "ISO-8859-1"); + String owner = new String(data, 0, ownerEndIndex, Charsets.ISO_8859_1); int privateDataStartIndex = ownerEndIndex + 1; byte[] privateData = copyOfRangeIfValid(data, privateDataStartIndex, data.length); @@ -536,23 +548,22 @@ public final class Id3Decoder extends SimpleMetadataDecoder { return new PrivFrame(owner, privateData); } - private static GeobFrame decodeGeobFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static GeobFrame decodeGeobFrame(ParsableByteArray id3Data, int frameSize) { int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); + Charset charset = getCharset(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); int mimeTypeEndIndex = indexOfZeroByte(data, 0); - String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1"); + String mimeType = new String(data, 0, mimeTypeEndIndex, Charsets.ISO_8859_1); int filenameStartIndex = mimeTypeEndIndex + 1; - int filenameEndIndex = indexOfEos(data, filenameStartIndex, encoding); + int filenameEndIndex = indexOfTerminator(data, filenameStartIndex, encoding); String filename = decodeStringIfValid(data, filenameStartIndex, filenameEndIndex, charset); int descriptionStartIndex = filenameEndIndex + delimiterLength(encoding); - int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding); + int descriptionEndIndex = indexOfTerminator(data, descriptionStartIndex, encoding); String description = decodeStringIfValid(data, descriptionStartIndex, descriptionEndIndex, charset); @@ -563,10 +574,9 @@ public final class Id3Decoder extends SimpleMetadataDecoder { } private static ApicFrame decodeApicFrame( - ParsableByteArray id3Data, int frameSize, int majorVersion) - throws UnsupportedEncodingException { + ParsableByteArray id3Data, int frameSize, int majorVersion) { int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); + Charset charset = getCharset(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); @@ -575,13 +585,13 @@ public final class Id3Decoder extends SimpleMetadataDecoder { int mimeTypeEndIndex; if (majorVersion == 2) { mimeTypeEndIndex = 2; - mimeType = "image/" + Ascii.toLowerCase(new String(data, 0, 3, "ISO-8859-1")); + mimeType = "image/" + Ascii.toLowerCase(new String(data, 0, 3, Charsets.ISO_8859_1)); if ("image/jpg".equals(mimeType)) { mimeType = "image/jpeg"; } } else { mimeTypeEndIndex = indexOfZeroByte(data, 0); - mimeType = Ascii.toLowerCase(new String(data, 0, mimeTypeEndIndex, "ISO-8859-1")); + mimeType = Ascii.toLowerCase(new String(data, 0, mimeTypeEndIndex, Charsets.ISO_8859_1)); if (mimeType.indexOf('/') == -1) { mimeType = "image/" + mimeType; } @@ -590,7 +600,7 @@ public final class Id3Decoder extends SimpleMetadataDecoder { int pictureType = data[mimeTypeEndIndex + 1] & 0xFF; int descriptionStartIndex = mimeTypeEndIndex + 2; - int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding); + int descriptionEndIndex = indexOfTerminator(data, descriptionStartIndex, encoding); String description = new String( data, descriptionStartIndex, descriptionEndIndex - descriptionStartIndex, charset); @@ -602,15 +612,14 @@ public final class Id3Decoder extends SimpleMetadataDecoder { } @Nullable - private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize) { if (frameSize < 4) { // Frame is malformed. return null; } int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); + Charset charset = getCharset(encoding); byte[] data = new byte[3]; id3Data.readBytes(data, 0, 3); @@ -619,11 +628,11 @@ public final class Id3Decoder extends SimpleMetadataDecoder { data = new byte[frameSize - 4]; id3Data.readBytes(data, 0, frameSize - 4); - int descriptionEndIndex = indexOfEos(data, 0, encoding); + int descriptionEndIndex = indexOfTerminator(data, 0, encoding); String description = new String(data, 0, descriptionEndIndex, charset); int textStartIndex = descriptionEndIndex + delimiterLength(encoding); - int textEndIndex = indexOfEos(data, textStartIndex, encoding); + int textEndIndex = indexOfTerminator(data, textStartIndex, encoding); String text = decodeStringIfValid(data, textStartIndex, textEndIndex, charset); return new CommentFrame(language, description, text); @@ -635,13 +644,15 @@ public final class Id3Decoder extends SimpleMetadataDecoder { int majorVersion, boolean unsignedIntFrameSizeHack, int frameHeaderSize, - @Nullable FramePredicate framePredicate) - throws UnsupportedEncodingException { + @Nullable FramePredicate framePredicate) { int framePosition = id3Data.getPosition(); int chapterIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition); String chapterId = new String( - id3Data.getData(), framePosition, chapterIdEndIndex - framePosition, "ISO-8859-1"); + id3Data.getData(), + framePosition, + chapterIdEndIndex - framePosition, + Charsets.ISO_8859_1); id3Data.setPosition(chapterIdEndIndex + 1); int startTime = id3Data.readInt(); @@ -676,13 +687,15 @@ public final class Id3Decoder extends SimpleMetadataDecoder { int majorVersion, boolean unsignedIntFrameSizeHack, int frameHeaderSize, - @Nullable FramePredicate framePredicate) - throws UnsupportedEncodingException { + @Nullable FramePredicate framePredicate) { int framePosition = id3Data.getPosition(); int elementIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition); String elementId = new String( - id3Data.getData(), framePosition, elementIdEndIndex - framePosition, "ISO-8859-1"); + id3Data.getData(), + framePosition, + elementIdEndIndex - framePosition, + Charsets.ISO_8859_1); id3Data.setPosition(elementIdEndIndex + 1); int ctocFlags = id3Data.readUnsignedByte(); @@ -694,7 +707,8 @@ public final class Id3Decoder extends SimpleMetadataDecoder { for (int i = 0; i < childCount; i++) { int startIndex = id3Data.getPosition(); int endIndex = indexOfZeroByte(id3Data.getData(), startIndex); - children[i] = new String(id3Data.getData(), startIndex, endIndex - startIndex, "ISO-8859-1"); + children[i] = + new String(id3Data.getData(), startIndex, endIndex - startIndex, Charsets.ISO_8859_1); id3Data.setPosition(endIndex + 1); } @@ -773,23 +787,18 @@ public final class Id3Decoder extends SimpleMetadataDecoder { return length; } - /** - * Maps encoding byte from ID3v2 frame to a Charset. - * - * @param encodingByte The value of encoding byte from ID3v2 frame. - * @return Charset name. - */ - private static String getCharsetName(int encodingByte) { + /** Maps encoding byte from ID3v2 frame to a {@link Charset}. */ + private static Charset getCharset(int encodingByte) { switch (encodingByte) { case ID3_TEXT_ENCODING_UTF_16: - return "UTF-16"; + return Charsets.UTF_16; case ID3_TEXT_ENCODING_UTF_16BE: - return "UTF-16BE"; + return Charsets.UTF_16BE; case ID3_TEXT_ENCODING_UTF_8: - return "UTF-8"; + return Charsets.UTF_8; case ID3_TEXT_ENCODING_ISO_8859_1: default: - return "ISO-8859-1"; + return Charsets.ISO_8859_1; } } @@ -800,7 +809,7 @@ public final class Id3Decoder extends SimpleMetadataDecoder { : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); } - private static int indexOfEos(byte[] data, int fromIndex, int encoding) { + private static int indexOfTerminator(byte[] data, int fromIndex, int encoding) { int terminationPos = indexOfZeroByte(data, fromIndex); // For single byte encoding charsets, we're done. @@ -852,21 +861,19 @@ public final class Id3Decoder extends SimpleMetadataDecoder { /** * Returns a string obtained by decoding the specified range of {@code data} using the specified - * {@code charsetName}. An empty string is returned if the range is invalid. + * {@code charset}. An empty string is returned if the range is invalid. * * @param data The array from which to decode the string. * @param from The start of the range. * @param to The end of the range (exclusive). - * @param charsetName The name of the Charset to use. + * @param charset The {@link Charset} to use. * @return The decoded string, or an empty string if the range is invalid. - * @throws UnsupportedEncodingException If the Charset is not supported. */ - private static String decodeStringIfValid(byte[] data, int from, int to, String charsetName) - throws UnsupportedEncodingException { + private static String decodeStringIfValid(byte[] data, int from, int to, Charset charset) { if (to <= from || to > data.length) { return ""; } - return new String(data, from, to - from, charsetName); + return new String(data, from, to - from, charset); } private static final class Id3Header { diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java index c33ef14e31..04d3d17463 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/metadata/id3/TextInformationFrame.java @@ -15,7 +15,8 @@ */ package androidx.media3.extractor.metadata.id3; -import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; import android.os.Parcel; import android.os.Parcelable; @@ -23,6 +24,8 @@ import androidx.annotation.Nullable; import androidx.media3.common.MediaMetadata; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.InlineMe; import java.util.ArrayList; import java.util.List; @@ -31,42 +34,69 @@ import java.util.List; public final class TextInformationFrame extends Id3Frame { @Nullable public final String description; - public final String value; - public TextInformationFrame(String id, @Nullable String description, String value) { + /** + * @deprecated Use the first element of {@link #values} instead. + */ + @Deprecated public final String value; + + /** The text values of this frame. Will always have at least one element. */ + public final ImmutableList values; + + public TextInformationFrame(String id, @Nullable String description, List values) { super(id); + checkArgument(!values.isEmpty()); + this.description = description; - this.value = value; + this.values = ImmutableList.copyOf(values); + this.value = this.values.get(0); } - /* package */ TextInformationFrame(Parcel in) { - super(castNonNull(in.readString())); - description = in.readString(); - value = castNonNull(in.readString()); + /** + * @deprecated Use {@code TextInformationFrame(String id, String description, String[] values} + * instead + */ + @Deprecated + @InlineMe( + replacement = "this(id, description, ImmutableList.of(value))", + imports = "com.google.common.collect.ImmutableList") + public TextInformationFrame(String id, @Nullable String description, String value) { + this(id, description, ImmutableList.of(value)); } + private TextInformationFrame(Parcel in) { + this( + checkNotNull(in.readString()), + in.readString(), + ImmutableList.copyOf(checkNotNull(in.createStringArray()))); + } + + /** + * Uses the first element in {@link #values} to set the relevant field in {@link MediaMetadata} + * (as determined by {@link #id}). + */ @Override public void populateMediaMetadata(MediaMetadata.Builder builder) { switch (id) { case "TT2": case "TIT2": - builder.setTitle(value); + builder.setTitle(values.get(0)); break; case "TP1": case "TPE1": - builder.setArtist(value); + builder.setArtist(values.get(0)); break; case "TP2": case "TPE2": - builder.setAlbumArtist(value); + builder.setAlbumArtist(values.get(0)); break; case "TAL": case "TALB": - builder.setAlbumTitle(value); + builder.setAlbumTitle(values.get(0)); break; case "TRK": case "TRCK": - String[] trackNumbers = Util.split(value, "/"); + String[] trackNumbers = Util.split(values.get(0), "/"); try { int trackNumber = Integer.parseInt(trackNumbers[0]); @Nullable @@ -80,7 +110,7 @@ public final class TextInformationFrame extends Id3Frame { case "TYE": case "TYER": try { - builder.setRecordingYear(Integer.parseInt(value)); + builder.setRecordingYear(Integer.parseInt(values.get(0))); } catch (NumberFormatException e) { // Do nothing, invalid input. } @@ -88,15 +118,16 @@ public final class TextInformationFrame extends Id3Frame { case "TDA": case "TDAT": try { - int month = Integer.parseInt(value.substring(2, 4)); - int day = Integer.parseInt(value.substring(0, 2)); + String date = values.get(0); + int month = Integer.parseInt(date.substring(2, 4)); + int day = Integer.parseInt(date.substring(0, 2)); builder.setRecordingMonth(month).setRecordingDay(day); } catch (NumberFormatException | StringIndexOutOfBoundsException e) { // Do nothing, invalid input. } break; case "TDRC": - List recordingDate = parseId3v2point4TimestampFrameForDate(value); + List recordingDate = parseId3v2point4TimestampFrameForDate(values.get(0)); switch (recordingDate.size()) { case 3: builder.setRecordingDay(recordingDate.get(2)); @@ -114,7 +145,7 @@ public final class TextInformationFrame extends Id3Frame { } break; case "TDRL": - List releaseDate = parseId3v2point4TimestampFrameForDate(value); + List releaseDate = parseId3v2point4TimestampFrameForDate(values.get(0)); switch (releaseDate.size()) { case 3: builder.setReleaseDay(releaseDate.get(2)); @@ -133,15 +164,15 @@ public final class TextInformationFrame extends Id3Frame { break; case "TCM": case "TCOM": - builder.setComposer(value); + builder.setComposer(values.get(0)); break; case "TP3": case "TPE3": - builder.setConductor(value); + builder.setConductor(values.get(0)); break; case "TXT": case "TEXT": - builder.setWriter(value); + builder.setWriter(values.get(0)); break; default: break; @@ -159,7 +190,7 @@ public final class TextInformationFrame extends Id3Frame { TextInformationFrame other = (TextInformationFrame) obj; return Util.areEqual(id, other.id) && Util.areEqual(description, other.description) - && Util.areEqual(value, other.value); + && values.equals(other.values); } @Override @@ -167,13 +198,13 @@ public final class TextInformationFrame extends Id3Frame { int result = 17; result = 31 * result + id.hashCode(); result = 31 * result + (description != null ? description.hashCode() : 0); - result = 31 * result + (value != null ? value.hashCode() : 0); + result = 31 * result + values.hashCode(); return result; } @Override public String toString() { - return id + ": description=" + description + ": value=" + value; + return id + ": description=" + description + ": values=" + values; } // Parcelable implementation. @@ -182,7 +213,7 @@ public final class TextInformationFrame extends Id3Frame { public void writeToParcel(Parcel dest, int flags) { dest.writeString(id); dest.writeString(description); - dest.writeString(value); + dest.writeStringArray(values.toArray(new String[0])); } public static final Parcelable.Creator CREATOR = diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java index 9bd0503ab0..b8ff74a679 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java @@ -1652,8 +1652,8 @@ public class MatroskaExtractor implements Extractor { } /** - * Called by {@link #writeSampleData(ExtractorInput, Track, int)} when the sample has been - * written. Returns the final sample size and resets state for the next sample. + * Called by {@link #writeSampleData(ExtractorInput, Track, int, boolean)} when the sample has + * been written. Returns the final sample size and resets state for the next sample. */ private int finishWriteSampleData() { int sampleSize = sampleBytesWritten; @@ -1661,7 +1661,7 @@ public class MatroskaExtractor implements Extractor { return sampleSize; } - /** Resets state used by {@link #writeSampleData(ExtractorInput, Track, int)}. */ + /** Resets state used by {@link #writeSampleData(ExtractorInput, Track, int, boolean)}. */ private void resetWriteSampleData() { sampleBytesRead = 0; sampleBytesWritten = 0; diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java index 6279da9e63..3c90957f64 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java @@ -594,7 +594,7 @@ public final class Mp3Extractor implements Extractor { Metadata.Entry entry = metadata.get(i); if (entry instanceof TextInformationFrame && ((TextInformationFrame) entry).id.equals("TLEN")) { - return Util.msToUs(Long.parseLong(((TextInformationFrame) entry).value)); + return Util.msToUs(Long.parseLong(((TextInformationFrame) entry).values.get(0))); } } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java index 4543d32819..5e290bc67d 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java @@ -43,6 +43,7 @@ import androidx.media3.extractor.GaplessInfoHolder; import androidx.media3.extractor.HevcConfig; import androidx.media3.extractor.OpusUtil; import androidx.media3.extractor.metadata.mp4.SmtaMetadataEntry; +import androidx.media3.extractor.mp4.Atom.LeafAtom; import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Ints; @@ -308,9 +309,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Pair mdhdData = parseMdhd(checkNotNull(mdia.getLeafAtomOfType(Atom.TYPE_mdhd)).data); + LeafAtom stsd = stbl.getLeafAtomOfType(Atom.TYPE_stsd); + if (stsd == null) { + throw ParserException.createForMalformedContainer( + "Malformed sample table (stbl) missing sample description (stsd)", /* cause= */ null); + } StsdData stsdData = parseStsd( - checkNotNull(stbl.getLeafAtomOfType(Atom.TYPE_stsd)).data, + stsd.data, tkhdData.id, tkhdData.rotationDegrees, mdhdData.second, diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/FragmentedMp4Extractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/FragmentedMp4Extractor.java index 800c601059..4ccca487c8 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/FragmentedMp4Extractor.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/FragmentedMp4Extractor.java @@ -1675,15 +1675,15 @@ public class FragmentedMp4Extractor implements Extractor { } /** - * Advances {@link #firstSampleToOutputIndex} to point to the sync sample before the specified - * seek time in the current fragment. + * Advances {@link #firstSampleToOutputIndex} to point to the sync sample at or before the + * specified seek time in the current fragment. * * @param timeUs The seek time, in microseconds. */ public void seek(long timeUs) { int searchIndex = currentSampleIndex; while (searchIndex < fragment.sampleCount - && fragment.getSamplePresentationTimeUs(searchIndex) < timeUs) { + && fragment.getSamplePresentationTimeUs(searchIndex) <= timeUs) { if (fragment.sampleIsSyncFrameTable[searchIndex]) { firstSampleToOutputIndex = searchIndex; } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java index 4c111af241..023d573e29 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/MetadataUtil.java @@ -31,6 +31,7 @@ import androidx.media3.extractor.metadata.id3.Id3Frame; import androidx.media3.extractor.metadata.id3.InternalFrame; import androidx.media3.extractor.metadata.id3.TextInformationFrame; import androidx.media3.extractor.metadata.mp4.MdtaMetadataEntry; +import com.google.common.collect.ImmutableList; import org.checkerframework.checker.nullness.compatqual.NullableType; /** Utilities for handling metadata in MP4. */ @@ -452,7 +453,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (atomType == Atom.TYPE_data) { data.skipBytes(8); // version (1), flags (3), empty (4) String value = data.readNullTerminatedString(atomSize - 16); - return new TextInformationFrame(id, /* description= */ null, value); + return new TextInformationFrame(id, /* description= */ null, ImmutableList.of(value)); } Log.w(TAG, "Failed to parse text attribute: " + Atom.getAtomTypeString(type)); return null; @@ -484,7 +485,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } if (value >= 0) { return isTextInformationFrame - ? new TextInformationFrame(id, /* description= */ null, Integer.toString(value)) + ? new TextInformationFrame( + id, /* description= */ null, ImmutableList.of(Integer.toString(value))) : new CommentFrame(C.LANGUAGE_UNDETERMINED, id, Integer.toString(value)); } Log.w(TAG, "Failed to parse uint8 attribute: " + Atom.getAtomTypeString(type)); @@ -505,7 +507,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (count > 0) { value += "/" + count; } - return new TextInformationFrame(attributeName, /* description= */ null, value); + return new TextInformationFrame( + attributeName, /* description= */ null, ImmutableList.of(value)); } } Log.w(TAG, "Failed to parse index/count attribute: " + Atom.getAtomTypeString(type)); @@ -521,7 +524,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ? STANDARD_GENRES[genreCode - 1] : null; if (genreString != null) { - return new TextInformationFrame("TCON", /* description= */ null, genreString); + return new TextInformationFrame( + "TCON", /* description= */ null, ImmutableList.of(genreString)); } Log.w(TAG, "Failed to parse standard genre code"); return null; diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/ogg/OpusReader.java b/libraries/extractor/src/main/java/androidx/media3/extractor/ogg/OpusReader.java index 95996f6a80..00e12a7b56 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/ogg/OpusReader.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/ogg/OpusReader.java @@ -54,7 +54,7 @@ import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; @Override protected long preparePayload(ParsableByteArray packet) { - return convertTimeToGranule(getPacketDurationUs(packet.getData())); + return convertTimeToGranule(OpusUtil.getPacketDurationUs(packet.getData())); } @Override @@ -121,42 +121,6 @@ import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; } } - /** - * Returns the duration of the given audio packet. - * - * @param packet Contains audio data. - * @return Returns the duration of the given audio packet. - */ - private long getPacketDurationUs(byte[] packet) { - int toc = packet[0] & 0xFF; - int frames; - switch (toc & 0x3) { - case 0: - frames = 1; - break; - case 1: - case 2: - frames = 2; - break; - default: - frames = packet[1] & 0x3F; - break; - } - - int config = toc >> 3; - int length = config & 0x3; - if (config >= 16) { - length = 2500 << length; - } else if (config >= 12) { - length = 10000 << (length & 0x1); - } else if (length == 3) { - length = 60000; - } else { - length = 10000 << length; - } - return (long) frames * length; - } - /** * Returns true if the given {@link ParsableByteArray} starts with {@code expectedPrefix}. Does * not change the {@link ParsableByteArray#getPosition() position} of {@code packet}. diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java index 1ecc7f425d..6147ff92ad 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java @@ -27,6 +27,8 @@ import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.UnstableApi; import androidx.media3.extractor.text.SimpleSubtitleDecoder; import androidx.media3.extractor.text.Subtitle; +import com.google.common.base.Charsets; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -76,9 +78,10 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { ArrayList cues = new ArrayList<>(); LongArray cueTimesUs = new LongArray(); ParsableByteArray subripData = new ParsableByteArray(data, length); + Charset charset = detectUtfCharset(subripData); @Nullable String currentLine; - while ((currentLine = subripData.readLine()) != null) { + while ((currentLine = subripData.readLine(charset)) != null) { if (currentLine.length() == 0) { // Skip blank lines. continue; @@ -93,7 +96,7 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { } // Read and parse the timing line. - currentLine = subripData.readLine(); + currentLine = subripData.readLine(charset); if (currentLine == null) { Log.w(TAG, "Unexpected end"); break; @@ -111,13 +114,13 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { // Read and parse the text and tags. textBuilder.setLength(0); tags.clear(); - currentLine = subripData.readLine(); + currentLine = subripData.readLine(charset); while (!TextUtils.isEmpty(currentLine)) { if (textBuilder.length() > 0) { textBuilder.append("
    "); } textBuilder.append(processLine(currentLine, tags)); - currentLine = subripData.readLine(); + currentLine = subripData.readLine(charset); } Spanned text = Html.fromHtml(textBuilder.toString()); @@ -140,6 +143,15 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { return new SubripSubtitle(cuesArray, cueTimesUsArray); } + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private Charset detectUtfCharset(ParsableByteArray data) { + @Nullable Charset charset = data.readUtfCharsetFromBom(); + return charset != null ? charset : Charsets.UTF_8; + } + /** * Trims and removes tags from the given line. The removed tags are added to {@code tags}. * diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/tx3g/Tx3gDecoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/tx3g/Tx3gDecoder.java index e0339d8f97..e66888b807 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/tx3g/Tx3gDecoder.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/tx3g/Tx3gDecoder.java @@ -26,6 +26,7 @@ import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.text.Cue; import androidx.media3.common.util.Log; @@ -36,6 +37,7 @@ import androidx.media3.extractor.text.SimpleSubtitleDecoder; import androidx.media3.extractor.text.Subtitle; import androidx.media3.extractor.text.SubtitleDecoderException; import com.google.common.base.Charsets; +import java.nio.charset.Charset; import java.util.List; /** @@ -48,16 +50,12 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { private static final String TAG = "Tx3gDecoder"; - private static final char BOM_UTF16_BE = '\uFEFF'; - private static final char BOM_UTF16_LE = '\uFFFE'; - private static final int TYPE_STYL = 0x7374796c; private static final int TYPE_TBOX = 0x74626f78; private static final String TX3G_SERIF = "Serif"; private static final int SIZE_ATOM_HEADER = 8; private static final int SIZE_SHORT = 2; - private static final int SIZE_BOM_UTF16 = 2; private static final int SIZE_STYLE_RECORD = 12; private static final int FONT_FACE_BOLD = 0x0001; @@ -173,13 +171,11 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { if (textLength == 0) { return ""; } - if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) { - char firstChar = parsableByteArray.peekChar(); - if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) { - return parsableByteArray.readString(textLength, Charsets.UTF_16); - } - } - return parsableByteArray.readString(textLength, Charsets.UTF_8); + int textStartPosition = parsableByteArray.getPosition(); + @Nullable Charset charset = parsableByteArray.readUtfCharsetFromBom(); + int bomSize = parsableByteArray.getPosition() - textStartPosition; + return parsableByteArray.readString( + textLength - bomSize, charset != null ? charset : Charsets.UTF_8); } private void applyStyleRecord(ParsableByteArray parsableByteArray, SpannableStringBuilder cueText) diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Ac3Reader.java b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Ac3Reader.java index 1d80fbca08..02d674c973 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Ac3Reader.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/ts/Ac3Reader.java @@ -22,6 +22,7 @@ import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.ParsableBitArray; import androidx.media3.common.util.ParsableByteArray; @@ -209,14 +210,19 @@ public final class Ac3Reader implements ElementaryStreamReader { || frameInfo.channelCount != format.channelCount || frameInfo.sampleRate != format.sampleRate || !Util.areEqual(frameInfo.mimeType, format.sampleMimeType)) { - format = + Format.Builder formatBuilder = new Format.Builder() .setId(formatId) .setSampleMimeType(frameInfo.mimeType) .setChannelCount(frameInfo.channelCount) .setSampleRate(frameInfo.sampleRate) .setLanguage(language) - .build(); + .setPeakBitrate(frameInfo.bitrate); + // AC3 has constant bitrate, so averageBitrate = peakBitrate + if (MimeTypes.AUDIO_AC3.equals(frameInfo.mimeType)) { + formatBuilder.setAverageBitrate(frameInfo.bitrate); + } + format = formatBuilder.build(); output.format(format); } sampleSize = frameInfo.frameSize; diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/AacUtilTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/AacUtilTest.java new file mode 100644 index 0000000000..f9d71a3cc4 --- /dev/null +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/AacUtilTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2022 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.extractor; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.media3.common.ParserException; +import androidx.media3.common.util.Util; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link AacUtil}. */ +@RunWith(AndroidJUnit4.class) +public final class AacUtilTest { + private static final byte[] AAC_48K_2CH_HEADER = Util.getBytesFromHexString("1190"); + + private static final byte[] NOT_ENOUGH_ARBITRARY_SAMPLING_FREQ_BITS_HEADER = + Util.getBytesFromHexString("1790"); + + private static final byte[] ARBITRARY_SAMPLING_FREQ_BITS_HEADER = + Util.getBytesFromHexString("1780000790"); + + @Test + public void parseAudioSpecificConfig_twoCh48kAac_parsedCorrectly() throws Exception { + AacUtil.Config aac = AacUtil.parseAudioSpecificConfig(AAC_48K_2CH_HEADER); + + assertThat(aac.channelCount).isEqualTo(2); + assertThat(aac.sampleRateHz).isEqualTo(48000); + assertThat(aac.codecs).isEqualTo("mp4a.40.2"); + } + + @Test + public void parseAudioSpecificConfig_arbitrarySamplingFreqHeader_parsedCorrectly() + throws Exception { + AacUtil.Config aac = AacUtil.parseAudioSpecificConfig(ARBITRARY_SAMPLING_FREQ_BITS_HEADER); + assertThat(aac.channelCount).isEqualTo(2); + assertThat(aac.sampleRateHz).isEqualTo(15); + assertThat(aac.codecs).isEqualTo("mp4a.40.2"); + } + + @Test + public void + parseAudioSpecificConfig_arbitrarySamplingFreqHeaderNotEnoughBits_throwsParserException() { + // ISO 14496-3 1.6.2.1 allows for setting of arbitrary sampling frequency, but if the extra + // frequency bits are missing, make sure the code will throw an exception. + assertThrows( + ParserException.class, + () -> AacUtil.parseAudioSpecificConfig(NOT_ENOUGH_ARBITRARY_SAMPLING_FREQ_BITS_HEADER)); + } +} diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/OpusUtilTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/OpusUtilTest.java index 1bc6b3431c..48c74327c2 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/OpusUtilTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/OpusUtilTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.extractor; +import static androidx.media3.common.util.Util.getBytesFromHexString; import static com.google.common.truth.Truth.assertThat; import androidx.media3.common.C; @@ -41,8 +42,9 @@ public final class OpusUtilTest { buildNativeOrderByteArray(sampleCountToNanoseconds(DEFAULT_SEEK_PRE_ROLL_SAMPLES)); @Test - public void buildInitializationData() { + public void buildInitializationData_returnsExpectedHeaderWithPreSkipAndPreRoll() { List initializationData = OpusUtil.buildInitializationData(HEADER); + assertThat(initializationData).hasSize(3); assertThat(initializationData.get(0)).isEqualTo(HEADER); assertThat(initializationData.get(1)).isEqualTo(HEADER_PRE_SKIP_BYTES); @@ -50,11 +52,576 @@ public final class OpusUtilTest { } @Test - public void getChannelCount() { + public void getChannelCount_returnsChannelCount() { int channelCount = OpusUtil.getChannelCount(HEADER); + assertThat(channelCount).isEqualTo(2); } + @Test + public void getPacketDurationUs_code0_returnsExpectedDuration() { + long config0DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("04")); + long config1DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("0C")); + long config2DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("14")); + long config3DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("1C")); + long config4DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("24")); + long config5DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("2C")); + long config6DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("34")); + long config7DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("3C")); + long config8DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("44")); + long config9DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("4C")); + long config10DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("54")); + long config11DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("5C")); + long config12DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("64")); + long config13DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("6C")); + long config14DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("74")); + long config15DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("7C")); + long config16DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("84")); + long config17DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("8C")); + long config18DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("94")); + long config19DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("9C")); + long config20DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("A4")); + long config21DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("AC")); + long config22DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("B4")); + long config23DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("BC")); + long config24DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("C4")); + long config25DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("CC")); + long config26DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("D4")); + long config27DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("DC")); + long config28DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("E4")); + long config29DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("EC")); + long config30DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("F4")); + long config31DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("FC")); + + assertThat(config0DurationUs).isEqualTo(10_000); + assertThat(config1DurationUs).isEqualTo(20_000); + assertThat(config2DurationUs).isEqualTo(40_000); + assertThat(config3DurationUs).isEqualTo(60_000); + assertThat(config4DurationUs).isEqualTo(10_000); + assertThat(config5DurationUs).isEqualTo(20_000); + assertThat(config6DurationUs).isEqualTo(40_000); + assertThat(config7DurationUs).isEqualTo(60_000); + assertThat(config8DurationUs).isEqualTo(10_000); + assertThat(config9DurationUs).isEqualTo(20_000); + assertThat(config10DurationUs).isEqualTo(40_000); + assertThat(config11DurationUs).isEqualTo(60_000); + assertThat(config12DurationUs).isEqualTo(10_000); + assertThat(config13DurationUs).isEqualTo(20_000); + assertThat(config14DurationUs).isEqualTo(10_000); + assertThat(config15DurationUs).isEqualTo(20_000); + assertThat(config16DurationUs).isEqualTo(2_500); + assertThat(config17DurationUs).isEqualTo(5_000); + assertThat(config18DurationUs).isEqualTo(10_000); + assertThat(config19DurationUs).isEqualTo(20_000); + assertThat(config20DurationUs).isEqualTo(2_500); + assertThat(config21DurationUs).isEqualTo(5_000); + assertThat(config22DurationUs).isEqualTo(10_000); + assertThat(config23DurationUs).isEqualTo(20_000); + assertThat(config24DurationUs).isEqualTo(2_500); + assertThat(config25DurationUs).isEqualTo(5_000); + assertThat(config26DurationUs).isEqualTo(10_000); + assertThat(config27DurationUs).isEqualTo(20_000); + assertThat(config28DurationUs).isEqualTo(2_500); + assertThat(config29DurationUs).isEqualTo(5_000); + assertThat(config30DurationUs).isEqualTo(10_000); + assertThat(config31DurationUs).isEqualTo(20_000); + } + + @Test + public void getPacketDurationUs_code1_returnsExpectedDuration() { + long config0DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("05")); + long config1DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("0D")); + long config2DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("15")); + long config3DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("1D")); + long config4DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("25")); + long config5DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("2D")); + long config6DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("35")); + long config7DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("3D")); + long config8DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("45")); + long config9DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("4D")); + long config10DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("55")); + long config11DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("5D")); + long config12DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("65")); + long config13DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("6D")); + long config14DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("75")); + long config15DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("7D")); + long config16DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("85")); + long config17DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("8D")); + long config18DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("95")); + long config19DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("9D")); + long config20DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("A5")); + long config21DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("AD")); + long config22DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("B5")); + long config23DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("BD")); + long config24DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("C5")); + long config25DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("CD")); + long config26DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("D5")); + long config27DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("DD")); + long config28DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("E5")); + long config29DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("ED")); + long config30DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("F5")); + long config31DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("FD")); + + assertThat(config0DurationUs).isEqualTo(20_000); + assertThat(config1DurationUs).isEqualTo(40_000); + assertThat(config2DurationUs).isEqualTo(80_000); + assertThat(config3DurationUs).isEqualTo(120_000); + assertThat(config4DurationUs).isEqualTo(20_000); + assertThat(config5DurationUs).isEqualTo(40_000); + assertThat(config6DurationUs).isEqualTo(80_000); + assertThat(config7DurationUs).isEqualTo(120_000); + assertThat(config8DurationUs).isEqualTo(20_000); + assertThat(config9DurationUs).isEqualTo(40_000); + assertThat(config10DurationUs).isEqualTo(80_000); + assertThat(config11DurationUs).isEqualTo(120_000); + assertThat(config12DurationUs).isEqualTo(20_000); + assertThat(config13DurationUs).isEqualTo(40_000); + assertThat(config14DurationUs).isEqualTo(20_000); + assertThat(config15DurationUs).isEqualTo(40_000); + assertThat(config16DurationUs).isEqualTo(5_000); + assertThat(config17DurationUs).isEqualTo(10_000); + assertThat(config18DurationUs).isEqualTo(20_000); + assertThat(config19DurationUs).isEqualTo(40_000); + assertThat(config20DurationUs).isEqualTo(5_000); + assertThat(config21DurationUs).isEqualTo(10_000); + assertThat(config22DurationUs).isEqualTo(20_000); + assertThat(config23DurationUs).isEqualTo(40_000); + assertThat(config24DurationUs).isEqualTo(5_000); + assertThat(config25DurationUs).isEqualTo(10_000); + assertThat(config26DurationUs).isEqualTo(20_000); + assertThat(config27DurationUs).isEqualTo(40_000); + assertThat(config28DurationUs).isEqualTo(5_000); + assertThat(config29DurationUs).isEqualTo(10_000); + assertThat(config30DurationUs).isEqualTo(20_000); + assertThat(config31DurationUs).isEqualTo(40_000); + } + + @Test + public void getPacketDurationUs_code2_returnsExpectedDuration() { + long config0DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("06")); + long config1DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("0E")); + long config2DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("16")); + long config3DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("1E")); + long config4DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("26")); + long config5DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("2E")); + long config6DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("36")); + long config7DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("3E")); + long config8DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("46")); + long config9DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("4E")); + long config10DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("56")); + long config11DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("5E")); + long config12DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("66")); + long config13DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("6E")); + long config14DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("76")); + long config15DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("7E")); + long config16DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("86")); + long config17DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("8E")); + long config18DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("96")); + long config19DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("9E")); + long config20DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("A6")); + long config21DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("AE")); + long config22DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("B6")); + long config23DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("BE")); + long config24DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("C6")); + long config25DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("CE")); + long config26DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("D6")); + long config27DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("DE")); + long config28DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("E6")); + long config29DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("EE")); + long config30DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("F6")); + long config31DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("FE")); + + assertThat(config0DurationUs).isEqualTo(20_000); + assertThat(config1DurationUs).isEqualTo(40_000); + assertThat(config2DurationUs).isEqualTo(80_000); + assertThat(config3DurationUs).isEqualTo(120_000); + assertThat(config4DurationUs).isEqualTo(20_000); + assertThat(config5DurationUs).isEqualTo(40_000); + assertThat(config6DurationUs).isEqualTo(80_000); + assertThat(config7DurationUs).isEqualTo(120_000); + assertThat(config8DurationUs).isEqualTo(20_000); + assertThat(config9DurationUs).isEqualTo(40_000); + assertThat(config10DurationUs).isEqualTo(80_000); + assertThat(config11DurationUs).isEqualTo(120_000); + assertThat(config12DurationUs).isEqualTo(20_000); + assertThat(config13DurationUs).isEqualTo(40_000); + assertThat(config14DurationUs).isEqualTo(20_000); + assertThat(config15DurationUs).isEqualTo(40_000); + assertThat(config16DurationUs).isEqualTo(5_000); + assertThat(config17DurationUs).isEqualTo(10_000); + assertThat(config18DurationUs).isEqualTo(20_000); + assertThat(config19DurationUs).isEqualTo(40_000); + assertThat(config20DurationUs).isEqualTo(5_000); + assertThat(config21DurationUs).isEqualTo(10_000); + assertThat(config22DurationUs).isEqualTo(20_000); + assertThat(config23DurationUs).isEqualTo(40_000); + assertThat(config24DurationUs).isEqualTo(5_000); + assertThat(config25DurationUs).isEqualTo(10_000); + assertThat(config26DurationUs).isEqualTo(20_000); + assertThat(config27DurationUs).isEqualTo(40_000); + assertThat(config28DurationUs).isEqualTo(5_000); + assertThat(config29DurationUs).isEqualTo(10_000); + assertThat(config30DurationUs).isEqualTo(20_000); + assertThat(config31DurationUs).isEqualTo(40_000); + } + + @Test + public void getPacketDurationUs_code3_returnsExpectedDuration() { + // max possible frame count to reach 120ms duration + long config0DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("078C")); + long config1DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("0F86")); + long config2DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("1783")); + long config3DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("1F82")); + // frame count of 2 + long config4DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("2782")); + long config5DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("2F82")); + long config6DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("3782")); + long config7DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("3F82")); + long config8DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("4782")); + long config9DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("4F82")); + long config10DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("5782")); + long config11DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("5F82")); + // max possible frame count to reach 120ms duration + long config12DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("678C")); + long config13DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("6F86")); + // frame count of 2 + long config14DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("7782")); + long config15DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("7F82")); + // max possible frame count to reach 120ms duration + long config16DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("87B0")); + long config17DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("8F98")); + long config18DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("978C")); + long config19DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("9F86")); + // frame count of 2 + long config20DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("A782")); + long config21DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("AF82")); + long config22DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("B782")); + long config23DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("BF82")); + long config24DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("C782")); + long config25DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("CF82")); + long config26DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("D782")); + long config27DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("DF82")); + long config28DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("E782")); + long config29DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("EF82")); + long config30DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("F782")); + long config31DurationUs = OpusUtil.getPacketDurationUs(getBytesFromHexString("FF82")); + + assertThat(config0DurationUs).isEqualTo(120_000); + assertThat(config1DurationUs).isEqualTo(120_000); + assertThat(config2DurationUs).isEqualTo(120_000); + assertThat(config3DurationUs).isEqualTo(120_000); + assertThat(config4DurationUs).isEqualTo(20_000); + assertThat(config5DurationUs).isEqualTo(40_000); + assertThat(config6DurationUs).isEqualTo(80_000); + assertThat(config7DurationUs).isEqualTo(120_000); + assertThat(config8DurationUs).isEqualTo(20_000); + assertThat(config9DurationUs).isEqualTo(40_000); + assertThat(config10DurationUs).isEqualTo(80_000); + assertThat(config11DurationUs).isEqualTo(120_000); + assertThat(config12DurationUs).isEqualTo(120_000); + assertThat(config13DurationUs).isEqualTo(120_000); + assertThat(config14DurationUs).isEqualTo(20_000); + assertThat(config15DurationUs).isEqualTo(40_000); + assertThat(config16DurationUs).isEqualTo(120_000); + assertThat(config17DurationUs).isEqualTo(120_000); + assertThat(config18DurationUs).isEqualTo(120_000); + assertThat(config19DurationUs).isEqualTo(120_000); + assertThat(config20DurationUs).isEqualTo(5_000); + assertThat(config21DurationUs).isEqualTo(10_000); + assertThat(config22DurationUs).isEqualTo(20_000); + assertThat(config23DurationUs).isEqualTo(40_000); + assertThat(config24DurationUs).isEqualTo(5_000); + assertThat(config25DurationUs).isEqualTo(10_000); + assertThat(config26DurationUs).isEqualTo(20_000); + assertThat(config27DurationUs).isEqualTo(40_000); + assertThat(config28DurationUs).isEqualTo(5_000); + assertThat(config29DurationUs).isEqualTo(10_000); + assertThat(config30DurationUs).isEqualTo(20_000); + assertThat(config31DurationUs).isEqualTo(40_000); + } + + @Test + public void getPacketAudioSampleCount_code0_returnsExpectedDuration() { + int config0SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("04")); + int config1SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("0C")); + int config2SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("14")); + int config3SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("1C")); + int config4SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("24")); + int config5SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("2C")); + int config6SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("34")); + int config7SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("3C")); + int config8SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("44")); + int config9SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("4C")); + int config10SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("54")); + int config11SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("5C")); + int config12SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("64")); + int config13SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("6C")); + int config14SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("74")); + int config15SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("7C")); + int config16SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("84")); + int config17SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("8C")); + int config18SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("94")); + int config19SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("9C")); + int config20SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("A4")); + int config21SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("AC")); + int config22SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("B4")); + int config23SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("BC")); + int config24SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("C4")); + int config25SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("CC")); + int config26SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("D4")); + int config27SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("DC")); + int config28SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("E4")); + int config29SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("EC")); + int config30SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("F4")); + int config31SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("FC")); + + assertThat(config0SampleCount).isEqualTo(480); + assertThat(config1SampleCount).isEqualTo(960); + assertThat(config2SampleCount).isEqualTo(1920); + assertThat(config3SampleCount).isEqualTo(2880); + assertThat(config4SampleCount).isEqualTo(480); + assertThat(config5SampleCount).isEqualTo(960); + assertThat(config6SampleCount).isEqualTo(1920); + assertThat(config7SampleCount).isEqualTo(2880); + assertThat(config8SampleCount).isEqualTo(480); + assertThat(config9SampleCount).isEqualTo(960); + assertThat(config10SampleCount).isEqualTo(1920); + assertThat(config11SampleCount).isEqualTo(2880); + assertThat(config12SampleCount).isEqualTo(480); + assertThat(config13SampleCount).isEqualTo(960); + assertThat(config14SampleCount).isEqualTo(480); + assertThat(config15SampleCount).isEqualTo(960); + assertThat(config16SampleCount).isEqualTo(120); + assertThat(config17SampleCount).isEqualTo(240); + assertThat(config18SampleCount).isEqualTo(480); + assertThat(config19SampleCount).isEqualTo(960); + assertThat(config20SampleCount).isEqualTo(120); + assertThat(config21SampleCount).isEqualTo(240); + assertThat(config22SampleCount).isEqualTo(480); + assertThat(config23SampleCount).isEqualTo(960); + assertThat(config24SampleCount).isEqualTo(120); + assertThat(config25SampleCount).isEqualTo(240); + assertThat(config26SampleCount).isEqualTo(480); + assertThat(config27SampleCount).isEqualTo(960); + assertThat(config28SampleCount).isEqualTo(120); + assertThat(config29SampleCount).isEqualTo(240); + assertThat(config30SampleCount).isEqualTo(480); + assertThat(config31SampleCount).isEqualTo(960); + } + + @Test + public void getPacketAudioSampleCount_code1_returnsExpectedDuration() { + int config0SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("05")); + int config1SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("0D")); + int config2SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("15")); + int config3SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("1D")); + int config4SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("25")); + int config5SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("2D")); + int config6SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("35")); + int config7SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("3D")); + int config8SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("45")); + int config9SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("4D")); + int config10SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("55")); + int config11SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("5D")); + int config12SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("65")); + int config13SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("6D")); + int config14SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("75")); + int config15SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("7D")); + int config16SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("85")); + int config17SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("8D")); + int config18SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("95")); + int config19SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("9D")); + int config20SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("A5")); + int config21SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("AD")); + int config22SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("B5")); + int config23SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("BD")); + int config24SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("C5")); + int config25SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("CD")); + int config26SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("D5")); + int config27SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("DD")); + int config28SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("E5")); + int config29SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("ED")); + int config30SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("F5")); + int config31SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("FD")); + + assertThat(config0SampleCount).isEqualTo(960); + assertThat(config1SampleCount).isEqualTo(1920); + assertThat(config2SampleCount).isEqualTo(3840); + assertThat(config3SampleCount).isEqualTo(5760); + assertThat(config4SampleCount).isEqualTo(960); + assertThat(config5SampleCount).isEqualTo(1920); + assertThat(config6SampleCount).isEqualTo(3840); + assertThat(config7SampleCount).isEqualTo(5760); + assertThat(config8SampleCount).isEqualTo(960); + assertThat(config9SampleCount).isEqualTo(1920); + assertThat(config10SampleCount).isEqualTo(3840); + assertThat(config11SampleCount).isEqualTo(5760); + assertThat(config12SampleCount).isEqualTo(960); + assertThat(config13SampleCount).isEqualTo(1920); + assertThat(config14SampleCount).isEqualTo(960); + assertThat(config15SampleCount).isEqualTo(1920); + assertThat(config16SampleCount).isEqualTo(240); + assertThat(config17SampleCount).isEqualTo(480); + assertThat(config18SampleCount).isEqualTo(960); + assertThat(config19SampleCount).isEqualTo(1920); + assertThat(config20SampleCount).isEqualTo(240); + assertThat(config21SampleCount).isEqualTo(480); + assertThat(config22SampleCount).isEqualTo(960); + assertThat(config23SampleCount).isEqualTo(1920); + assertThat(config24SampleCount).isEqualTo(240); + assertThat(config25SampleCount).isEqualTo(480); + assertThat(config26SampleCount).isEqualTo(960); + assertThat(config27SampleCount).isEqualTo(1920); + assertThat(config28SampleCount).isEqualTo(240); + assertThat(config29SampleCount).isEqualTo(480); + assertThat(config30SampleCount).isEqualTo(960); + assertThat(config31SampleCount).isEqualTo(1920); + } + + @Test + public void getPacketAudioSampleCount_code2_returnsExpectedDuration() { + int config0SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("06")); + int config1SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("0E")); + int config2SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("16")); + int config3SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("1E")); + int config4SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("26")); + int config5SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("2E")); + int config6SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("36")); + int config7SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("3E")); + int config8SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("46")); + int config9SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("4E")); + int config10SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("56")); + int config11SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("5E")); + int config12SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("66")); + int config13SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("6E")); + int config14SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("76")); + int config15SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("7E")); + int config16SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("86")); + int config17SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("8E")); + int config18SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("96")); + int config19SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("9E")); + int config20SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("A6")); + int config21SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("AE")); + int config22SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("B6")); + int config23SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("BE")); + int config24SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("C6")); + int config25SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("CE")); + int config26SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("D6")); + int config27SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("DE")); + int config28SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("E6")); + int config29SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("EE")); + int config30SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("F6")); + int config31SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("FE")); + + assertThat(config0SampleCount).isEqualTo(960); + assertThat(config1SampleCount).isEqualTo(1920); + assertThat(config2SampleCount).isEqualTo(3840); + assertThat(config3SampleCount).isEqualTo(5760); + assertThat(config4SampleCount).isEqualTo(960); + assertThat(config5SampleCount).isEqualTo(1920); + assertThat(config6SampleCount).isEqualTo(3840); + assertThat(config7SampleCount).isEqualTo(5760); + assertThat(config8SampleCount).isEqualTo(960); + assertThat(config9SampleCount).isEqualTo(1920); + assertThat(config10SampleCount).isEqualTo(3840); + assertThat(config11SampleCount).isEqualTo(5760); + assertThat(config12SampleCount).isEqualTo(960); + assertThat(config13SampleCount).isEqualTo(1920); + assertThat(config14SampleCount).isEqualTo(960); + assertThat(config15SampleCount).isEqualTo(1920); + assertThat(config16SampleCount).isEqualTo(240); + assertThat(config17SampleCount).isEqualTo(480); + assertThat(config18SampleCount).isEqualTo(960); + assertThat(config19SampleCount).isEqualTo(1920); + assertThat(config20SampleCount).isEqualTo(240); + assertThat(config21SampleCount).isEqualTo(480); + assertThat(config22SampleCount).isEqualTo(960); + assertThat(config23SampleCount).isEqualTo(1920); + assertThat(config24SampleCount).isEqualTo(240); + assertThat(config25SampleCount).isEqualTo(480); + assertThat(config26SampleCount).isEqualTo(960); + assertThat(config27SampleCount).isEqualTo(1920); + assertThat(config28SampleCount).isEqualTo(240); + assertThat(config29SampleCount).isEqualTo(480); + assertThat(config30SampleCount).isEqualTo(960); + assertThat(config31SampleCount).isEqualTo(1920); + } + + @Test + public void getPacketAudioSampleCount_code3_returnsExpectedDuration() { + // max possible frame count to reach 120ms duration + int config0SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("078C")); + int config1SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("0F86")); + int config2SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("1783")); + int config3SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("1F82")); + // frame count of 2 + int config4SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("2782")); + int config5SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("2F82")); + int config6SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("3782")); + int config7SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("3F82")); + int config8SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("4782")); + int config9SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("4F82")); + int config10SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("5782")); + int config11SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("5F82")); + // max possible frame count to reach 120ms duration + int config12SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("678C")); + int config13SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("6F86")); + // frame count of 2 + int config14SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("7782")); + int config15SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("7F82")); + // max possible frame count to reach 120ms duration + int config16SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("87B0")); + int config17SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("8F98")); + int config18SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("978C")); + int config19SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("9F86")); + // frame count of 2 + int config20SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("A782")); + int config21SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("AF82")); + int config22SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("B782")); + int config23SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("BF82")); + int config24SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("C782")); + int config25SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("CF82")); + int config26SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("D782")); + int config27SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("DF82")); + int config28SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("E782")); + int config29SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("EF82")); + int config30SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("F782")); + int config31SampleCount = OpusUtil.parsePacketAudioSampleCount(getByteBuffer("FF82")); + + assertThat(config0SampleCount).isEqualTo(5760); + assertThat(config1SampleCount).isEqualTo(5760); + assertThat(config2SampleCount).isEqualTo(5760); + assertThat(config3SampleCount).isEqualTo(5760); + assertThat(config4SampleCount).isEqualTo(960); + assertThat(config5SampleCount).isEqualTo(1920); + assertThat(config6SampleCount).isEqualTo(3840); + assertThat(config7SampleCount).isEqualTo(5760); + assertThat(config8SampleCount).isEqualTo(960); + assertThat(config9SampleCount).isEqualTo(1920); + assertThat(config10SampleCount).isEqualTo(3840); + assertThat(config11SampleCount).isEqualTo(5760); + assertThat(config12SampleCount).isEqualTo(5760); + assertThat(config13SampleCount).isEqualTo(5760); + assertThat(config14SampleCount).isEqualTo(960); + assertThat(config15SampleCount).isEqualTo(1920); + assertThat(config16SampleCount).isEqualTo(5760); + assertThat(config17SampleCount).isEqualTo(5760); + assertThat(config18SampleCount).isEqualTo(5760); + assertThat(config19SampleCount).isEqualTo(5760); + assertThat(config20SampleCount).isEqualTo(240); + assertThat(config21SampleCount).isEqualTo(480); + assertThat(config22SampleCount).isEqualTo(960); + assertThat(config23SampleCount).isEqualTo(1920); + assertThat(config24SampleCount).isEqualTo(240); + assertThat(config25SampleCount).isEqualTo(480); + assertThat(config26SampleCount).isEqualTo(960); + assertThat(config27SampleCount).isEqualTo(1920); + assertThat(config28SampleCount).isEqualTo(240); + assertThat(config29SampleCount).isEqualTo(480); + assertThat(config30SampleCount).isEqualTo(960); + assertThat(config31SampleCount).isEqualTo(1920); + } + private static long sampleCountToNanoseconds(long sampleCount) { return (sampleCount * C.NANOS_PER_SECOND) / OpusUtil.SAMPLE_RATE; } @@ -62,4 +629,8 @@ public final class OpusUtilTest { private static byte[] buildNativeOrderByteArray(long value) { return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).putLong(value).array(); } + + private static ByteBuffer getByteBuffer(String hexString) { + return ByteBuffer.wrap(getBytesFromHexString(hexString)); + } } diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterFrameTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterFrameTest.java index d2042f1798..38c164d14e 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterFrameTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterFrameTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,7 +31,7 @@ public final class ChapterFrameTest { public void parcelable() { Id3Frame[] subFrames = new Id3Frame[] { - new TextInformationFrame("TIT2", null, "title"), + new TextInformationFrame("TIT2", null, ImmutableList.of("title")), new UrlLinkFrame("WXXX", "description", "url") }; ChapterFrame chapterFrameToParcel = new ChapterFrame("id", 0, 1, 2, 3, subFrames); diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterTocFrameTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterTocFrameTest.java index 222df1785d..b786a4f23f 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterTocFrameTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/ChapterTocFrameTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,7 +32,7 @@ public final class ChapterTocFrameTest { String[] children = new String[] {"child0", "child1"}; Id3Frame[] subFrames = new Id3Frame[] { - new TextInformationFrame("TIT2", null, "title"), + new TextInformationFrame("TIT2", null, ImmutableList.of("title")), new UrlLinkFrame("WXXX", "description", "url") }; ChapterTocFrame chapterTocFrameToParcel = diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3DecoderTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3DecoderTest.java index 55e81ab93c..8b9ce52840 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3DecoderTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/Id3DecoderTest.java @@ -37,8 +37,7 @@ public final class Id3DecoderTest { private static final int ID3_TEXT_ENCODING_UTF_8 = 3; @Test - public void decodeTxxxFrame() { - // Test UTF-8. + public void decodeTxxxFrame_utf8() { byte[] rawId3 = buildSingleFrameTag( "TXXX", @@ -47,41 +46,77 @@ public final class Id3DecoderTest { 55, 54, 54, 52, 95, 115, 116, 97, 114, 116, 0 }); Id3Decoder decoder = new Id3Decoder(); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertThat(textInformationFrame.id).isEqualTo("TXXX"); assertThat(textInformationFrame.description).isEmpty(); - assertThat(textInformationFrame.value).isEqualTo("mdialog_VINDICO1527664_start"); + assertThat(textInformationFrame.values.get(0)).isEqualTo("mdialog_VINDICO1527664_start"); + } - // Test UTF-16. - rawId3 = + @Test + public void decodeTxxxFrame_utf16() { + byte[] rawId3 = buildSingleFrameTag( "TXXX", new byte[] { 1, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0, 0 }); - metadata = decoder.decode(rawId3, rawId3.length); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); - textInformationFrame = (TextInformationFrame) metadata.get(0); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertThat(textInformationFrame.id).isEqualTo("TXXX"); assertThat(textInformationFrame.description).isEqualTo("Hello World"); - assertThat(textInformationFrame.value).isEmpty(); + assertThat(textInformationFrame.values).containsExactly(""); + } - // Test empty. - rawId3 = buildSingleFrameTag("TXXX", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); - assertThat(metadata.length()).isEqualTo(0); + @Test + public void decodeTxxxFrame_multipleValues() { + byte[] rawId3 = + buildSingleFrameTag( + "TXXX", + new byte[] { + 1, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, + 100, 0, 0, 0, 70, 0, 111, 0, 111, 0, 0, 0, 66, 0, 97, 0, 114, 0, 0 + }); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test encoding byte only. - rawId3 = buildSingleFrameTag("TXXX", new byte[] {ID3_TEXT_ENCODING_UTF_8}); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - textInformationFrame = (TextInformationFrame) metadata.get(0); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); + assertThat(textInformationFrame.description).isEqualTo("Hello World"); + assertThat(textInformationFrame.values).containsExactly("Foo", "Bar").inOrder(); + } + + @Test + public void decodeTxxxFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("TXXX", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + + assertThat(metadata.length()).isEqualTo(0); + } + + @Test + public void decodeTxxxFrame_encodingByteOnly() { + byte[] rawId3 = buildSingleFrameTag("TXXX", new byte[] {ID3_TEXT_ENCODING_UTF_8}); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + + assertThat(metadata.length()).isEqualTo(1); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertThat(textInformationFrame.id).isEqualTo("TXXX"); assertThat(textInformationFrame.description).isEmpty(); - assertThat(textInformationFrame.value).isEmpty(); + assertThat(textInformationFrame.values).containsExactly(""); } @Test @@ -90,26 +125,52 @@ public final class Id3DecoderTest { buildSingleFrameTag( "TIT2", new byte[] {3, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0}); Id3Decoder decoder = new Id3Decoder(); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertThat(textInformationFrame.id).isEqualTo("TIT2"); assertThat(textInformationFrame.description).isNull(); - assertThat(textInformationFrame.value).isEqualTo("Hello World"); + assertThat(textInformationFrame.values.size()).isEqualTo(1); + assertThat(textInformationFrame.values.get(0)).isEqualTo("Hello World"); + } - // Test empty. - rawId3 = buildSingleFrameTag("TIT2", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); - assertThat(metadata.length()).isEqualTo(0); + @Test + public void decodeTextInformationFrame_multipleValues() { + // Test multiple values. + byte[] rawId3 = buildSingleFrameTag("TIT2", new byte[] {3, 70, 111, 111, 0, 66, 97, 114, 0}); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test encoding byte only. - rawId3 = buildSingleFrameTag("TIT2", new byte[] {ID3_TEXT_ENCODING_UTF_8}); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - textInformationFrame = (TextInformationFrame) metadata.get(0); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); + assertThat(textInformationFrame.values).containsExactly("Foo", "Bar").inOrder(); + } + + @Test + public void decodeTextInformationFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("TIT2", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + + assertThat(metadata.length()).isEqualTo(0); + } + + @Test + public void decodeTextInformationFrame_encodingByteOnly() { + byte[] rawId3 = buildSingleFrameTag("TIT2", new byte[] {ID3_TEXT_ENCODING_UTF_8}); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + + assertThat(metadata.length()).isEqualTo(1); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertThat(textInformationFrame.id).isEqualTo("TIT2"); assertThat(textInformationFrame.description).isNull(); - assertThat(textInformationFrame.value).isEmpty(); + assertThat(textInformationFrame.values).containsExactly(""); } @Test @@ -150,23 +211,35 @@ public final class Id3DecoderTest { 102 }); Id3Decoder decoder = new Id3Decoder(); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); UrlLinkFrame urlLinkFrame = (UrlLinkFrame) metadata.get(0); assertThat(urlLinkFrame.id).isEqualTo("WXXX"); assertThat(urlLinkFrame.description).isEqualTo("test"); assertThat(urlLinkFrame.url).isEqualTo("https://test.com/abc?def"); + } + + @Test + public void decodeWxxxFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("WXXX", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test empty. - rawId3 = buildSingleFrameTag("WXXX", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(0); + } + + @Test + public void decodeWxxxFrame_encodingByteOnly() { + byte[] rawId3 = buildSingleFrameTag("WXXX", new byte[] {ID3_TEXT_ENCODING_UTF_8}); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test encoding byte only. - rawId3 = buildSingleFrameTag("WXXX", new byte[] {ID3_TEXT_ENCODING_UTF_8}); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - urlLinkFrame = (UrlLinkFrame) metadata.get(0); + UrlLinkFrame urlLinkFrame = (UrlLinkFrame) metadata.get(0); assertThat(urlLinkFrame.id).isEqualTo("WXXX"); assertThat(urlLinkFrame.description).isEmpty(); assertThat(urlLinkFrame.url).isEmpty(); @@ -188,12 +261,17 @@ public final class Id3DecoderTest { assertThat(urlLinkFrame.id).isEqualTo("WCOM"); assertThat(urlLinkFrame.description).isNull(); assertThat(urlLinkFrame.url).isEqualTo("https://test.com/abc?def"); + } + + @Test + public void decodeUrlLinkFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("WCOM", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test empty. - rawId3 = buildSingleFrameTag("WCOM", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - urlLinkFrame = (UrlLinkFrame) metadata.get(0); + UrlLinkFrame urlLinkFrame = (UrlLinkFrame) metadata.get(0); assertThat(urlLinkFrame.id).isEqualTo("WCOM"); assertThat(urlLinkFrame.description).isNull(); assertThat(urlLinkFrame.url).isEmpty(); @@ -208,12 +286,17 @@ public final class Id3DecoderTest { PrivFrame privFrame = (PrivFrame) metadata.get(0); assertThat(privFrame.owner).isEqualTo("test"); assertThat(privFrame.privateData).isEqualTo(new byte[] {1, 2, 3, 4}); + } + + @Test + public void decodePrivFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("PRIV", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test empty. - rawId3 = buildSingleFrameTag("PRIV", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - privFrame = (PrivFrame) metadata.get(0); + PrivFrame privFrame = (PrivFrame) metadata.get(0); assertThat(privFrame.owner).isEmpty(); assertThat(privFrame.privateData).isEqualTo(new byte[0]); } @@ -236,9 +319,11 @@ public final class Id3DecoderTest { assertThat(apicFrame.description).isEqualTo("Hello World"); assertThat(apicFrame.pictureData).hasLength(10); assertThat(apicFrame.pictureData).isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); + } - // Test with UTF-16 description at even offset. - rawId3 = + @Test + public void decodeApicFrame_utf16DescriptionEvenOffset() { + byte[] rawId3 = buildSingleFrameTag( "APIC", new byte[] { @@ -246,28 +331,34 @@ public final class Id3DecoderTest { 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }); - decoder = new Id3Decoder(); - metadata = decoder.decode(rawId3, rawId3.length); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); - apicFrame = (ApicFrame) metadata.get(0); + ApicFrame apicFrame = (ApicFrame) metadata.get(0); assertThat(apicFrame.mimeType).isEqualTo("image/jpeg"); assertThat(apicFrame.pictureType).isEqualTo(16); assertThat(apicFrame.description).isEqualTo("Hello World"); assertThat(apicFrame.pictureData).hasLength(10); assertThat(apicFrame.pictureData).isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); + } - // Test with UTF-16 description at odd offset. - rawId3 = + @Test + public void decodeApicFrame_utf16DescriptionOddOffset() { + byte[] rawId3 = buildSingleFrameTag( "APIC", new byte[] { 1, 105, 109, 97, 103, 101, 47, 112, 110, 103, 0, 16, 0, 72, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 87, 0, 111, 0, 114, 0, 108, 0, 100, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }); - decoder = new Id3Decoder(); - metadata = decoder.decode(rawId3, rawId3.length); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(1); - apicFrame = (ApicFrame) metadata.get(0); + ApicFrame apicFrame = (ApicFrame) metadata.get(0); assertThat(apicFrame.mimeType).isEqualTo("image/png"); assertThat(apicFrame.pictureType).isEqualTo(16); assertThat(apicFrame.description).isEqualTo("Hello World"); @@ -310,17 +401,28 @@ public final class Id3DecoderTest { assertThat(commentFrame.language).isEqualTo("eng"); assertThat(commentFrame.description).isEqualTo("description"); assertThat(commentFrame.text).isEqualTo("text"); + } + + @Test + public void decodeCommentFrame_empty() { + byte[] rawId3 = buildSingleFrameTag("COMM", new byte[0]); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test empty. - rawId3 = buildSingleFrameTag("COMM", new byte[0]); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(0); + } + + @Test + public void decodeCommentFrame_languageOnly() { + byte[] rawId3 = + buildSingleFrameTag("COMM", new byte[] {ID3_TEXT_ENCODING_UTF_8, 101, 110, 103}); + Id3Decoder decoder = new Id3Decoder(); + + Metadata metadata = decoder.decode(rawId3, rawId3.length); - // Test language only. - rawId3 = buildSingleFrameTag("COMM", new byte[] {ID3_TEXT_ENCODING_UTF_8, 101, 110, 103}); - metadata = decoder.decode(rawId3, rawId3.length); assertThat(metadata.length()).isEqualTo(1); - commentFrame = (CommentFrame) metadata.get(0); + CommentFrame commentFrame = (CommentFrame) metadata.get(0); assertThat(commentFrame.language).isEqualTo("eng"); assertThat(commentFrame.description).isEmpty(); assertThat(commentFrame.text).isEmpty(); diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/TextInformationFrameTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/TextInformationFrameTest.java index bafb57e3cf..ce9123c308 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/TextInformationFrameTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/metadata/id3/TextInformationFrameTest.java @@ -16,6 +16,7 @@ package androidx.media3.extractor.metadata.id3; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import android.os.Parcel; import androidx.media3.common.MediaMetadata; @@ -32,7 +33,8 @@ public class TextInformationFrameTest { @Test public void parcelable() { - TextInformationFrame textInformationFrameToParcel = new TextInformationFrame("", "", ""); + TextInformationFrame textInformationFrameToParcel = + new TextInformationFrame("", "", ImmutableList.of("")); Parcel parcel = Parcel.obtain(); textInformationFrameToParcel.writeToParcel(parcel, 0); @@ -62,28 +64,42 @@ public class TextInformationFrameTest { List entries = ImmutableList.of( - new TextInformationFrame(/* id= */ "TT2", /* description= */ null, /* value= */ title), - new TextInformationFrame(/* id= */ "TP1", /* description= */ null, /* value= */ artist), new TextInformationFrame( - /* id= */ "TAL", /* description= */ null, /* value= */ albumTitle), + /* id= */ "TT2", /* description= */ null, /* values= */ ImmutableList.of(title)), new TextInformationFrame( - /* id= */ "TP2", /* description= */ null, /* value= */ albumArtist), + /* id= */ "TP1", /* description= */ null, /* values= */ ImmutableList.of(artist)), new TextInformationFrame( - /* id= */ "TRK", /* description= */ null, /* value= */ trackNumberInfo), + /* id= */ "TAL", + /* description= */ null, + /* values= */ ImmutableList.of(albumTitle)), new TextInformationFrame( - /* id= */ "TYE", /* description= */ null, /* value= */ recordingYear), + /* id= */ "TP2", + /* description= */ null, + /* values= */ ImmutableList.of(albumArtist)), + new TextInformationFrame( + /* id= */ "TRK", + /* description= */ null, + /* values= */ ImmutableList.of(trackNumberInfo)), + new TextInformationFrame( + /* id= */ "TYE", + /* description= */ null, + /* values= */ ImmutableList.of(recordingYear)), new TextInformationFrame( /* id= */ "TDA", /* description= */ null, - /* value= */ recordingDay + recordingMonth), + /* values= */ ImmutableList.of(recordingDay + recordingMonth)), new TextInformationFrame( - /* id= */ "TDRL", /* description= */ null, /* value= */ releaseDate), + /* id= */ "TDRL", + /* description= */ null, + /* values= */ ImmutableList.of(releaseDate)), new TextInformationFrame( - /* id= */ "TCM", /* description= */ null, /* value= */ composer), + /* id= */ "TCM", /* description= */ null, /* values= */ ImmutableList.of(composer)), new TextInformationFrame( - /* id= */ "TP3", /* description= */ null, /* value= */ conductor), + /* id= */ "TP3", + /* description= */ null, + /* values= */ ImmutableList.of(conductor)), new TextInformationFrame( - /* id= */ "TXT", /* description= */ null, /* value= */ writer)); + /* id= */ "TXT", /* description= */ null, /* values= */ ImmutableList.of(writer))); MediaMetadata.Builder builder = MediaMetadata.EMPTY.buildUpon(); for (Metadata.Entry entry : entries) { @@ -108,4 +124,41 @@ public class TextInformationFrameTest { assertThat(mediaMetadata.conductor.toString()).isEqualTo(conductor); assertThat(mediaMetadata.writer.toString()).isEqualTo(writer); } + + @Test + public void emptyValuesListThrowsException() { + assertThrows( + IllegalArgumentException.class, + () -> new TextInformationFrame("TXXX", "description", ImmutableList.of())); + } + + @Test + @SuppressWarnings("deprecation") // Testing deprecated field + public void deprecatedValueStillPopulated() { + TextInformationFrame frame = + new TextInformationFrame("TXXX", "description", ImmutableList.of("value")); + + assertThat(frame.value).isEqualTo("value"); + assertThat(frame.values).containsExactly("value"); + } + + @Test + @SuppressWarnings({"deprecation", "InlineMeInliner"}) // Testing deprecated constructor + public void deprecatedConstructorPopulatesValuesList() { + TextInformationFrame frame = new TextInformationFrame("TXXX", "description", "value"); + + assertThat(frame.value).isEqualTo("value"); + assertThat(frame.values).containsExactly("value"); + } + + @Test + @SuppressWarnings({"deprecation", "InlineMeInliner"}) // Testing deprecated constructor + public void deprecatedConstructorCreatesEqualInstance() { + TextInformationFrame frame1 = new TextInformationFrame("TXXX", "description", "value"); + TextInformationFrame frame2 = + new TextInformationFrame("TXXX", "description", ImmutableList.of("value")); + + assertThat(frame1).isEqualTo(frame2); + assertThat(frame1.hashCode()).isEqualTo(frame2.hashCode()); + } } diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java index e9a4b8f8b8..642e20e259 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java @@ -40,6 +40,8 @@ public final class SubripDecoderTest { private static final String TYPICAL_NEGATIVE_TIMESTAMPS = "media/subrip/typical_negative_timestamps"; private static final String TYPICAL_UNEXPECTED_END = "media/subrip/typical_unexpected_end"; + private static final String TYPICAL_UTF16BE = "media/subrip/typical_utf16be"; + private static final String TYPICAL_UTF16LE = "media/subrip/typical_utf16le"; private static final String TYPICAL_WITH_TAGS = "media/subrip/typical_with_tags"; private static final String TYPICAL_NO_HOURS_AND_MILLIS = "media/subrip/typical_no_hours_and_millis"; @@ -48,6 +50,7 @@ public final class SubripDecoderTest { public void decodeEmpty() throws IOException { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(0); @@ -58,6 +61,7 @@ public final class SubripDecoderTest { public void decodeTypical() throws IOException { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(6); @@ -72,6 +76,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(6); @@ -86,6 +91,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(6); @@ -101,6 +107,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(4); @@ -115,6 +122,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(4); @@ -129,6 +137,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(2); @@ -141,6 +150,7 @@ public final class SubripDecoderTest { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(4); @@ -148,28 +158,52 @@ public final class SubripDecoderTest { assertTypicalCue2(subtitle, 2); } + @Test + public void decodeTypicalUtf16LittleEndian() throws IOException { + SubripDecoder decoder = new SubripDecoder(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE); + + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(6); + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + + @Test + public void decodeTypicalUtf16BigEndian() throws IOException { + SubripDecoder decoder = new SubripDecoder(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE); + + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(6); + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + @Test public void decodeCueWithTag() throws IOException { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()) .isEqualTo("This is the first subtitle."); - assertThat(subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()) .isEqualTo("This is the second subtitle.\nSecond subtitle with second line."); - assertThat(subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString()) .isEqualTo("This is the third subtitle."); - assertThat(subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString()) .isEqualTo("This { \\an2} is not a valid tag due to the space after the opening bracket."); - assertThat(subtitle.getCues(subtitle.getEventTime(8)).get(0).text.toString()) .isEqualTo("This is the fifth subtitle with multiple valid tags."); - assertAlignmentCue(subtitle, 10, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_START); // {/an1} assertAlignmentCue(subtitle, 12, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_MIDDLE); // {/an2} assertAlignmentCue(subtitle, 14, Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_END); // {/an3} @@ -187,6 +221,7 @@ public final class SubripDecoderTest { byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); assertThat(subtitle.getEventTimeCount()).isEqualTo(6); diff --git a/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl b/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl index d1a348cd3a..7c1eb001d2 100644 --- a/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl +++ b/libraries/session/src/main/aidl/androidx/media3/session/IMediaController.aidl @@ -35,14 +35,19 @@ oneway interface IMediaController { void onSetCustomLayout(int seq, in List commandButtonList) = 3003; void onCustomCommand(int seq, in Bundle command, in Bundle args) = 3004; void onDisconnected(int seq) = 3005; - void onPlayerInfoChanged(int seq, in Bundle playerInfoBundle, boolean isTimelineExcluded) = 3006; + /** Deprecated: Use onPlayerInfoChangedWithExclusions from MediaControllerStub#VERSION_INT=2. */ + void onPlayerInfoChanged( + int seq, in Bundle playerInfoBundle, boolean isTimelineExcluded) = 3006; + /** Introduced to deprecate onPlayerInfoChanged (from MediaControllerStub#VERSION_INT=2). */ + void onPlayerInfoChangedWithExclusions( + int seq, in Bundle playerInfoBundle, in Bundle playerInfoExclusions) = 3012; void onPeriodicSessionPositionInfoChanged(int seq, in Bundle sessionPositionInfo) = 3007; void onAvailableCommandsChangedFromPlayer(int seq, in Bundle commandsBundle) = 3008; void onAvailableCommandsChangedFromSession( int seq, in Bundle sessionCommandsBundle, in Bundle playerCommandsBundle) = 3009; void onRenderedFirstFrame(int seq) = 3010; void onExtrasChanged(int seq, in Bundle extras) = 3011; - // Next Id for MediaController: 3012 + // Next Id for MediaController: 3013 void onChildrenChanged( int seq, String parentId, int itemCount, in @nullable Bundle libraryParams) = 4000; diff --git a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java index b8632b7617..989cd4a57c 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -17,20 +17,15 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; import androidx.annotation.DrawableRes; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.Bundleable; import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.util.List; /** @@ -201,38 +196,25 @@ public final class CommandButton implements Bundleable { // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_SESSION_COMMAND, - FIELD_PLAYER_COMMAND, - FIELD_ICON_RES_ID, - FIELD_DISPLAY_NAME, - FIELD_EXTRAS, - FIELD_ENABLED - }) - private @interface FieldNumber {} - - private static final int FIELD_SESSION_COMMAND = 0; - private static final int FIELD_PLAYER_COMMAND = 1; - private static final int FIELD_ICON_RES_ID = 2; - private static final int FIELD_DISPLAY_NAME = 3; - private static final int FIELD_EXTRAS = 4; - private static final int FIELD_ENABLED = 5; + private static final String FIELD_SESSION_COMMAND = Util.intToStringMaxRadix(0); + private static final String FIELD_PLAYER_COMMAND = Util.intToStringMaxRadix(1); + private static final String FIELD_ICON_RES_ID = Util.intToStringMaxRadix(2); + private static final String FIELD_DISPLAY_NAME = Util.intToStringMaxRadix(3); + private static final String FIELD_EXTRAS = Util.intToStringMaxRadix(4); + private static final String FIELD_ENABLED = Util.intToStringMaxRadix(5); @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); if (sessionCommand != null) { - bundle.putBundle(keyForField(FIELD_SESSION_COMMAND), sessionCommand.toBundle()); + bundle.putBundle(FIELD_SESSION_COMMAND, sessionCommand.toBundle()); } - bundle.putInt(keyForField(FIELD_PLAYER_COMMAND), playerCommand); - bundle.putInt(keyForField(FIELD_ICON_RES_ID), iconResId); - bundle.putCharSequence(keyForField(FIELD_DISPLAY_NAME), displayName); - bundle.putBundle(keyForField(FIELD_EXTRAS), extras); - bundle.putBoolean(keyForField(FIELD_ENABLED), isEnabled); + bundle.putInt(FIELD_PLAYER_COMMAND, playerCommand); + bundle.putInt(FIELD_ICON_RES_ID, iconResId); + bundle.putCharSequence(FIELD_DISPLAY_NAME, displayName); + bundle.putBundle(FIELD_EXTRAS, extras); + bundle.putBoolean(FIELD_ENABLED, isEnabled); return bundle; } @@ -240,7 +222,7 @@ public final class CommandButton implements Bundleable { @UnstableApi public static final Creator CREATOR = CommandButton::fromBundle; private static CommandButton fromBundle(Bundle bundle) { - @Nullable Bundle sessionCommandBundle = bundle.getBundle(keyForField(FIELD_SESSION_COMMAND)); + @Nullable Bundle sessionCommandBundle = bundle.getBundle(FIELD_SESSION_COMMAND); @Nullable SessionCommand sessionCommand = sessionCommandBundle == null @@ -248,13 +230,11 @@ public final class CommandButton implements Bundleable { : SessionCommand.CREATOR.fromBundle(sessionCommandBundle); @Player.Command int playerCommand = - bundle.getInt( - keyForField(FIELD_PLAYER_COMMAND), /* defaultValue= */ Player.COMMAND_INVALID); - int iconResId = bundle.getInt(keyForField(FIELD_ICON_RES_ID), /* defaultValue= */ 0); - CharSequence displayName = - bundle.getCharSequence(keyForField(FIELD_DISPLAY_NAME), /* defaultValue= */ ""); - @Nullable Bundle extras = bundle.getBundle(keyForField(FIELD_EXTRAS)); - boolean enabled = bundle.getBoolean(keyForField(FIELD_ENABLED), /* defaultValue= */ false); + bundle.getInt(FIELD_PLAYER_COMMAND, /* defaultValue= */ Player.COMMAND_INVALID); + int iconResId = bundle.getInt(FIELD_ICON_RES_ID, /* defaultValue= */ 0); + CharSequence displayName = bundle.getCharSequence(FIELD_DISPLAY_NAME, /* defaultValue= */ ""); + @Nullable Bundle extras = bundle.getBundle(FIELD_EXTRAS); + boolean enabled = bundle.getBoolean(FIELD_ENABLED, /* defaultValue= */ false); Builder builder = new Builder(); if (sessionCommand != null) { builder.setSessionCommand(sessionCommand); @@ -269,8 +249,4 @@ public final class CommandButton implements Bundleable { .setEnabled(enabled) .build(); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java b/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java index 6b09d056a5..037baff628 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java @@ -17,17 +17,12 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; -import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Bundle; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.Bundleable; import androidx.media3.common.MediaLibraryInfo; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import androidx.media3.common.util.Util; /** * Created by {@link MediaController} to send its state to the {@link MediaSession} to request to @@ -69,47 +64,34 @@ import java.lang.annotation.Target; // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_LIBRARY_VERSION, - FIELD_PACKAGE_NAME, - FIELD_PID, - FIELD_CONNECTION_HINTS, - FIELD_CONTROLLER_INTERFACE_VERSION - }) - private @interface FieldNumber {} - - private static final int FIELD_LIBRARY_VERSION = 0; - private static final int FIELD_PACKAGE_NAME = 1; - private static final int FIELD_PID = 2; - private static final int FIELD_CONNECTION_HINTS = 3; - private static final int FIELD_CONTROLLER_INTERFACE_VERSION = 4; + private static final String FIELD_LIBRARY_VERSION = Util.intToStringMaxRadix(0); + private static final String FIELD_PACKAGE_NAME = Util.intToStringMaxRadix(1); + private static final String FIELD_PID = Util.intToStringMaxRadix(2); + private static final String FIELD_CONNECTION_HINTS = Util.intToStringMaxRadix(3); + private static final String FIELD_CONTROLLER_INTERFACE_VERSION = Util.intToStringMaxRadix(4); // Next id: 5 @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_LIBRARY_VERSION), libraryVersion); - bundle.putString(keyForField(FIELD_PACKAGE_NAME), packageName); - bundle.putInt(keyForField(FIELD_PID), pid); - bundle.putBundle(keyForField(FIELD_CONNECTION_HINTS), connectionHints); - bundle.putInt(keyForField(FIELD_CONTROLLER_INTERFACE_VERSION), controllerInterfaceVersion); + bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion); + bundle.putString(FIELD_PACKAGE_NAME, packageName); + bundle.putInt(FIELD_PID, pid); + bundle.putBundle(FIELD_CONNECTION_HINTS, connectionHints); + bundle.putInt(FIELD_CONTROLLER_INTERFACE_VERSION, controllerInterfaceVersion); return bundle; } /** Object that can restore {@link ConnectionRequest} from a {@link Bundle}. */ public static final Creator CREATOR = bundle -> { - int libraryVersion = - bundle.getInt(keyForField(FIELD_LIBRARY_VERSION), /* defaultValue= */ 0); + int libraryVersion = bundle.getInt(FIELD_LIBRARY_VERSION, /* defaultValue= */ 0); int controllerInterfaceVersion = - bundle.getInt(keyForField(FIELD_CONTROLLER_INTERFACE_VERSION), /* defaultValue= */ 0); - String packageName = checkNotNull(bundle.getString(keyForField(FIELD_PACKAGE_NAME))); - int pid = bundle.getInt(keyForField(FIELD_PID), /* defaultValue= */ 0); + bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0); + String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME)); + int pid = bundle.getInt(FIELD_PID, /* defaultValue= */ 0); checkArgument(pid != 0); - @Nullable Bundle connectionHints = bundle.getBundle(keyForField(FIELD_CONNECTION_HINTS)); + @Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS); return new ConnectionRequest( libraryVersion, controllerInterfaceVersion, @@ -117,8 +99,4 @@ import java.lang.annotation.Target; pid, connectionHints == null ? Bundle.EMPTY : connectionHints); }; - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java index afb57b623f..c681ab4420 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java @@ -16,20 +16,15 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkNotNull; -import static java.lang.annotation.ElementType.TYPE_USE; import android.app.PendingIntent; import android.os.Bundle; import android.os.IBinder; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.core.app.BundleCompat; import androidx.media3.common.Bundleable; import androidx.media3.common.Player; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import androidx.media3.common.util.Util; /** * Created by {@link MediaSession} to send its state to the {@link MediaController} when the @@ -78,59 +73,34 @@ import java.lang.annotation.Target; // Bundleable implementation. - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - FIELD_LIBRARY_VERSION, - FIELD_SESSION_BINDER, - FIELD_SESSION_ACTIVITY, - FIELD_SESSION_COMMANDS, - FIELD_PLAYER_COMMANDS_FROM_SESSION, - FIELD_PLAYER_COMMANDS_FROM_PLAYER, - FIELD_TOKEN_EXTRAS, - FIELD_PLAYER_INFO, - FIELD_SESSION_INTERFACE_VERSION, - }) - private @interface FieldNumber {} - - private static final int FIELD_LIBRARY_VERSION = 0; - private static final int FIELD_SESSION_BINDER = 1; - private static final int FIELD_SESSION_ACTIVITY = 2; - private static final int FIELD_SESSION_COMMANDS = 3; - private static final int FIELD_PLAYER_COMMANDS_FROM_SESSION = 4; - private static final int FIELD_PLAYER_COMMANDS_FROM_PLAYER = 5; - private static final int FIELD_TOKEN_EXTRAS = 6; - private static final int FIELD_PLAYER_INFO = 7; - private static final int FIELD_SESSION_INTERFACE_VERSION = 8; + private static final String FIELD_LIBRARY_VERSION = Util.intToStringMaxRadix(0); + private static final String FIELD_SESSION_BINDER = Util.intToStringMaxRadix(1); + private static final String FIELD_SESSION_ACTIVITY = Util.intToStringMaxRadix(2); + private static final String FIELD_SESSION_COMMANDS = Util.intToStringMaxRadix(3); + private static final String FIELD_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4); + private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5); + private static final String FIELD_TOKEN_EXTRAS = Util.intToStringMaxRadix(6); + private static final String FIELD_PLAYER_INFO = Util.intToStringMaxRadix(7); + private static final String FIELD_SESSION_INTERFACE_VERSION = Util.intToStringMaxRadix(8); // Next field key = 9 @Override public Bundle toBundle() { Bundle bundle = new Bundle(); - bundle.putInt(keyForField(FIELD_LIBRARY_VERSION), libraryVersion); - BundleCompat.putBinder(bundle, keyForField(FIELD_SESSION_BINDER), sessionBinder.asBinder()); - bundle.putParcelable(keyForField(FIELD_SESSION_ACTIVITY), sessionActivity); - bundle.putBundle(keyForField(FIELD_SESSION_COMMANDS), sessionCommands.toBundle()); + bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion); + BundleCompat.putBinder(bundle, FIELD_SESSION_BINDER, sessionBinder.asBinder()); + bundle.putParcelable(FIELD_SESSION_ACTIVITY, sessionActivity); + bundle.putBundle(FIELD_SESSION_COMMANDS, sessionCommands.toBundle()); + bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle()); + bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle()); + bundle.putBundle(FIELD_TOKEN_EXTRAS, tokenExtras); + Player.Commands intersectedCommands = + MediaUtils.intersect(playerCommandsFromSession, playerCommandsFromPlayer); bundle.putBundle( - keyForField(FIELD_PLAYER_COMMANDS_FROM_SESSION), playerCommandsFromSession.toBundle()); - bundle.putBundle( - keyForField(FIELD_PLAYER_COMMANDS_FROM_PLAYER), playerCommandsFromPlayer.toBundle()); - bundle.putBundle(keyForField(FIELD_TOKEN_EXTRAS), tokenExtras); - bundle.putBundle( - keyForField(FIELD_PLAYER_INFO), + FIELD_PLAYER_INFO, playerInfo.toBundle( - /* excludeMediaItems= */ !playerCommandsFromPlayer.contains(Player.COMMAND_GET_TIMELINE) - || !playerCommandsFromSession.contains(Player.COMMAND_GET_TIMELINE), - /* excludeMediaItemsMetadata= */ !playerCommandsFromPlayer.contains( - Player.COMMAND_GET_MEDIA_ITEMS_METADATA) - || !playerCommandsFromSession.contains(Player.COMMAND_GET_MEDIA_ITEMS_METADATA), - /* excludeCues= */ !playerCommandsFromPlayer.contains(Player.COMMAND_GET_TEXT) - || !playerCommandsFromSession.contains(Player.COMMAND_GET_TEXT), - /* excludeTimeline= */ false, - /* excludeTracks= */ !playerCommandsFromPlayer.contains(Player.COMMAND_GET_TRACKS) - || !playerCommandsFromSession.contains(Player.COMMAND_GET_TRACKS))); - bundle.putInt(keyForField(FIELD_SESSION_INTERFACE_VERSION), sessionInterfaceVersion); + intersectedCommands, /* excludeTimeline= */ false, /* excludeTracks= */ false)); + bundle.putInt(FIELD_SESSION_INTERFACE_VERSION, sessionInterfaceVersion); return bundle; } @@ -138,34 +108,30 @@ import java.lang.annotation.Target; public static final Creator CREATOR = ConnectionState::fromBundle; private static ConnectionState fromBundle(Bundle bundle) { - int libraryVersion = bundle.getInt(keyForField(FIELD_LIBRARY_VERSION), /* defaultValue= */ 0); + int libraryVersion = bundle.getInt(FIELD_LIBRARY_VERSION, /* defaultValue= */ 0); int sessionInterfaceVersion = - bundle.getInt(keyForField(FIELD_SESSION_INTERFACE_VERSION), /* defaultValue= */ 0); - IBinder sessionBinder = - checkNotNull(BundleCompat.getBinder(bundle, keyForField(FIELD_SESSION_BINDER))); - @Nullable - PendingIntent sessionActivity = bundle.getParcelable(keyForField(FIELD_SESSION_ACTIVITY)); - @Nullable Bundle sessionCommandsBundle = bundle.getBundle(keyForField(FIELD_SESSION_COMMANDS)); + bundle.getInt(FIELD_SESSION_INTERFACE_VERSION, /* defaultValue= */ 0); + IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER)); + @Nullable PendingIntent sessionActivity = bundle.getParcelable(FIELD_SESSION_ACTIVITY); + @Nullable Bundle sessionCommandsBundle = bundle.getBundle(FIELD_SESSION_COMMANDS); SessionCommands sessionCommands = sessionCommandsBundle == null ? SessionCommands.EMPTY : SessionCommands.CREATOR.fromBundle(sessionCommandsBundle); @Nullable - Bundle playerCommandsFromPlayerBundle = - bundle.getBundle(keyForField(FIELD_PLAYER_COMMANDS_FROM_PLAYER)); + Bundle playerCommandsFromPlayerBundle = bundle.getBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER); Player.Commands playerCommandsFromPlayer = playerCommandsFromPlayerBundle == null ? Player.Commands.EMPTY : Player.Commands.CREATOR.fromBundle(playerCommandsFromPlayerBundle); @Nullable - Bundle playerCommandsFromSessionBundle = - bundle.getBundle(keyForField(FIELD_PLAYER_COMMANDS_FROM_SESSION)); + Bundle playerCommandsFromSessionBundle = bundle.getBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION); Player.Commands playerCommandsFromSession = playerCommandsFromSessionBundle == null ? Player.Commands.EMPTY : Player.Commands.CREATOR.fromBundle(playerCommandsFromSessionBundle); - @Nullable Bundle tokenExtras = bundle.getBundle(keyForField(FIELD_TOKEN_EXTRAS)); - @Nullable Bundle playerInfoBundle = bundle.getBundle(keyForField(FIELD_PLAYER_INFO)); + @Nullable Bundle tokenExtras = bundle.getBundle(FIELD_TOKEN_EXTRAS); + @Nullable Bundle playerInfoBundle = bundle.getBundle(FIELD_PLAYER_INFO); PlayerInfo playerInfo = playerInfoBundle == null ? PlayerInfo.DEFAULT @@ -181,8 +147,4 @@ import java.lang.annotation.Target; tokenExtras == null ? Bundle.EMPTY : tokenExtras, playerInfo); } - - private static String keyForField(@FieldNumber int field) { - return Integer.toString(field, Character.MAX_RADIX); - } } diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index 008ba329eb..b6c487fcd2 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -54,7 +54,6 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; @@ -84,8 +83,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * *

    Drawables

    * - * The drawables used can be overridden by drawables with the same names defined the application. - * The drawables are: + * The drawables used can be overridden by drawables with the same file names in {@code + * res/drawables} of the application module. Alternatively, you can override the drawable resource + * ID with a {@code drawable} element in a resource file in {@code res/values}. The drawable + * resource IDs are: * *