Commit 72a4fb08 by Ian Baker Committed by GitHub

Merge pull request #52 from androidx/release-1.0

r1.0.0-alpha03
parents 850bd69d 58324934
Showing with 374 additions and 296 deletions
...@@ -94,15 +94,13 @@ to prevent build errors. ...@@ -94,15 +94,13 @@ to prevent build errors.
Cloning the repository and depending on the modules locally is required when Cloning the repository and depending on the modules locally is required when
using some libraries. It's also a suitable approach if you want to make local using some libraries. It's also a suitable approach if you want to make local
changes, or if you want to use the main branch. changes, or if you want to use the `main` branch.
First, clone the repository into a local directory and checkout the desired First, clone the repository into a local directory:
branch:
```sh ```sh
git clone https://github.com/androidx/media.git git clone https://github.com/androidx/media.git
cd media cd media
git checkout main
``` ```
Next, add the following to your project's `settings.gradle` file, replacing Next, add the following to your project's `settings.gradle` file, replacing
...@@ -129,7 +127,7 @@ implementation project(':media-lib-ui') ...@@ -129,7 +127,7 @@ implementation project(':media-lib-ui')
Development work happens on the `main` branch. Pull requests should normally be Development work happens on the `main` branch. Pull requests should normally be
made to this branch. made to this branch.
We plan to add a release branch soon. The `release` branch holds the most recent stable release.
#### Using Android Studio #### Using Android Studio
......
# Release notes # Release notes
### 1.0.0-alpha02 (2022-03-09) ### 1.0.0-alpha03 (2022-03-14)
This release corresponds to the
[ExoPlayer 2.17.1 release](https://github.com/google/ExoPlayer/releases/tag/r2.17.1).
* Audio:
* Fix error checking audio capabilities for Dolby Atmos (E-AC3-JOC) in
HLS.
* Extractors:
* FMP4: Fix issue where emsg sample metadata could be output in the wrong
order for streams containing both v0 and v1 emsg atoms
([#9996](https://github.com/google/ExoPlayer/issues/9996)).
* Text:
* Fix the interaction of `SingleSampleMediaSource.Factory.setTrackId` and
`MediaItem.SubtitleConfiguration.Builder.setId` to prioritise the
`SubtitleConfiguration` field and fall back to the `Factory` value if
it's not set
([#10016](https://github.com/google/ExoPlayer/issues/10016)).
* Ad playback:
* Fix audio underruns between ad periods in live HLS SSAI streams.
### 1.0.0-alpha02 (2022-03-02)
This release corresponds to the
[ExoPlayer 2.17.0 release](https://github.com/google/ExoPlayer/releases/tag/r2.17.0).
* Core Library: * Core Library:
* Add protected method `DefaultRenderersFactory.getCodecAdapterFactory()` * Add protected method `DefaultRenderersFactory.getCodecAdapterFactory()`
...@@ -18,7 +42,7 @@ ...@@ -18,7 +42,7 @@
from a secure codec to another codec from a secure codec to another codec
([#8696](https://github.com/google/ExoPlayer/issues/8696)). ([#8696](https://github.com/google/ExoPlayer/issues/8696)).
* Add `MediaCodecAdapter.getMetrics()` to allow users obtain metrics data * Add `MediaCodecAdapter.getMetrics()` to allow users obtain metrics data
from `MediaCodec`. from `MediaCodec`
([#9766](https://github.com/google/ExoPlayer/issues/9766)). ([#9766](https://github.com/google/ExoPlayer/issues/9766)).
* Fix Maven dependency resolution * Fix Maven dependency resolution
([#8353](https://github.com/google/ExoPlayer/issues/8353)). ([#8353](https://github.com/google/ExoPlayer/issues/8353)).
...@@ -63,17 +87,17 @@ ...@@ -63,17 +87,17 @@
under sufficient network bandwidth even if playback is very close to the under sufficient network bandwidth even if playback is very close to the
live edge ([#9784](https://github.com/google/ExoPlayer/issues/9784)). live edge ([#9784](https://github.com/google/ExoPlayer/issues/9784)).
* Video: * Video:
* Fix decoder fallback logic for Dolby Vision * Fix decoder fallback logic for Dolby Vision to use a compatible
to use a compatible H264/H265 decoder if needed. H264/H265 decoder if needed.
* Audio: * Audio:
* Fix decoder fallback logic for Dolby Atmos (E-AC3-JOC) * Fix decoder fallback logic for Dolby Atmos (E-AC3-JOC) to use a
to use a compatible E-AC3 decoder if needed. compatible E-AC3 decoder if needed.
* Change `AudioCapabilities` APIs to require passing explicitly * Change `AudioCapabilities` APIs to require passing explicitly
`AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES` instead of `null`. `AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES` instead of `null`.
* Allow customization of the `AudioTrack` buffer size calculation by * Allow customization of the `AudioTrack` buffer size calculation by
injecting an `AudioTrackBufferSizeProvider` to `DefaultAudioSink`. injecting an `AudioTrackBufferSizeProvider` to `DefaultAudioSink`
([#8891](https://github.com/google/ExoPlayer/issues/8891)). ([#8891](https://github.com/google/ExoPlayer/issues/8891)).
* Retry `AudioTrack` creation if the requested buffer size was > 1MB. * Retry `AudioTrack` creation if the requested buffer size was > 1MB
([#9712](https://github.com/google/ExoPlayer/issues/9712)). ([#9712](https://github.com/google/ExoPlayer/issues/9712)).
* Extractors: * Extractors:
* WAV: Add support for RF64 streams * WAV: Add support for RF64 streams
...@@ -120,7 +144,8 @@ ...@@ -120,7 +144,8 @@
* Support the `forced-subtitle` track role * Support the `forced-subtitle` track role
([#9727](https://github.com/google/ExoPlayer/issues/9727)). ([#9727](https://github.com/google/ExoPlayer/issues/9727)).
* Stop interpreting the `main` track role as `C.SELECTION_FLAG_DEFAULT`. * Stop interpreting the `main` track role as `C.SELECTION_FLAG_DEFAULT`.
* Fix base URL exclusion logic for manifests that do not declare the DVB namespace ([#9856](https://github.com/google/ExoPlayer/issues/9856)). * Fix base URL exclusion logic for manifests that do not declare the DVB
namespace ([#9856](https://github.com/google/ExoPlayer/issues/9856)).
* Support relative `MPD.Location` URLs * Support relative `MPD.Location` URLs
([#9939](https://github.com/google/ExoPlayer/issues/9939)). ([#9939](https://github.com/google/ExoPlayer/issues/9939)).
* HLS: * HLS:
...@@ -133,8 +158,6 @@ ...@@ -133,8 +158,6 @@
`HlsMediaSource.Factory.setAllowChunklessPreparation(false)`. `HlsMediaSource.Factory.setAllowChunklessPreparation(false)`.
* Support key-frame accurate seeking in HLS * Support key-frame accurate seeking in HLS
([#2882](https://github.com/google/ExoPlayer/issues/2882)). ([#2882](https://github.com/google/ExoPlayer/issues/2882)).
* Correctly populate `Format.label` for audio only HLS streams
([#9608](https://github.com/google/ExoPlayer/issues/9608)).
* RTSP: * RTSP:
* Provide a client API to override the `SocketFactory` used for any server * Provide a client API to override the `SocketFactory` used for any server
connection ([#9606](https://github.com/google/ExoPlayer/pull/9606)). connection ([#9606](https://github.com/google/ExoPlayer/pull/9606)).
...@@ -154,12 +177,10 @@ ...@@ -154,12 +177,10 @@
* Fix potential NPE in `Transformer.getProgress` when releasing the muxer * Fix potential NPE in `Transformer.getProgress` when releasing the muxer
throws. throws.
* Add a demo app for applying transformations. * Add a demo app for applying transformations.
* The transformer module is no longer included by depending on
`com.google.android.exoplayer:exoplayer`. To continue using transformer,
add an additional dependency on
`com.google.android.exoplayer:exoplayer-transformer`.
* MediaSession extension: * MediaSession extension:
* By default, `MediaSessionConnector` now clears the playlist on stop. Apps that want the playlist to be retained can call `setClearMediaItemsOnStop(false)` on the connector. * By default, `MediaSessionConnector` now clears the playlist on stop.
Apps that want the playlist to be retained can call
`setClearMediaItemsOnStop(false)` on the connector.
* Cast extension: * Cast extension:
* Fix bug that prevented `CastPlayer` from calling `onIsPlayingChanged` * Fix bug that prevented `CastPlayer` from calling `onIsPlayingChanged`
correctly ([#9792](https://github.com/google/ExoPlayer/issues/9792)). correctly ([#9792](https://github.com/google/ExoPlayer/issues/9792)).
...@@ -178,38 +199,38 @@ ...@@ -178,38 +199,38 @@
([#9528](https://github.com/google/ExoPlayer/issues/9528)). ([#9528](https://github.com/google/ExoPlayer/issues/9528)).
* Remove deprecated symbols: * Remove deprecated symbols:
* Remove `Player.EventLister`. Use `Player.Listener` instead. * Remove `Player.EventLister`. Use `Player.Listener` instead.
* Remove `MediaSourceFactory#setDrmSessionManager`, * Remove `MediaSourceFactory.setDrmSessionManager`,
`MediaSourceFactory#setDrmHttpDataSourceFactory`, and `MediaSourceFactory.setDrmHttpDataSourceFactory`, and
`MediaSourceFactory#setDrmUserAgent`. Use `MediaSourceFactory.setDrmUserAgent`. Use
`MediaSourceFactory#setDrmSessionManagerProvider` instead. `MediaSourceFactory.setDrmSessionManagerProvider` instead.
* Remove `MediaSourceFactory#setStreamKeys`. Use * Remove `MediaSourceFactory.setStreamKeys`. Use
`MediaItem.Builder#setStreamKeys` instead. `MediaItem.Builder.setStreamKeys` instead.
* Remove `MediaSourceFactory#createMediaSource(Uri)`. Use * Remove `MediaSourceFactory.createMediaSource(Uri)`. Use
`MediaSourceFactory#createMediaSource(MediaItem)` instead. `MediaSourceFactory.createMediaSource(MediaItem)` instead.
* Remove `setTag` from `DashMediaSource`, `HlsMediaSource` and * Remove `setTag` from `DashMediaSource`, `HlsMediaSource` and
`SsMediaSource`. Use `MediaItem.Builder#setTag` instead. `SsMediaSource`. Use `MediaItem.Builder.setTag` instead.
* Remove `DashMediaSource#setLivePresentationDelayMs(long, boolean)`. Use * Remove `DashMediaSource.setLivePresentationDelayMs(long, boolean)`. Use
`MediaItem.Builder#setLiveConfiguration` and `MediaItem.Builder.setLiveConfiguration` and
`MediaItem.LiveConfiguration.Builder#setTargetOffsetMs` to override the `MediaItem.LiveConfiguration.Builder.setTargetOffsetMs` to override the
manifest, or `DashMediaSource#setFallbackTargetLiveOffsetMs` to provide manifest, or `DashMediaSource.setFallbackTargetLiveOffsetMs` to provide
a fallback value. a fallback value.
* Remove `(Simple)ExoPlayer.setThrowsWhenUsingWrongThread`. Opting out of * Remove `(Simple)ExoPlayer.setThrowsWhenUsingWrongThread`. Opting out of
the thread enforcement is no longer possible. the thread enforcement is no longer possible.
* Remove `ActionFile` and `ActionFileUpgradeUtil`. Use ExoPlayer 2.16.1 or * Remove `ActionFile` and `ActionFileUpgradeUtil`. Use ExoPlayer 2.16.1 or
before to use `ActionFileUpgradeUtil` to merge legacy action files into before to use `ActionFileUpgradeUtil` to merge legacy action files into
`DefaultDownloadIndex`. `DefaultDownloadIndex`.
* Remove `ProgressiveMediaSource#setExtractorsFactory`. Use * Remove `ProgressiveMediaSource.setExtractorsFactory`. Use
`ProgressiveMediaSource.Factory(DataSource.Factory, ExtractorsFactory)` `ProgressiveMediaSource.Factory(DataSource.Factory, ExtractorsFactory)`
constructor instead. constructor instead.
* Remove `ProgressiveMediaSource.Factory#setTag` and, and * Remove `ProgressiveMediaSource.Factory.setTag` and
`ProgressiveMediaSource.Factory#setCustomCacheKey`. Use `ProgressiveMediaSource.Factory.setCustomCacheKey`. Use
`MediaItem.Builder#setTag` and `MediaItem.Builder#setCustomCacheKey` `MediaItem.Builder.setTag` and `MediaItem.Builder.setCustomCacheKey`
instead. instead.
* Remove `DefaultRenderersFactory(Context, @ExtensionRendererMode int)` * Remove `DefaultRenderersFactory(Context, @ExtensionRendererMode int)`
and `DefaultRenderersFactory(Context, @ExtensionRendererMode int, long)` and `DefaultRenderersFactory(Context, @ExtensionRendererMode int, long)`
constructors. Use the `DefaultRenderersFactory(Context)` constructor, constructors. Use the `DefaultRenderersFactory(Context)` constructor,
`DefaultRenderersFactory#setExtensionRendererMode`, and `DefaultRenderersFactory.setExtensionRendererMode`, and
`DefaultRenderersFactory#setAllowedVideoJoiningTimeMs` instead. `DefaultRenderersFactory.setAllowedVideoJoiningTimeMs` instead.
* Remove all public `CronetDataSource` constructors. Use * Remove all public `CronetDataSource` constructors. Use
`CronetDataSource.Factory` instead. `CronetDataSource.Factory` instead.
* Change the following `IntDefs` to `@Target(TYPE_USE)` only. This may break * Change the following `IntDefs` to `@Target(TYPE_USE)` only. This may break
......
...@@ -12,8 +12,8 @@ ...@@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
project.ext { project.ext {
releaseVersion = '1.0.0-alpha02' releaseVersion = '1.0.0-alpha03'
releaseVersionCode = 1_000_000_0_02 releaseVersionCode = 1_000_000_0_03
minSdkVersion = 16 minSdkVersion = 16
appTargetSdkVersion = 29 appTargetSdkVersion = 29
// Upgrading this requires [Internal ref: b/193254928] to be fixed, or some // Upgrading this requires [Internal ref: b/193254928] to be fixed, or some
......
...@@ -30,6 +30,7 @@ import android.widget.ListView ...@@ -30,6 +30,7 @@ import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaBrowser import androidx.media3.session.MediaBrowser
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
...@@ -179,6 +180,9 @@ class PlayableFolderActivity : AppCompatActivity() { ...@@ -179,6 +180,9 @@ class PlayableFolderActivity : AppCompatActivity() {
returnConvertView.findViewById<TextView>(R.id.add_button).setOnClickListener { returnConvertView.findViewById<TextView>(R.id.add_button).setOnClickListener {
val browser = this@PlayableFolderActivity.browser ?: return@setOnClickListener val browser = this@PlayableFolderActivity.browser ?: return@setOnClickListener
browser.addMediaItem(mediaItem) browser.addMediaItem(mediaItem)
if (browser.playbackState == Player.STATE_IDLE) {
browser.prepare()
}
Snackbar.make( Snackbar.make(
findViewById<LinearLayout>(R.id.linear_layout), findViewById<LinearLayout>(R.id.linear_layout),
getString(R.string.added_media_item_format, mediaItem.mediaMetadata.title), getString(R.string.added_media_item_format, mediaItem.mediaMetadata.title),
......
...@@ -96,7 +96,6 @@ class PlaybackService : MediaLibraryService() { ...@@ -96,7 +96,6 @@ class PlaybackService : MediaLibraryService() {
val item = MediaItemTree.getItemFromTitle(mediaTitle) ?: MediaItemTree.getRandomItem() val item = MediaItemTree.getItemFromTitle(mediaTitle) ?: MediaItemTree.getRandomItem()
player.setMediaItem(item) player.setMediaItem(item)
player.prepare()
} }
override fun onSetMediaUri( override fun onSetMediaUri(
......
#Wed Mar 04 12:41:50 GMT 2020
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https://services.gradle.org/distributions/gradle-7.3.3-all.zip
@if "%DEBUG%" == "" @echo off @rem
@rem ########################################################################## @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Gradle startup script for Windows @rem Licensed under the Apache License, Version 2.0 (the "License");
@rem @rem you may not use this file except in compliance with the License.
@rem ########################################################################## @rem You may obtain a copy of the License at
@rem
@rem Set local scope for the variables with windows NT shell @rem https://www.apache.org/licenses/LICENSE-2.0
if "%OS%"=="Windows_NT" setlocal @rem
@rem Unless required by applicable law or agreed to in writing, software
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem distributed under the License is distributed on an "AS IS" BASIS,
set DEFAULT_JVM_OPTS= @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
set DIRNAME=%~dp0 @rem limitations under the License.
if "%DIRNAME%" == "" set DIRNAME=. @rem
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% @if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem Find java.exe @rem
if defined JAVA_HOME goto findJavaFromJavaHome @rem Gradle startup script for Windows
@rem
set JAVA_EXE=java.exe @rem ##########################################################################
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. set DIRNAME=%~dp0
echo. if "%DIRNAME%" == "" set DIRNAME=.
echo Please set the JAVA_HOME variable in your environment to match the set APP_BASE_NAME=%~n0
echo location of your Java installation. set APP_HOME=%DIRNAME%
goto fail @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
if exist "%JAVA_EXE%" goto init @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% set JAVA_EXE=java.exe
echo. %JAVA_EXE% -version >NUL 2>&1
echo Please set the JAVA_HOME variable in your environment to match the if "%ERRORLEVEL%" == "0" goto execute
echo location of your Java installation.
echo.
goto fail echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
:init echo Please set the JAVA_HOME variable in your environment to match the
@rem Get command-line arguments, handling Windowz variants echo location of your Java installation.
if not "%OS%" == "Windows_NT" goto win9xME_args goto fail
if "%@eval[2+2]" == "4" goto 4NT_args
:findJavaFromJavaHome
:win9xME_args set JAVA_HOME=%JAVA_HOME:"=%
@rem Slurp the command line arguments. set JAVA_EXE=%JAVA_HOME%/bin/java.exe
set CMD_LINE_ARGS=
set _SKIP=2 if exist "%JAVA_EXE%" goto execute
:win9xME_args_slurp echo.
if "x%~1" == "x" goto execute echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
set CMD_LINE_ARGS=%* echo Please set the JAVA_HOME variable in your environment to match the
goto execute echo location of your Java installation.
:4NT_args goto fail
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$ :execute
@rem Setup the command line
:execute
@rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
@rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
:end @rem End local scope for the variables with windows NT shell
@rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
:fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code!
rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1
exit /b 1
:mainEnd
:mainEnd if "%OS%"=="Windows_NT" endlocal
if "%OS%"=="Windows_NT" endlocal
:omega
:omega
...@@ -29,11 +29,11 @@ public final class MediaLibraryInfo { ...@@ -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". */ /** 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. // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa.
public static final String VERSION = "1.0.0-alpha02"; public static final String VERSION = "1.0.0-alpha03";
/** The version of the library expressed as {@code TAG + "/" + VERSION}. */ /** The version of the library expressed as {@code TAG + "/" + VERSION}. */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-alpha02"; public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-alpha03";
/** /**
* The version of the library expressed as an integer, for example 1002003300. * The version of the library expressed as an integer, for example 1002003300.
...@@ -47,7 +47,7 @@ public final class MediaLibraryInfo { ...@@ -47,7 +47,7 @@ public final class MediaLibraryInfo {
* (123-045-006-3-00). * (123-045-006-3-00).
*/ */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
public static final int VERSION_INT = 1_000_000_0_02; public static final int VERSION_INT = 1_000_000_0_03;
/** Whether the library was compiled with {@link Assertions} checks enabled. */ /** Whether the library was compiled with {@link Assertions} checks enabled. */
public static final boolean ASSERTIONS_ENABLED = true; public static final boolean ASSERTIONS_ENABLED = true;
......
...@@ -56,6 +56,7 @@ import androidx.media3.exoplayer.DefaultMediaClock.PlaybackParametersListener; ...@@ -56,6 +56,7 @@ import androidx.media3.exoplayer.DefaultMediaClock.PlaybackParametersListener;
import androidx.media3.exoplayer.analytics.AnalyticsCollector; import androidx.media3.exoplayer.analytics.AnalyticsCollector;
import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.drm.DrmSession; import androidx.media3.exoplayer.drm.DrmSession;
import androidx.media3.exoplayer.metadata.MetadataRenderer;
import androidx.media3.exoplayer.source.BehindLiveWindowException; import androidx.media3.exoplayer.source.BehindLiveWindowException;
import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
...@@ -2228,6 +2229,7 @@ import java.util.concurrent.atomic.AtomicBoolean; ...@@ -2228,6 +2229,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
return reading.info.isFollowedByTransitionToSameStream return reading.info.isFollowedByTransitionToSameStream
&& nextPeriod.prepared && nextPeriod.prepared
&& (renderer instanceof TextRenderer // [internal: b/181312195] && (renderer instanceof TextRenderer // [internal: b/181312195]
|| renderer instanceof MetadataRenderer
|| renderer.getReadingPositionUs() >= nextPeriod.getStartPositionRendererTime()); || renderer.getReadingPositionUs() >= nextPeriod.getStartPositionRendererTime());
} }
......
...@@ -1739,8 +1739,11 @@ public final class DefaultAudioSink implements AudioSink { ...@@ -1739,8 +1739,11 @@ public final class DefaultAudioSink implements AudioSink {
// the channel count for this encoding, but before then there is no way to query it so we // the channel count for this encoding, but before then there is no way to query it so we
// assume 6 channel audio is supported. // assume 6 channel audio is supported.
if (Util.SDK_INT >= 29) { if (Util.SDK_INT >= 29) {
// Default to 48 kHz if the format doesn't have a sample rate (for example, for chunkless
// HLS preparation). See [Internal: b/222127949].
int sampleRate = format.sampleRate != Format.NO_VALUE ? format.sampleRate : 48000;
channelCount = channelCount =
getMaxSupportedChannelCountForPassthroughV29(C.ENCODING_E_AC3_JOC, format.sampleRate); getMaxSupportedChannelCountForPassthroughV29(C.ENCODING_E_AC3_JOC, sampleRate);
if (channelCount == 0) { if (channelCount == 0) {
Log.w(TAG, "E-AC3 JOC encoding supported but no channel count supported"); Log.w(TAG, "E-AC3 JOC encoding supported but no channel count supported");
return null; return null;
......
...@@ -24,6 +24,8 @@ import static java.lang.annotation.ElementType.TYPE_USE; ...@@ -24,6 +24,8 @@ import static java.lang.annotation.ElementType.TYPE_USE;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import java.lang.annotation.Documented; import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/** Thrown when the requested DRM scheme is not supported. */ /** Thrown when the requested DRM scheme is not supported. */
...@@ -35,8 +37,9 @@ public final class UnsupportedDrmException extends Exception { ...@@ -35,8 +37,9 @@ public final class UnsupportedDrmException extends Exception {
* #REASON_INSTANTIATION_ERROR}. * #REASON_INSTANTIATION_ERROR}.
*/ */
// @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility
// with Kotlin usages from before TYPE_USE was added. @Retention(RetentionPolicy.SOURCE) // with Kotlin usages from before TYPE_USE was added.
@Documented @Documented
@Retention(RetentionPolicy.SOURCE)
@Target({FIELD, METHOD, PARAMETER, LOCAL_VARIABLE, TYPE_USE}) @Target({FIELD, METHOD, PARAMETER, LOCAL_VARIABLE, TYPE_USE})
@IntDef({REASON_UNSUPPORTED_SCHEME, REASON_INSTANTIATION_ERROR}) @IntDef({REASON_UNSUPPORTED_SCHEME, REASON_INSTANTIATION_ERROR})
public @interface Reason {} public @interface Reason {}
......
...@@ -74,11 +74,12 @@ public final class SingleSampleMediaSource extends BaseMediaSource { ...@@ -74,11 +74,12 @@ public final class SingleSampleMediaSource extends BaseMediaSource {
} }
/** /**
* Sets an optional track id to be used. * @deprecated Use {@link MediaItem.SubtitleConfiguration.Builder#setId(String)} instead (on the
* * {@link MediaItem.SubtitleConfiguration} passed to {@link
* @param trackId An optional track id. * #createMediaSource(MediaItem.SubtitleConfiguration, long)}). {@code trackId} will only be
* @return This factory, for convenience. * used if {@link MediaItem.SubtitleConfiguration#id} is {@code null}.
*/ */
@Deprecated
public Factory setTrackId(@Nullable String trackId) { public Factory setTrackId(@Nullable String trackId) {
this.trackId = trackId; this.trackId = trackId;
return this; return this;
...@@ -157,29 +158,28 @@ public final class SingleSampleMediaSource extends BaseMediaSource { ...@@ -157,29 +158,28 @@ public final class SingleSampleMediaSource extends BaseMediaSource {
this.durationUs = durationUs; this.durationUs = durationUs;
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream;
mediaItem = this.mediaItem =
new MediaItem.Builder() new MediaItem.Builder()
.setUri(Uri.EMPTY) .setUri(Uri.EMPTY)
.setMediaId(subtitleConfiguration.uri.toString()) .setMediaId(subtitleConfiguration.uri.toString())
.setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)) .setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration))
.setTag(tag) .setTag(tag)
.build(); .build();
format = this.format =
new Format.Builder() new Format.Builder()
.setId(trackId)
.setSampleMimeType(firstNonNull(subtitleConfiguration.mimeType, MimeTypes.TEXT_UNKNOWN)) .setSampleMimeType(firstNonNull(subtitleConfiguration.mimeType, MimeTypes.TEXT_UNKNOWN))
.setLanguage(subtitleConfiguration.language) .setLanguage(subtitleConfiguration.language)
.setSelectionFlags(subtitleConfiguration.selectionFlags) .setSelectionFlags(subtitleConfiguration.selectionFlags)
.setRoleFlags(subtitleConfiguration.roleFlags) .setRoleFlags(subtitleConfiguration.roleFlags)
.setLabel(subtitleConfiguration.label) .setLabel(subtitleConfiguration.label)
.setId(subtitleConfiguration.id) .setId(subtitleConfiguration.id != null ? subtitleConfiguration.id : trackId)
.build(); .build();
dataSpec = this.dataSpec =
new DataSpec.Builder() new DataSpec.Builder()
.setUri(subtitleConfiguration.uri) .setUri(subtitleConfiguration.uri)
.setFlags(DataSpec.FLAG_ALLOW_GZIP) .setFlags(DataSpec.FLAG_ALLOW_GZIP)
.build(); .build();
timeline = this.timeline =
new SinglePeriodTimeline( new SinglePeriodTimeline(
durationUs, durationUs,
/* isSeekable= */ true, /* isSeekable= */ true,
......
...@@ -105,11 +105,7 @@ import java.util.Map; ...@@ -105,11 +105,7 @@ import java.util.Map;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** /** MediaSource for IMA server side inserted ad streams. */
* MediaSource for IMA server side inserted ad streams.
*
* <p>TODO(bachinger) add code snippet from PlayerActivity
*/
@UnstableApi @UnstableApi
public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSource<Void> { public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSource<Void> {
...@@ -119,8 +115,6 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou ...@@ -119,8 +115,6 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou
* *
* <p>Apps can use the {@link ImaServerSideAdInsertionMediaSource.Factory} to customized the * <p>Apps can use the {@link ImaServerSideAdInsertionMediaSource.Factory} to customized the
* {@link DefaultMediaSourceFactory} that is used to build a player: * {@link DefaultMediaSourceFactory} that is used to build a player:
*
* <p>TODO(bachinger) add code snippet from PlayerActivity
*/ */
public static final class Factory implements MediaSource.Factory { public static final class Factory implements MediaSource.Factory {
...@@ -461,6 +455,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou ...@@ -461,6 +455,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou
@Nullable private IOException loadError; @Nullable private IOException loadError;
private @MonotonicNonNull Timeline contentTimeline; private @MonotonicNonNull Timeline contentTimeline;
private AdPlaybackState adPlaybackState; private AdPlaybackState adPlaybackState;
private int firstSeenAdIndexInAdGroup;
private ImaServerSideAdInsertionMediaSource( private ImaServerSideAdInsertionMediaSource(
MediaItem mediaItem, MediaItem mediaItem,
...@@ -698,18 +693,21 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou ...@@ -698,18 +693,21 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou
return adPlaybackState; return adPlaybackState;
} }
private static AdPlaybackState addLiveAdBreak( private AdPlaybackState addLiveAdBreak(
Ad ad, long currentPeriodPositionUs, AdPlaybackState adPlaybackState) { Ad ad, long currentPeriodPositionUs, AdPlaybackState adPlaybackState) {
AdPodInfo adPodInfo = ad.getAdPodInfo(); AdPodInfo adPodInfo = ad.getAdPodInfo();
long adDurationUs = secToUs(ad.getDuration()); long adDurationUs = secToUs(ad.getDuration());
int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
// TODO(b/208398934) Support seeking backwards. // TODO(b/208398934) Support seeking backwards.
if (adIndexInAdGroup == 0 || adPlaybackState.adGroupCount == 1) { if (adIndexInAdGroup == 0 || adPlaybackState.adGroupCount == 1) {
firstSeenAdIndexInAdGroup = adIndexInAdGroup;
// Adjust count and ad index in case we joined the live stream within an ad group.
int adCount = adPodInfo.getTotalAds() - firstSeenAdIndexInAdGroup;
adIndexInAdGroup -= firstSeenAdIndexInAdGroup;
// First ad of group. Create a new group with all ads. // First ad of group. Create a new group with all ads.
long[] adDurationsUs = long[] adDurationsUs =
updateAdDurationAndPropagate( updateAdDurationAndPropagate(
new long[adPodInfo.getTotalAds()], new long[adCount],
adIndexInAdGroup, adIndexInAdGroup,
adDurationUs, adDurationUs,
secToUs(adPodInfo.getMaxDuration())); secToUs(adPodInfo.getMaxDuration()));
...@@ -721,6 +719,11 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou ...@@ -721,6 +719,11 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou
/* adDurationsUs...= */ adDurationsUs); /* adDurationsUs...= */ adDurationsUs);
} else { } else {
int adGroupIndex = adPlaybackState.adGroupCount - 2; int adGroupIndex = adPlaybackState.adGroupCount - 2;
adIndexInAdGroup -= firstSeenAdIndexInAdGroup;
if (adPodInfo.getTotalAds() == adPodInfo.getAdPosition()) {
// Reset the ad index whe we are at the last ad in the group.
firstSeenAdIndexInAdGroup = 0;
}
adPlaybackState = adPlaybackState =
updateAdDurationInAdGroup(adGroupIndex, adIndexInAdGroup, adDurationUs, adPlaybackState); updateAdDurationInAdGroup(adGroupIndex, adIndexInAdGroup, adDurationUs, adPlaybackState);
AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
...@@ -857,7 +860,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou ...@@ -857,7 +860,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou
long positionInWindowUs = long positionInWindowUs =
timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()) timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.positionInWindowUs; .positionInWindowUs;
long currentPeriodPosition = msToUs(player.getCurrentPosition()) - positionInWindowUs; long currentPeriodPosition = msToUs(player.getContentPosition()) - positionInWindowUs;
newAdPlaybackState = newAdPlaybackState =
addLiveAdBreak( addLiveAdBreak(
event.getAd(), event.getAd(),
......
...@@ -666,14 +666,23 @@ public class FragmentedMp4Extractor implements Extractor { ...@@ -666,14 +666,23 @@ public class FragmentedMp4Extractor implements Extractor {
emsgTrackOutput.sampleData(encodedEventMessage, sampleSize); emsgTrackOutput.sampleData(encodedEventMessage, sampleSize);
} }
// Output the sample metadata. This is made a little complicated because emsg-v0 atoms // Output the sample metadata.
// have presentation time *delta* while v1 atoms have absolute presentation time.
if (sampleTimeUs == C.TIME_UNSET) { if (sampleTimeUs == C.TIME_UNSET) {
// We need the first sample timestamp in the segment before we can output the metadata. // We're processing a v0 emsg atom, which contains a presentation time delta, and cannot yet
// calculate its absolute sample timestamp. Defer outputting the metadata until we can.
pendingMetadataSampleInfos.addLast( pendingMetadataSampleInfos.addLast(
new MetadataSampleInfo(presentationTimeDeltaUs, sampleSize)); new MetadataSampleInfo(
presentationTimeDeltaUs, /* sampleTimeIsRelative= */ true, sampleSize));
pendingMetadataSampleBytes += sampleSize;
} else if (!pendingMetadataSampleInfos.isEmpty()) {
// We also need to defer outputting metadata if pendingMetadataSampleInfos is non-empty, else
// we will output metadata for samples in the wrong order. See:
// https://github.com/google/ExoPlayer/issues/9996.
pendingMetadataSampleInfos.addLast(
new MetadataSampleInfo(sampleTimeUs, /* sampleTimeIsRelative= */ false, sampleSize));
pendingMetadataSampleBytes += sampleSize; pendingMetadataSampleBytes += sampleSize;
} else { } else {
// We can output the sample metadata immediately.
if (timestampAdjuster != null) { if (timestampAdjuster != null) {
sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs);
} }
...@@ -1459,19 +1468,30 @@ public class FragmentedMp4Extractor implements Extractor { ...@@ -1459,19 +1468,30 @@ public class FragmentedMp4Extractor implements Extractor {
return true; return true;
} }
/**
* Called immediately after outputting a non-metadata sample, to output any pending metadata
* samples.
*
* @param sampleTimeUs The timestamp of the non-metadata sample that was just output.
*/
private void outputPendingMetadataSamples(long sampleTimeUs) { private void outputPendingMetadataSamples(long sampleTimeUs) {
while (!pendingMetadataSampleInfos.isEmpty()) { while (!pendingMetadataSampleInfos.isEmpty()) {
MetadataSampleInfo sampleInfo = pendingMetadataSampleInfos.removeFirst(); MetadataSampleInfo metadataSampleInfo = pendingMetadataSampleInfos.removeFirst();
pendingMetadataSampleBytes -= sampleInfo.size; pendingMetadataSampleBytes -= metadataSampleInfo.size;
long metadataTimeUs = sampleTimeUs + sampleInfo.presentationTimeDeltaUs; long metadataSampleTimeUs = metadataSampleInfo.sampleTimeUs;
if (metadataSampleInfo.sampleTimeIsRelative) {
// The metadata sample timestamp is relative to the timestamp of the non-metadata sample
// that was just output. Make it absolute.
metadataSampleTimeUs += sampleTimeUs;
}
if (timestampAdjuster != null) { if (timestampAdjuster != null) {
metadataTimeUs = timestampAdjuster.adjustSampleTimestamp(metadataTimeUs); metadataSampleTimeUs = timestampAdjuster.adjustSampleTimestamp(metadataSampleTimeUs);
} }
for (TrackOutput emsgTrackOutput : emsgTrackOutputs) { for (TrackOutput emsgTrackOutput : emsgTrackOutputs) {
emsgTrackOutput.sampleMetadata( emsgTrackOutput.sampleMetadata(
metadataTimeUs, metadataSampleTimeUs,
C.BUFFER_FLAG_KEY_FRAME, C.BUFFER_FLAG_KEY_FRAME,
sampleInfo.size, metadataSampleInfo.size,
pendingMetadataSampleBytes, pendingMetadataSampleBytes,
null); null);
} }
...@@ -1577,11 +1597,13 @@ public class FragmentedMp4Extractor implements Extractor { ...@@ -1577,11 +1597,13 @@ public class FragmentedMp4Extractor implements Extractor {
/** Holds data corresponding to a metadata sample. */ /** Holds data corresponding to a metadata sample. */
private static final class MetadataSampleInfo { private static final class MetadataSampleInfo {
public final long presentationTimeDeltaUs; public final long sampleTimeUs;
public final boolean sampleTimeIsRelative;
public final int size; public final int size;
public MetadataSampleInfo(long presentationTimeDeltaUs, int size) { public MetadataSampleInfo(long sampleTimeUs, boolean sampleTimeIsRelative, int size) {
this.presentationTimeDeltaUs = presentationTimeDeltaUs; this.sampleTimeUs = sampleTimeUs;
this.sampleTimeIsRelative = sampleTimeIsRelative;
this.size = size; this.size = size;
} }
} }
......
...@@ -27,6 +27,7 @@ import android.os.Bundle; ...@@ -27,6 +27,7 @@ import android.os.Bundle;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
...@@ -93,20 +94,21 @@ import androidx.media3.common.util.Util; ...@@ -93,20 +94,21 @@ import androidx.media3.common.util.Util;
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_previous), IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_previous),
context.getString(R.string.media3_controls_seek_to_previous_description), context.getString(R.string.media3_controls_seek_to_previous_description),
MediaNotification.ActionFactory.COMMAND_SKIP_TO_PREVIOUS)); MediaNotification.ActionFactory.COMMAND_SKIP_TO_PREVIOUS));
if (mediaController.getPlayWhenReady()) { if (mediaController.getPlaybackState() == Player.STATE_ENDED
// Pause action. || !mediaController.getPlayWhenReady()) {
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
context.getString(R.string.media3_controls_pause_description),
MediaNotification.ActionFactory.COMMAND_PAUSE));
} else {
// Play action. // Play action.
builder.addAction( builder.addAction(
actionFactory.createMediaAction( actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_play), IconCompat.createWithResource(context, R.drawable.media3_notification_play),
context.getString(R.string.media3_controls_play_description), context.getString(R.string.media3_controls_play_description),
MediaNotification.ActionFactory.COMMAND_PLAY)); MediaNotification.ActionFactory.COMMAND_PLAY));
} else {
// Pause action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
context.getString(R.string.media3_controls_pause_description),
MediaNotification.ActionFactory.COMMAND_PAUSE));
} }
// Skip to next action. // Skip to next action.
builder.addAction( builder.addAction(
......
...@@ -198,7 +198,7 @@ import java.util.concurrent.atomic.AtomicReference; ...@@ -198,7 +198,7 @@ import java.util.concurrent.atomic.AtomicReference;
try { try {
int page = options.getInt(EXTRA_PAGE); int page = options.getInt(EXTRA_PAGE);
int pageSize = options.getInt(EXTRA_PAGE_SIZE); int pageSize = options.getInt(EXTRA_PAGE_SIZE);
if (page > 0 && pageSize > 0) { if (page >= 0 && pageSize > 0) {
// Requesting the list of children through pagination. // Requesting the list of children through pagination.
@Nullable @Nullable
LibraryParams params = LibraryParams params =
...@@ -223,7 +223,7 @@ import java.util.concurrent.atomic.AtomicReference; ...@@ -223,7 +223,7 @@ import java.util.concurrent.atomic.AtomicReference;
parentId, parentId,
/* page= */ 0, /* page= */ 0,
/* pageSize= */ Integer.MAX_VALUE, /* pageSize= */ Integer.MAX_VALUE,
/* extras= */ null); /* params= */ null);
sendLibraryResultWithMediaItemsWhenReady(result, future); sendLibraryResultWithMediaItemsWhenReady(result, future);
}); });
} }
......
...@@ -196,7 +196,9 @@ import java.util.concurrent.TimeoutException; ...@@ -196,7 +196,9 @@ import java.util.concurrent.TimeoutException;
@Override @Override
public void onEvents(Player player, Player.Events events) { public void onEvents(Player player, Player.Events events) {
if (events.containsAny( if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED)) { Player.EVENT_PLAYBACK_STATE_CHANGED,
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_MEDIA_METADATA_CHANGED)) {
updateNotification(session); updateNotification(session);
} }
} }
......
...@@ -28,6 +28,8 @@ import static androidx.media3.common.Player.COMMAND_SET_REPEAT_MODE; ...@@ -28,6 +28,8 @@ import static androidx.media3.common.Player.COMMAND_SET_REPEAT_MODE;
import static androidx.media3.common.Player.COMMAND_SET_SHUFFLE_MODE; import static androidx.media3.common.Player.COMMAND_SET_SHUFFLE_MODE;
import static androidx.media3.common.Player.COMMAND_SET_SPEED_AND_PITCH; import static androidx.media3.common.Player.COMMAND_SET_SPEED_AND_PITCH;
import static androidx.media3.common.Player.COMMAND_STOP; import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.Player.STATE_IDLE;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.common.util.Util.postOrRun;
...@@ -231,7 +233,17 @@ import org.checkerframework.checker.initialization.qual.Initialized; ...@@ -231,7 +233,17 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} else { } else {
dispatchSessionTaskWithPlayerCommand( dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE, COMMAND_PLAY_PAUSE,
(controller) -> sessionImpl.getPlayerWrapper().play(), (controller) -> {
PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
@Player.State int playbackState = playerWrapper.getPlaybackState();
if (playbackState == STATE_IDLE) {
playerWrapper.prepare();
} else if (playbackState == STATE_ENDED) {
playerWrapper.seekTo(
playerWrapper.getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET);
}
playerWrapper.play();
},
remoteUserInfo); remoteUserInfo);
} }
} }
...@@ -285,7 +297,17 @@ import org.checkerframework.checker.initialization.qual.Initialized; ...@@ -285,7 +297,17 @@ import org.checkerframework.checker.initialization.qual.Initialized;
public void onPlay() { public void onPlay() {
dispatchSessionTaskWithPlayerCommand( dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE, COMMAND_PLAY_PAUSE,
controller -> sessionImpl.getPlayerWrapper().play(), controller -> {
PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
@Player.State int playbackState = playerWrapper.getPlaybackState();
if (playbackState == Player.STATE_IDLE) {
playerWrapper.prepare();
} else if (playbackState == Player.STATE_ENDED) {
playerWrapper.seekTo(
playerWrapper.getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET);
}
playerWrapper.play();
},
sessionCompat.getCurrentControllerInfo()); sessionCompat.getCurrentControllerInfo());
} }
...@@ -321,7 +343,15 @@ import org.checkerframework.checker.initialization.qual.Initialized; ...@@ -321,7 +343,15 @@ import org.checkerframework.checker.initialization.qual.Initialized;
if (sessionImpl.onSetMediaUriOnHandler( if (sessionImpl.onSetMediaUriOnHandler(
controller, mediaUri, extras == null ? Bundle.EMPTY : extras) controller, mediaUri, extras == null ? Bundle.EMPTY : extras)
== RESULT_SUCCESS) { == RESULT_SUCCESS) {
sessionImpl.getPlayerWrapper().play(); PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
@Player.State int playbackState = playerWrapper.getPlaybackState();
if (playbackState == Player.STATE_IDLE) {
playerWrapper.prepare();
} else if (playbackState == STATE_ENDED) {
playerWrapper.seekTo(
playerWrapper.getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET);
}
playerWrapper.play();
} }
}); });
} }
......
...@@ -181,7 +181,6 @@ public class MediaSessionAndControllerTest { ...@@ -181,7 +181,6 @@ public class MediaSessionAndControllerTest {
MockPlayer player = MockPlayer player =
new MockPlayer.Builder() new MockPlayer.Builder()
.setApplicationLooper(threadTestRule.getHandler().getLooper()) .setApplicationLooper(threadTestRule.getHandler().getLooper())
.setLatchCount(1)
.build(); .build();
MediaSession session = MediaSession session =
sessionTestRule.ensureReleaseAfterTest( sessionTestRule.ensureReleaseAfterTest(
...@@ -190,8 +189,7 @@ public class MediaSessionAndControllerTest { ...@@ -190,8 +189,7 @@ public class MediaSessionAndControllerTest {
threadTestRule.getHandler().postAndSync(controller::play); threadTestRule.getHandler().postAndSync(controller::play);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.playCalled).isTrue();
} }
@Test @Test
......
...@@ -74,7 +74,6 @@ public class MediaSessionCallbackTest { ...@@ -74,7 +74,6 @@ public class MediaSessionCallbackTest {
context = ApplicationProvider.getApplicationContext(); context = ApplicationProvider.getApplicationContext();
player = player =
new MockPlayer.Builder() new MockPlayer.Builder()
.setLatchCount(1)
.setApplicationLooper(threadTestRule.getHandler().getLooper()) .setApplicationLooper(threadTestRule.getHandler().getLooper())
.build(); .build();
} }
...@@ -157,15 +156,14 @@ public class MediaSessionCallbackTest { ...@@ -157,15 +156,14 @@ public class MediaSessionCallbackTest {
controllerTestRule.createRemoteController(session.getToken()); controllerTestRule.createRemoteController(session.getToken());
controller.prepare(); controller.prepare();
assertThat(player.countDownLatch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse(); Thread.sleep(NO_RESPONSE_TIMEOUT_MS);
assertThat(player.prepareCalled).isFalse(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isFalse();
assertThat(commands).hasSize(1); assertThat(commands).hasSize(1);
assertThat(commands.get(0)).isEqualTo(Player.COMMAND_PREPARE); assertThat(commands.get(0)).isEqualTo(Player.COMMAND_PREPARE);
controller.play(); controller.play();
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.playCalled).isTrue(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isFalse();
assertThat(player.prepareCalled).isFalse();
assertThat(commands).hasSize(2); assertThat(commands).hasSize(2);
assertThat(commands.get(1)).isEqualTo(Player.COMMAND_PLAY_PAUSE); assertThat(commands.get(1)).isEqualTo(Player.COMMAND_PLAY_PAUSE);
} }
......
...@@ -89,8 +89,7 @@ public class MediaSessionKeyEventTest { ...@@ -89,8 +89,7 @@ public class MediaSessionKeyEventTest {
Context context = ApplicationProvider.getApplicationContext(); Context context = ApplicationProvider.getApplicationContext();
audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
handler = threadTestRule.getHandler(); handler = threadTestRule.getHandler();
player = player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build();
new MockPlayer.Builder().setLatchCount(1).setApplicationLooper(handler.getLooper()).build();
sessionCallback = new TestSessionCallback(); sessionCallback = new TestSessionCallback();
session = new MediaSession.Builder(context, player).setSessionCallback(sessionCallback).build(); session = new MediaSession.Builder(context, player).setSessionCallback(sessionCallback).build();
...@@ -120,7 +119,7 @@ public class MediaSessionKeyEventTest { ...@@ -120,7 +119,7 @@ public class MediaSessionKeyEventTest {
} }
@After @After
public void cleanUp() throws Exception { public void tearDown() throws Exception {
handler.postAndSync( handler.postAndSync(
() -> { () -> {
if (mediaPlayer != null) { if (mediaPlayer != null) {
...@@ -131,55 +130,46 @@ public class MediaSessionKeyEventTest { ...@@ -131,55 +130,46 @@ public class MediaSessionKeyEventTest {
session.release(); session.release();
} }
private void dispatchMediaKeyEvent(int keyCode, boolean doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
if (doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
}
@Test @Test
public void playKeyEvent() throws Exception { public void playKeyEvent() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.playCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
} }
@Test @Test
public void pauseKeyEvent() throws Exception { public void pauseKeyEvent() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PAUSE, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PAUSE, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.pauseCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
} }
@Test @Test
public void nextKeyEvent() throws Exception { public void nextKeyEvent() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_NEXT, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_NEXT, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.seekToNextCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS);
} }
@Test @Test
public void previousKeyEvent() throws Exception { public void previousKeyEvent() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PREVIOUS, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PREVIOUS, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.seekToPreviousCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS);
} }
@Test @Test
public void stopKeyEvent() throws Exception { public void stopKeyEvent() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STOP, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STOP, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.stopCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS);
} }
@Test @Test
public void playPauseKeyEvent_play() throws Exception { public void playPauseKeyEvent_play() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.playCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
} }
@Test @Test
...@@ -188,18 +178,28 @@ public class MediaSessionKeyEventTest { ...@@ -188,18 +178,28 @@ public class MediaSessionKeyEventTest {
() -> { () -> {
player.playWhenReady = true; player.playWhenReady = true;
}); });
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.pauseCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
} }
@Test @Test
public void playPauseKeyEvent_doubleTapIsTranslatedToSkipToNext() throws Exception { public void playPauseKeyEvent_doubleTapIsTranslatedToSkipToNext() throws Exception {
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, true); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, true);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.seekToNextCalled).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS);
assertThat(player.playCalled).isFalse(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PLAY)).isFalse();
assertThat(player.pauseCalled).isFalse(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PAUSE)).isFalse();
}
private void dispatchMediaKeyEvent(int keyCode, boolean doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
if (doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
} }
private static class TestSessionCallback implements MediaSession.SessionCallback { private static class TestSessionCallback implements MediaSession.SessionCallback {
......
...@@ -88,7 +88,7 @@ public class MediaSessionPermissionTest { ...@@ -88,7 +88,7 @@ public class MediaSessionPermissionTest {
} }
@After @After
public void cleanUp() { public void tearDown() {
if (session != null) { if (session != null) {
session.release(); session.release();
session = null; session = null;
...@@ -97,55 +97,6 @@ public class MediaSessionPermissionTest { ...@@ -97,55 +97,6 @@ public class MediaSessionPermissionTest {
callback = null; callback = null;
} }
private void createSessionWithAvailableCommands(
SessionCommands sessionCommands, Player.Commands playerCommands) {
player =
new MockPlayer.Builder()
.setLatchCount(1)
.setApplicationLooper(threadTestRule.getHandler().getLooper())
.build();
callback =
new MySessionCallback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
if (!TextUtils.equals(SUPPORT_APP_PACKAGE_NAME, controller.getPackageName())) {
return MediaSession.ConnectionResult.reject();
}
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands);
}
};
if (this.session != null) {
this.session.release();
}
this.session =
new MediaSession.Builder(context, player)
.setId(SESSION_ID)
.setSessionCallback(callback)
.build();
}
private SessionCommands createSessionCommandsWith(SessionCommand command) {
return new SessionCommands.Builder().add(command).build();
}
private void testOnCommandRequest(int commandCode, PermissionTestTask runnable) throws Exception {
createSessionWithAvailableCommands(
SessionCommands.EMPTY, createPlayerCommandsWith(commandCode));
runnable.run(controllerTestRule.createRemoteController(session.getToken()));
assertThat(callback.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(callback.onCommandRequestCalled).isTrue();
assertThat(callback.command).isEqualTo(commandCode);
createSessionWithAvailableCommands(
SessionCommands.EMPTY, createPlayerCommandsWithout(commandCode));
runnable.run(controllerTestRule.createRemoteController(session.getToken()));
assertThat(callback.countDownLatch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse();
assertThat(callback.onCommandRequestCalled).isFalse();
}
@Test @Test
public void play() throws Exception { public void play() throws Exception {
testOnCommandRequest(COMMAND_PLAY_PAUSE, RemoteMediaController::play); testOnCommandRequest(COMMAND_PLAY_PAUSE, RemoteMediaController::play);
...@@ -409,4 +360,52 @@ public class MediaSessionPermissionTest { ...@@ -409,4 +360,52 @@ public class MediaSessionPermissionTest {
return Futures.immediateFuture(new SessionResult(RESULT_SUCCESS)); return Futures.immediateFuture(new SessionResult(RESULT_SUCCESS));
} }
} }
private void createSessionWithAvailableCommands(
SessionCommands sessionCommands, Player.Commands playerCommands) {
player =
new MockPlayer.Builder()
.setApplicationLooper(threadTestRule.getHandler().getLooper())
.build();
callback =
new MySessionCallback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
if (!TextUtils.equals(SUPPORT_APP_PACKAGE_NAME, controller.getPackageName())) {
return MediaSession.ConnectionResult.reject();
}
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands);
}
};
if (this.session != null) {
this.session.release();
}
this.session =
new MediaSession.Builder(context, player)
.setId(SESSION_ID)
.setSessionCallback(callback)
.build();
}
private SessionCommands createSessionCommandsWith(SessionCommand command) {
return new SessionCommands.Builder().add(command).build();
}
private void testOnCommandRequest(int commandCode, PermissionTestTask runnable) throws Exception {
createSessionWithAvailableCommands(
SessionCommands.EMPTY, createPlayerCommandsWith(commandCode));
runnable.run(controllerTestRule.createRemoteController(session.getToken()));
assertThat(callback.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(callback.onCommandRequestCalled).isTrue();
assertThat(callback.command).isEqualTo(commandCode);
createSessionWithAvailableCommands(
SessionCommands.EMPTY, createPlayerCommandsWithout(commandCode));
runnable.run(controllerTestRule.createRemoteController(session.getToken()));
assertThat(callback.countDownLatch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse();
assertThat(callback.onCommandRequestCalled).isFalse();
}
} }
...@@ -47,6 +47,7 @@ import java.util.concurrent.ExecutionException; ...@@ -47,6 +47,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Rule; import org.junit.Rule;
...@@ -78,8 +79,7 @@ public class MediaSessionTest { ...@@ -78,8 +79,7 @@ public class MediaSessionTest {
public void setUp() throws Exception { public void setUp() throws Exception {
context = ApplicationProvider.getApplicationContext(); context = ApplicationProvider.getApplicationContext();
handler = threadTestRule.getHandler(); handler = threadTestRule.getHandler();
player = player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build();
new MockPlayer.Builder().setLatchCount(1).setApplicationLooper(handler.getLooper()).build();
session = session =
sessionTestRule.ensureReleaseAfterTest( sessionTestRule.ensureReleaseAfterTest(
...@@ -107,6 +107,16 @@ public class MediaSessionTest { ...@@ -107,6 +107,16 @@ public class MediaSessionTest {
.get(TIMEOUT_MS, MILLISECONDS); .get(TIMEOUT_MS, MILLISECONDS);
} }
@After
public void tearDown() throws Exception {
if ((controller != null)) {
threadTestRule.getHandler().postAndSync(() -> controller.release());
}
if (session != null) {
session.release();
}
}
@Test @Test
public void builder() { public void builder() {
MediaSession.Builder builder; MediaSession.Builder builder;
...@@ -394,8 +404,7 @@ public class MediaSessionTest { ...@@ -394,8 +404,7 @@ public class MediaSessionTest {
long testSeekPositionMs = 1234; long testSeekPositionMs = 1234;
controllerCompat.getTransportControls().seekTo(testSeekPositionMs); controllerCompat.getTransportControls().seekTo(testSeekPositionMs);
assertThat(player.countDownLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO, TIMEOUT_MS);
assertThat(player.seekToCalled).isTrue();
assertThat(player.seekPositionMs).isEqualTo(testSeekPositionMs); assertThat(player.seekPositionMs).isEqualTo(testSeekPositionMs);
} }
......
...@@ -134,8 +134,7 @@ public class MockMediaLibraryService extends MediaLibraryService { ...@@ -134,8 +134,7 @@ public class MockMediaLibraryService extends MediaLibraryService {
return (MediaLibrarySession) onGetSessionHandler.onGetSession(controllerInfo); return (MediaLibrarySession) onGetSessionHandler.onGetSession(controllerInfo);
} }
MockPlayer player = MockPlayer player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build();
new MockPlayer.Builder().setLatchCount(1).setApplicationLooper(handler.getLooper()).build();
MediaLibrarySessionCallback callback = registry.getSessionCallback(); MediaLibrarySessionCallback callback = registry.getSessionCallback();
session = session =
......
...@@ -156,22 +156,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; ...@@ -156,22 +156,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
* <li>Corresponding method: {@link #setKeepContentOnPlayerReset(boolean)} * <li>Corresponding method: {@link #setKeepContentOnPlayerReset(boolean)}
* <li>Default: {@code false} * <li>Default: {@code false}
* </ul> * </ul>
* <li><b>{@code player_layout_id}</b> - Specifies the id of the layout to be inflated. See below
* for more details.
* <ul>
* <li>Corresponding method: None
* <li>Default: {@code R.layout.exo_player_view}
* </ul>
* <li><b>{@code controller_layout_id}</b> - Specifies the id of the layout resource to be
* inflated by the child {@link PlayerControlView}. See below for more details.
* <ul>
* <li>Corresponding method: None
* <li>Default: {@code R.layout.exo_player_control_view}
* </ul>
* <li>All attributes that can be set on {@link PlayerControlView} and {@link DefaultTimeBar} can * <li>All attributes that can be set on {@link PlayerControlView} and {@link DefaultTimeBar} can
* also be set on a PlayerView, and will be propagated to the inflated {@link * also be set on a PlayerView, and will be propagated to the inflated {@link
* PlayerControlView} unless the layout is overridden to specify a custom {@code * PlayerControlView} unless the layout is overridden to specify a custom {@code
* exo_controller} (see below). * exo_controller}.
* </ul> * </ul>
* *
* <h2>Overriding drawables</h2> * <h2>Overriding drawables</h2>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment