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 32da706cec..68838ea29c 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Format.java +++ b/libraries/common/src/main/java/androidx/media3/common/Format.java @@ -19,6 +19,8 @@ import static com.google.common.math.DoubleMath.fuzzyEquals; import static java.lang.annotation.ElementType.TYPE_USE; +import android.media.AudioPresentation; +import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import androidx.annotation.IntDef; @@ -116,6 +118,7 @@ *
  • {@link #pcmEncoding} *
  • {@link #encoderDelay} *
  • {@link #encoderPadding} + *
  • {@link #audioPresentations} * * *

    Fields relevant to text formats

    @@ -193,7 +196,7 @@ public static final class Builder { private @C.PcmEncoding int pcmEncoding; private int encoderDelay; private int encoderPadding; - + private List audioPresentations; // Text specific. private int accessibilityChannel; @@ -239,6 +242,7 @@ public Builder() { // Provided by the source. cryptoType = C.CRYPTO_TYPE_NONE; auxiliaryTrackType = C.AUXILIARY_TRACK_TYPE_UNDEFINED; + audioPresentations = ImmutableList.of(); } /** @@ -286,6 +290,7 @@ private Builder(Format format) { this.pcmEncoding = format.pcmEncoding; this.encoderDelay = format.encoderDelay; this.encoderPadding = format.encoderPadding; + this.audioPresentations = format.audioPresentations; // Text specific. this.accessibilityChannel = format.accessibilityChannel; this.cueReplacementBehavior = format.cueReplacementBehavior; @@ -766,6 +771,18 @@ public Builder setEncoderPadding(int encoderPadding) { return this; } + /** + * Sets {@link AudioPresentation}. The default value is {@code null}. + * + * @param presentations The {@link Format#audioPresentations}. + * @return The builder. + */ + @CanIgnoreReturnValue + public Builder setAudioPresentations(List presentations) { + this.audioPresentations = ImmutableList.copyOf(presentations); + return this; + } + // Text specific. /** @@ -1104,6 +1121,11 @@ public Format build() { */ @UnstableApi public final int encoderPadding; + /** The audio presentations. Will not be null, but may be empty if the container doesn't have + * audio presentations in it. + */ + public final List audioPresentations; + // Text specific. /** The Accessibility channel, or {@link #NO_VALUE} if not known or applicable. */ @@ -1209,6 +1231,7 @@ private Format(Builder builder) { pcmEncoding = builder.pcmEncoding; encoderDelay = builder.encoderDelay == NO_VALUE ? 0 : builder.encoderDelay; encoderPadding = builder.encoderPadding == NO_VALUE ? 0 : builder.encoderPadding; + audioPresentations = builder.audioPresentations; // Text specific. accessibilityChannel = builder.accessibilityChannel; cueReplacementBehavior = builder.cueReplacementBehavior; @@ -1348,6 +1371,8 @@ public String toString() { + channelCount + ", " + sampleRate + + ", " + + audioPresentations + "])"; } @@ -1394,6 +1419,7 @@ public int hashCode() { result = 31 * result + pcmEncoding; result = 31 * result + encoderDelay; result = 31 * result + encoderPadding; + // [Omitted audioPresentations] // Text specific. result = 31 * result + accessibilityChannel; // Image specific. @@ -1447,6 +1473,7 @@ public boolean equals(@Nullable Object obj) { && Objects.equals(id, other.id) && Objects.equals(label, other.label) && labels.equals(other.labels) + && audioPresentationsEquals(other) && Objects.equals(codecs, other.codecs) && Objects.equals(containerMimeType, other.containerMimeType) && Objects.equals(sampleMimeType, other.sampleMimeType) @@ -1480,6 +1507,27 @@ public boolean initializationDataEquals(Format other) { return true; } + /** + * Returns whether the {@link #audioPresentations}s belonging to this format and {@code other} are + * equal. + * + * @param other The other format whose {@link #audioPresentations} is being compared. + * @return Whether the {@link #audioPresentations}s belonging to this format and {@code other} are + * equal. + */ + @UnstableApi + public boolean audioPresentationsEquals(Format other) { + if (audioPresentations.size() != other.audioPresentations.size()) { + return false; + } + for (int i = 0; i < audioPresentations.size(); i++) { + if (!audioPresentations.get(i).equals(other.audioPresentations.get(i))) { + return false; + } + } + return true; + } + // Utility methods /** Returns a prettier {@link String} than {@link #toString()}, intended for logging. */ @@ -1618,6 +1666,7 @@ public static String toLogString(@Nullable Format format) { private static final String FIELD_MAX_SUB_LAYERS = Util.intToStringMaxRadix(34); private static final String FIELD_DECODED_WIDTH = Util.intToStringMaxRadix(35); private static final String FIELD_DECODED_HEIGHT = Util.intToStringMaxRadix(36); + private static final String FIELD_AUDIO_PRESENTATIONS = Util.intToStringMaxRadix(37); /** * Returns a {@link Bundle} representing the information stored in this object. If {@code @@ -1672,6 +1721,11 @@ public Bundle toBundle() { bundle.putInt(FIELD_PCM_ENCODING, pcmEncoding); bundle.putInt(FIELD_ENCODER_DELAY, encoderDelay); bundle.putInt(FIELD_ENCODER_PADDING, encoderPadding); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + for (int i = 0; i < audioPresentations.size(); i++) { + bundle.putParcelable(keyForAudioPresentations(i), audioPresentations.get(i)); + } + } // Text specific. bundle.putInt(FIELD_ACCESSIBILITY_CHANNEL, accessibilityChannel); // Image specific. @@ -1742,12 +1796,21 @@ public static Format fromBundle(Bundle bundle) { builder.setColorInfo(ColorInfo.fromBundle(colorInfoBundle)); } // Audio specific. + List presentations = new ArrayList<>(); + for (int i = 0; ; i++) { + @Nullable AudioPresentation data = bundle.getParcelable(keyForAudioPresentations(i)); + if (data == null) { + break; + } + presentations.add(data); + } builder .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)) + .setAudioPresentations(presentations) // Text specific. .setAccessibilityChannel( bundle.getInt(FIELD_ACCESSIBILITY_CHANNEL, DEFAULT.accessibilityChannel)) @@ -1767,6 +1830,12 @@ private static String keyForInitializationData(int initialisationDataIndex) { + Integer.toString(initialisationDataIndex, Character.MAX_RADIX); } + private static String keyForAudioPresentations(int audioPresentationsIndex) { + return FIELD_AUDIO_PRESENTATIONS + + "_" + + Integer.toString(audioPresentationsIndex, Character.MAX_RADIX); + } + /** * Utility method to get {@code defaultValue} if {@code value} is {@code null}. {@code * defaultValue} can be {@code null}. 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 f54dc86ca9..0f02438d92 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -21,6 +21,7 @@ import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.ElementType.TYPE_USE; +import android.media.AudioPresentation; import android.os.Bundle; import android.os.Looper; import android.view.Surface; @@ -30,6 +31,7 @@ import androidx.annotation.FloatRange; import androidx.annotation.IntDef; import androidx.annotation.IntRange; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.media3.common.text.Cue; @@ -859,6 +861,14 @@ default void onMediaItemTransition( */ default void onTracksChanged(Tracks tracks) {} + /** + * Called when the audio presentations, available to the player for the current track, are + * changed. + * + * @param audioPresentations The available audio presentations. Never null, but may be empty. + */ + default void onAudioPresentationsChanged(@NonNull List audioPresentations) {} + /** * Called when the value of {@link Player#getMediaMetadata()} changes. * @@ -1539,7 +1549,8 @@ default void onMetadata(Metadata metadata) {} EVENT_CUES, EVENT_METADATA, EVENT_DEVICE_INFO_CHANGED, - EVENT_DEVICE_VOLUME_CHANGED + EVENT_DEVICE_VOLUME_CHANGED, + EVENT_AUDIO_PRESENTATIONS_CHANGED }) @interface Event {} @@ -1642,6 +1653,9 @@ default void onMetadata(Metadata metadata) {} /** {@link #getDeviceVolume()} or {@link #isDeviceMuted()} changed. */ int EVENT_DEVICE_VOLUME_CHANGED = 30; + /** Audio presentations associated with the current audio track changed. */ + int EVENT_AUDIO_PRESENTATIONS_CHANGED = 31; + /** * Commands that indicate which method calls are currently permitted on a particular {@code * Player} instance. 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 fe2a62b2f0..4124e438de 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -45,6 +45,7 @@ import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.media.AudioDeviceInfo; +import android.media.AudioPresentation; import android.media.MediaFormat; import android.os.Handler; import android.os.Looper; @@ -153,6 +154,9 @@ private final TrackSelector trackSelector; private final HandlerWrapper playbackInfoUpdateHandler; private final ExoPlayerImplInternal.PlaybackInfoUpdateListener playbackInfoUpdateListener; + private final HandlerWrapper audioPresentationsUpdateHandler; + private final ExoPlayerImplInternal.AudioPresentationsChangeListener + audioPresentationsChangeListener; private final ExoPlayerImplInternal internalPlayer; private final ListenerSet listeners; @@ -352,6 +356,12 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer) playbackInfoUpdate -> playbackInfoUpdateHandler.post(() -> handlePlaybackInfo(playbackInfoUpdate)); playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); + audioPresentationsUpdateHandler = + clock.createHandler(applicationLooper, /* callback= */ null); + audioPresentationsChangeListener = + audioPresentations -> + playbackInfoUpdateHandler.post(() -> + handleAudioPresentationsChange(audioPresentations)); analyticsCollector.setPlayer(this.wrappingPlayer, applicationLooper); PlayerId playerId = new PlayerId(builder.playerName); internalPlayer = @@ -377,7 +387,8 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer) playerId, builder.playbackLooperProvider, preloadConfiguration, - frameMetadataListener); + frameMetadataListener, + audioPresentationsChangeListener); Looper playbackLooper = internalPlayer.getPlaybackLooper(); volume = 1; @@ -463,6 +474,12 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer) } } + private void handleAudioPresentationsChange(List audioPresentations) { + listeners.queueEvent( + Player.EVENT_AUDIO_PRESENTATIONS_CHANGED, + listener -> listener.onAudioPresentationsChanged(audioPresentations)); + } + @Override public boolean isSleepingForOffload() { verifyApplicationThread(); 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 c899a7a6bf..73a2e81672 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -28,6 +28,7 @@ import static java.lang.Math.min; import android.content.Context; +import android.media.AudioPresentation; import android.media.MediaFormat; import android.os.Handler; import android.os.Looper; @@ -138,6 +139,10 @@ public interface PlaybackInfoUpdateListener { void onPlaybackInfoUpdate(ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfo); } + public interface AudioPresentationsChangeListener { + void onAudioPresentationsChanged(List audioPresentations); + } + // Internal messages private static final int MSG_SET_PLAY_WHEN_READY = 1; private static final int MSG_DO_SOME_WORK = 2; @@ -176,6 +181,7 @@ public interface PlaybackInfoUpdateListener { private static final int MSG_SET_SCRUBBING_MODE_ENABLED = 36; private static final int MSG_SEEK_COMPLETED_IN_SCRUBBING_MODE = 37; private static final int MSG_SET_SCRUBBING_MODE_PARAMETERS = 38; + private static final int MSG_AUDIO_PRESENTATIONS_CHANGED = 39; private static final long BUFFERING_MAXIMUM_INTERVAL_MS = Util.usToMs(Renderer.DEFAULT_DURATION_TO_PROGRESS_US); @@ -256,7 +262,7 @@ public interface PlaybackInfoUpdateListener { private long prewarmingMediaPeriodDiscontinuity = C.TIME_UNSET; private boolean isPrewarmingDisabledUntilNextTransition; private float volume; - + private final AudioPresentationsChangeListener audioPresentationsChangeListener; public ExoPlayerImplInternal( Context context, Renderer[] renderers, @@ -279,7 +285,8 @@ public ExoPlayerImplInternal( PlayerId playerId, @Nullable PlaybackLooperProvider playbackLooperProvider, PreloadConfiguration preloadConfiguration, - VideoFrameMetadataListener videoFrameMetadataListener) { + VideoFrameMetadataListener videoFrameMetadataListener, + AudioPresentationsChangeListener audioPresentationsChangeListener) { this.playbackInfoUpdateListener = playbackInfoUpdateListener; this.trackSelector = trackSelector; this.emptyTrackSelectorResult = emptyTrackSelectorResult; @@ -299,6 +306,7 @@ public ExoPlayerImplInternal( this.analyticsCollector = analyticsCollector; this.volume = 1f; this.scrubbingModeParameters = ScrubbingModeParameters.DEFAULT; + this.audioPresentationsChangeListener = audioPresentationsChangeListener; playbackMaybeBecameStuckAtMs = C.TIME_UNSET; lastRebufferRealtimeMs = C.TIME_UNSET; @@ -615,6 +623,11 @@ public void onPrepared(MediaPeriod source) { handler.obtainMessage(MSG_PERIOD_PREPARED, source).sendToTarget(); } + @Override + public void onAudioPresentationsChanged(List audioPresentations) { + handler.obtainMessage(MSG_AUDIO_PRESENTATIONS_CHANGED, audioPresentations).sendToTarget(); + } + @Override public void onContinueLoadingRequested(MediaPeriod source) { handler.obtainMessage(MSG_SOURCE_CONTINUE_LOADING_REQUESTED, source).sendToTarget(); @@ -735,6 +748,9 @@ public boolean handleMessage(Message msg) { case MSG_PERIOD_PREPARED: handlePeriodPrepared((MediaPeriod) msg.obj); break; + case MSG_AUDIO_PRESENTATIONS_CHANGED: + handleAudioPresentationsChanged((List) msg.obj); + break; case MSG_SOURCE_CONTINUE_LOADING_REQUESTED: handleContinueLoadingRequested((MediaPeriod) msg.obj); break; @@ -3008,6 +3024,10 @@ private void handleLoadingPeriodPrepared(MediaPeriodHolder loadingPeriodHolder) maybeContinueLoading(); } + private void handleAudioPresentationsChanged(List audioPresentations) { + audioPresentationsChangeListener.onAudioPresentationsChanged(audioPresentations); + } + private void handleContinueLoadingRequested(MediaPeriod mediaPeriod) { if (queue.isLoading(mediaPeriod)) { queue.reevaluateBuffer(rendererPositionUs); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaPeriod.java index f2ba4bb1d3..d1705eb057 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaPeriod.java @@ -19,6 +19,7 @@ import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.castNonNull; +import android.media.AudioPresentation; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.util.NullableType; @@ -29,6 +30,7 @@ import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; import java.io.IOException; +import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -249,6 +251,11 @@ public void onPrepared(MediaPeriod mediaPeriod) { } } + @Override + public void onAudioPresentationsChanged(List audioPresentations) { + castNonNull(callback).onAudioPresentationsChanged(audioPresentations); + } + private long getPreparePositionWithOverride(long preparePositionUs) { return preparePositionOverrideUs != C.TIME_UNSET ? preparePositionOverrideUs diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaPeriod.java index 2ab6a4764b..b1bf9e39c7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaPeriod.java @@ -15,6 +15,8 @@ */ package androidx.media3.exoplayer.source; +import android.media.AudioPresentation; +import androidx.annotation.NonNull; import androidx.media3.common.C; import androidx.media3.common.StreamKey; import androidx.media3.common.Timeline; @@ -55,6 +57,14 @@ interface Callback extends SequenceableLoader.Callback { * @param mediaPeriod The prepared {@link MediaPeriod}. */ void onPrepared(MediaPeriod mediaPeriod); + + /** + * Called when the audio presentations, available to the player for the current track, are + * changed. + * + * @param audioPresentations The available audio presentations. Never null, but may be empty. + */ + default void onAudioPresentationsChanged(@NonNull List audioPresentations) {} } /** diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java index dfdd90bf44..43f7697761 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java @@ -19,6 +19,7 @@ import static java.lang.Math.max; import static java.lang.Math.min; +import android.media.AudioPresentation; import android.net.Uri; import android.os.Handler; import androidx.annotation.Nullable; @@ -46,6 +47,7 @@ import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionManager; import androidx.media3.exoplayer.source.SampleQueue.UpstreamFormatChangedListener; +import androidx.media3.exoplayer.source.SampleQueue.UpstreamAudioPresentationsChangedListener; import androidx.media3.exoplayer.source.SampleStream.ReadFlags; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; @@ -71,6 +73,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -81,7 +84,8 @@ ExtractorOutput, Loader.Callback, Loader.ReleaseCallback, - UpstreamFormatChangedListener { + UpstreamFormatChangedListener, + UpstreamAudioPresentationsChangedListener { /** Listener for information about the period. */ interface Listener { @@ -792,6 +796,11 @@ public void onUpstreamFormatChanged(Format format) { handler.post(maybeFinishPrepareRunnable); } + @Override + public void onUpstreamAudioPresentationsChanged(List audioPresentations) { + handler.post(() -> updateAudioPresentations(audioPresentations)); + } + // Internal methods. private void onLengthKnown() { @@ -812,6 +821,7 @@ private TrackOutput prepareTrackOutput(TrackId id) { SampleQueue trackOutput = SampleQueue.createWithDrm(allocator, drmSessionManager, drmEventDispatcher); trackOutput.setUpstreamFormatChangeListener(this); + trackOutput.setUpstreamAudioPresentationsChangeListener(this); @NullableType TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); sampleQueueTrackIds[trackCount] = id; @@ -822,6 +832,10 @@ private TrackOutput prepareTrackOutput(TrackId id) { return trackOutput; } + private void updateAudioPresentations(List audioPresentations) { + checkNotNull(callback).onAudioPresentationsChanged(audioPresentations); + } + private void setSeekMap(SeekMap seekMap) { this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs= */ C.TIME_UNSET); durationUs = seekMap.getDurationUs(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SampleQueue.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SampleQueue.java index cb4ec71aab..b646aa19a6 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SampleQueue.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/SampleQueue.java @@ -22,6 +22,7 @@ import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT; import static java.lang.Math.max; +import android.media.AudioPresentation; import android.os.Looper; import androidx.annotation.CallSuper; import androidx.annotation.GuardedBy; @@ -50,6 +51,7 @@ import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.extractor.TrackOutput; import java.io.IOException; +import java.util.List; import java.util.Objects; /** A queue of media samples. */ @@ -67,6 +69,17 @@ public interface UpstreamFormatChangedListener { void onUpstreamFormatChanged(Format format); } + /** A listener for changes to the upstream audio presentations. */ + public interface UpstreamAudioPresentationsChangedListener { + + /** + * Called on the loading thread when an upstream audio presentation change occurs. + * + * @param audioPresentations The new upstream audio presentations. + */ + void onUpstreamAudioPresentationsChanged(List audioPresentations); + } + @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; private static final String TAG = "SampleQueue"; @@ -76,6 +89,8 @@ public interface UpstreamFormatChangedListener { @Nullable private final DrmSessionManager drmSessionManager; @Nullable private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; @Nullable private UpstreamFormatChangedListener upstreamFormatChangeListener; + @Nullable private UpstreamAudioPresentationsChangedListener + upstreamAudioPresentationsChangeListener; @Nullable private Format downstreamFormat; @Nullable private DrmSession currentDrmSession; @@ -582,6 +597,16 @@ public final void setUpstreamFormatChangeListener( upstreamFormatChangeListener = listener; } + /** + * Sets a listener to be notified of changes to the upstream audio presentations. + * + * @param listener The listener. + */ + public final void setUpstreamAudioPresentationsChangeListener( + @Nullable UpstreamAudioPresentationsChangedListener listener) { + upstreamAudioPresentationsChangeListener = listener; + } + // TrackOutput implementation. Called by the loading thread. @Override @@ -589,10 +614,21 @@ public final void format(Format format) { Format adjustedUpstreamFormat = getAdjustedUpstreamFormat(format); upstreamFormatAdjustmentRequired = false; unadjustedUpstreamFormat = format; + boolean audioPresentationsChanged = false; + if (format.sampleMimeType != null && + MimeTypes.getTrackType(format.sampleMimeType) == C.TRACK_TYPE_AUDIO) { + audioPresentationsChanged = + upstreamFormat == null ? !format.audioPresentations.isEmpty() : + !format.audioPresentations.equals(upstreamFormat.audioPresentations); + } boolean upstreamFormatChanged = setUpstreamFormat(adjustedUpstreamFormat); if (upstreamFormatChangeListener != null && upstreamFormatChanged) { upstreamFormatChangeListener.onUpstreamFormatChanged(adjustedUpstreamFormat); } + if (upstreamAudioPresentationsChangeListener != null && audioPresentationsChanged) { + upstreamAudioPresentationsChangeListener.onUpstreamAudioPresentationsChanged( + upstreamFormat.audioPresentations); + } } @Override 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 cccf8c61df..d088cafc6a 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -100,15 +100,19 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.SurfaceTexture; +import android.icu.util.ULocale; import android.media.AudioDeviceInfo; import android.media.AudioManager; +import android.media.AudioPresentation; import android.media.AudioTrack; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Pair; import android.view.Surface; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.media3.common.AdPlaybackState; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; @@ -259,6 +263,8 @@ public final class ExoPlayerTest { private static final int TIMEOUT_MS = 10_000; private static final String SAMPLE_URI = "asset://android_asset/media/mp4/sample.mp4"; + private static final String SAMPLE_AC4_MP4_URI = + "asset://android_asset/media/mp4/sample_ac4_multiple_presentations.mp4"; @Parameters(name = "preload={0}") public static ImmutableList params() { @@ -14414,6 +14420,58 @@ public void playingMedia_withNoMetadata_doesNotUpdateMediaMetadata() throws Exce player.release(); } + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(api = Build.VERSION_CODES.P) + @Test + public void playingAC4_AudioPresentations() throws Exception { + ExoPlayer player = parameterizeTestExoPlayerBuilder(new TestExoPlayerBuilder(context)).build(); + player.setMediaItem(MediaItem.fromUri(SAMPLE_AC4_MP4_URI)); + List refPresentations = new ArrayList<>(); + refPresentations.add(new AudioPresentation.Builder(10) + .setProgramId(300) + .setLocale(ULocale.ENGLISH) + .setMasteringIndication(AudioPresentation.MASTERED_FOR_SURROUND) + .setHasSpokenSubtitles(false) + .setHasDialogueEnhancement(false) + .build()); + refPresentations.add(new AudioPresentation.Builder(11) + .setProgramId(300) + .setLocale(ULocale.ENGLISH) + .setMasteringIndication(AudioPresentation.MASTERED_FOR_SURROUND) + .setHasSpokenSubtitles(false) + .setHasAudioDescription(false) + .setHasDialogueEnhancement(false) + .build()); + refPresentations.add(new AudioPresentation.Builder(12) + .setProgramId(300) + .setLocale(ULocale.ENGLISH) + .setMasteringIndication(AudioPresentation.MASTERED_FOR_SURROUND) + .setHasSpokenSubtitles(false) + .setHasDialogueEnhancement(false) + .build()); + + player.addListener( + new Player.Listener() { + @Override + public void onAudioPresentationsChanged(List audioPresentations) { + assertThat(audioPresentations).isNotNull(); + assertThat(refPresentations.size()).isEqualTo(audioPresentations.size()); + for (int i = 0; i < refPresentations.size(); i++) { + assertThat(refPresentations.get(i)).isEqualTo((audioPresentations.get(i))); + } + } + }); + + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.stop(); + + shadowOf(Looper.getMainLooper()).idle(); + + player.release(); + } + @Test @Config(sdk = ALL_SDKS) public void builder_inBackgroundThreadWithAllowedAnyThreadMethods_doesNotThrow() diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/Ac4Util.java b/libraries/extractor/src/main/java/androidx/media3/extractor/Ac4Util.java index 16131bf9d8..0899c0a384 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/Ac4Util.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/Ac4Util.java @@ -18,6 +18,9 @@ import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.RetentionPolicy.SOURCE; +import android.icu.util.ULocale; +import android.media.AudioPresentation; +import android.os.Build; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.C; @@ -33,6 +36,12 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; /** Utility methods for parsing AC-4 frames, which are access units in AC-4 bitstreams. */ @UnstableApi @@ -112,6 +121,31 @@ private SyncFrameInfo( private static final int CHANNEL_MODE_9_1_4 = 14; private static final int CHANNEL_MODE_22_2 = 15; + // TS 103 190-1 v1.2.1 4.3.3.8.1: content_classifiers + @Documented + @Retention(SOURCE) + @Target(TYPE_USE) + @IntDef({ + COMPLETE_MAIN, + MUSIC_AND_EFFECTS, + VISUALLY_IMPAIRED, + HEARING_IMPAIRED, + DIALOG, + COMMENTARY, + EMERGENCY, + VOICEOVER + }) + private @interface ContentClassifier {} + + private static final int COMPLETE_MAIN = 0; + private static final int MUSIC_AND_EFFECTS = 1; + private static final int VISUALLY_IMPAIRED = 2; + private static final int HEARING_IMPAIRED = 3; + private static final int DIALOG = 4; + private static final int COMMENTARY = 5; + private static final int EMERGENCY = 6; + private static final int VOICEOVER = 7; + public static final int AC40_SYNCWORD = 0xAC40; public static final int AC41_SYNCWORD = 0xAC41; @@ -178,6 +212,8 @@ public static Format parseAc4AnnexEFormat( throws ParserException { ParsableBitArray dataBitArray = new ParsableBitArray(); dataBitArray.reset(data); + Map ac4Presentations = new HashMap<>(); + List audioPresentations = new ArrayList<>(); int dsiSize = dataBitArray.bitsLeft(); int ac4DsiVersion = dataBitArray.readBits(3); // ac4_dsi_version @@ -190,14 +226,14 @@ public static Format parseAc4AnnexEFormat( int sampleRate = dataBitArray.readBit() ? 48000 : 44100; // fs_index dataBitArray.skipBits(4); // frame_rate_index int numberOfPresentations = dataBitArray.readBits(9); // n_presentations - + int shortProgramId = -1; if (bitstreamVersion > 1) { if (ac4DsiVersion == 0) { throw ParserException.createForUnsupportedContainerFeature( "Invalid AC-4 DSI version: " + ac4DsiVersion); } if (dataBitArray.readBit()) { // b_program_id - dataBitArray.skipBits(16); // short_program_id + shortProgramId = dataBitArray.readBits(16); if (dataBitArray.readBit()) { // b_uuid dataBitArray.skipBits(16 * 8); // program_uuid } @@ -211,8 +247,9 @@ public static Format parseAc4AnnexEFormat( dataBitArray.byteAlign(); } - Ac4Presentation ac4Presentation = new Ac4Presentation(); for (int presentationIdx = 0; presentationIdx < numberOfPresentations; presentationIdx++) { + Ac4Presentation ac4Presentation = new Ac4Presentation(); + ac4Presentation.programID = shortProgramId; boolean isSingleSubstream = false; boolean isSingleSubstreamGroup = false; int presentationConfig; @@ -250,7 +287,7 @@ public static Format parseAc4AnnexEFormat( ac4Presentation.level = dataBitArray.readBits(3); // mdcompat if (dataBitArray.readBit()) { // b_presentation_group_index - dataBitArray.skipBits(5); // group_index + ac4Presentation.groupIndex = dataBitArray.readBits(5); } dataBitArray.skipBits(2); // dsi_frame_rate_multiply_info @@ -356,7 +393,7 @@ public static Format parseAc4AnnexEFormat( break; } } - dataBitArray.skipBit(); // b_pre_virtualized + ac4Presentation.preVirtualized = dataBitArray.readBit(); addEmdfSubstreams = dataBitArray.readBit(); // b_add_emdf_substreams } if (addEmdfSubstreams) { @@ -375,8 +412,10 @@ public static Format parseAc4AnnexEFormat( if (dataBitArray.readBit()) { // b_alternative dataBitArray.byteAlign(); - int nameLen = dataBitArray.readBits(16); // name_len - dataBitArray.skipBytes(nameLen); // presentation_name + int nameLen = dataBitArray.readBits(16); + byte[] presentationName = new byte[nameLen]; + dataBitArray.readBytes(presentationName, 0, nameLen); + ac4Presentation.description = new String(presentationName); int nTargets = dataBitArray.readBits(5); // n_targets for (int i = 0; i < nTargets; i++) { @@ -404,17 +443,20 @@ public static Format parseAc4AnnexEFormat( throw ParserException.createForUnsupportedContainerFeature( "Can't determine channel mode of presentation " + presentationIdx); } - break; // Successfully parsed the first presentation with presentation version 0, 1 or 2. + ac4Presentations.put(presentationIdx, ac4Presentation); } - int channelCount; - if (ac4Presentation.isChannelCoded) { - channelCount = - getAdjustedChannelCount( - ac4Presentation.channelMode, - ac4Presentation.hasBackChannels, - ac4Presentation.topChannelPairs); - } else { + int channelCount = -1; + String codecString = ""; + for (int id = 0; id < ac4Presentations.size(); id++) { + Ac4Presentation ac4Presentation = Objects.requireNonNull(ac4Presentations.get(id)); + if (ac4Presentation.isChannelCoded) { + channelCount = + getAdjustedChannelCount( + ac4Presentation.channelMode, + ac4Presentation.hasBackChannels, + ac4Presentation.topChannelPairs); + } else { // The ETSI TS 103 190-2 V1.2.1 (2018-02) specification defines the parameter // n_umx_objects_minus1 in Annex E (E.11.11) to specify the number of fullband objects. While // the elementary stream specification (section 6.3.2.8.1 and 6.3.2.10.4) provides information @@ -426,27 +468,81 @@ public static Format parseAc4AnnexEFormat( int lfeChannelCount = 1; channelCount = ac4Presentation.numOfUmxObjects + lfeChannelCount; // TODO: There is a bug in ETSI TS 103 190-2 V1.2.1 (2018-02), E.11.11 - // For AC-4 level 4 stream, the intention is to set 19 to n_umx_objects_minus1 but it is - // equal to 15 based on current specification. Dolby has filed a bug report to ETSI. - // The following sentence should be deleted after ETSI specification error is fixed. - if (ac4Presentation.level == 4) { - channelCount = channelCount == 17 ? 21 : channelCount; + // For AC-4 level 4 stream, the intention is to set 19 to n_umx_objects_minus1 but it is + // equal to 15 based on current specification. Dolby has filed a bug report to ETSI. + // The following sentence should be deleted after ETSI specification error is fixed. + if (ac4Presentation.level == 4) { + channelCount = channelCount == 17 ? 21 : channelCount; + } } - } - - if (channelCount <= 0) { - throw ParserException.createForUnsupportedContainerFeature( - "Can't determine channel count of presentation."); - } - String codecString = + codecString = createCodecsString(bitstreamVersion, ac4Presentation.version, ac4Presentation.level); + if (channelCount <= 0) { + throw ParserException.createForUnsupportedContainerFeature( + "Can't determine channel count of presentation."); + } + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + && ac4Presentation.isChannelCoded) { + int masteringIndicationType = AudioPresentation.MASTERING_NOT_INDICATED; + if (ac4Presentation.preVirtualized) { + masteringIndicationType = AudioPresentation.MASTERED_FOR_HEADPHONE; + } else { + switch (ac4Presentation.channelMode ) { + case CHANNEL_MODE_MONO: + case CHANNEL_MODE_STEREO: + masteringIndicationType = AudioPresentation.MASTERED_FOR_STEREO; + break; + case CHANNEL_MODE_3_0: + case CHANNEL_MODE_5_0: + case CHANNEL_MODE_5_1: + case CHANNEL_MODE_7_0_34: + case CHANNEL_MODE_7_1_34: + case CHANNEL_MODE_7_0_52: + case CHANNEL_MODE_7_1_52: + masteringIndicationType = AudioPresentation.MASTERED_FOR_SURROUND; + break; + case CHANNEL_MODE_7_0_322: + case CHANNEL_MODE_7_1_322: + case CHANNEL_MODE_7_0_4: + case CHANNEL_MODE_7_1_4: + case CHANNEL_MODE_9_0_4: + case CHANNEL_MODE_9_1_4: + case CHANNEL_MODE_22_2: + masteringIndicationType = AudioPresentation.MASTERED_FOR_3D; + break; + default: + throw ParserException.createForUnsupportedContainerFeature( + "Invalid channel mode in AC4 presentation."); + } + } + ULocale locale = new ULocale(""); + if (ac4Presentation.language != null) { + locale = ULocale.forLocale(new Locale(ac4Presentation.language)); + } + HashMap label = new HashMap<>(); + if (ac4Presentation.description != null) { + label.put(locale, ac4Presentation.description); + } + AudioPresentation presentation = (new AudioPresentation.Builder(ac4Presentation.groupIndex) + .setProgramId(ac4Presentation.programID) + .setLocale(locale) + .setMasteringIndication(masteringIndicationType) + .setHasAudioDescription(ac4Presentation.contentClassifier == + VISUALLY_IMPAIRED) + .setHasSpokenSubtitles(ac4Presentation.contentClassifier == VOICEOVER) + .setHasDialogueEnhancement(ac4Presentation.hasDialogEnhancements) + .setLabels(label)).build(); + audioPresentations.add(presentation); + } + } return new Format.Builder() .setId(trackId) .setSampleMimeType(MimeTypes.AUDIO_AC4) .setChannelCount(channelCount) .setSampleRate(sampleRate) + .setAudioPresentations(audioPresentations) .setDrmInitData(drmInitData) .setLanguage(language) .setCodecs(codecString) @@ -477,18 +573,18 @@ private static void parseDsiSubstream(ParsableBitArray data, Ac4Presentation ac4 } if (data.readBit()) { // b_content_type - int contentClassifier = data.readBits(3); // content_classifier + ac4Presentation.contentClassifier = data.readBits(3); // For streams based on TS 103 190 part 1 the presentation level channel_mode doesn't exist // and so we use the channel_mode from either the CM or M&E substream (they are mutually // exclusive). if (ac4Presentation.channelMode == CHANNEL_MODE_UNKNOWN && (channelMode >= 0 && channelMode <= 15) - && (contentClassifier == 0 || contentClassifier == 1)) { + && (ac4Presentation.contentClassifier == 0 || ac4Presentation.contentClassifier == 1)) { ac4Presentation.channelMode = channelMode; } if (data.readBit()) { // b_language_indicator - skipDsiLanguage(data); + parseDsiLanguage(data, ac4Presentation); } } } @@ -532,7 +628,7 @@ private static void parseDsiSubstreamGroup(ParsableBitArray data, Ac4Presentatio data.skipBits(3); // content_classifier if (data.readBit()) { // b_language_indicator - skipDsiLanguage(data); + parseDsiLanguage(data, ac4Presentation); } } } @@ -546,7 +642,8 @@ private static void parseDsiSubstreamGroup(ParsableBitArray data, Ac4Presentatio * the language tag field. * @throws ParserException If the language tag length is invalid. */ - private static void skipDsiLanguage(ParsableBitArray data) throws ParserException { + private static void parseDsiLanguage(ParsableBitArray data, Ac4Presentation ac4Presentation) + throws ParserException { int languageTagBytesNumber = data.readBits(6); // n_language_tag_bytes if (languageTagBytesNumber < 2 || languageTagBytesNumber > 42) { throw ParserException.createForUnsupportedContainerFeature( @@ -554,8 +651,10 @@ private static void skipDsiLanguage(ParsableBitArray data) throws ParserExceptio "Invalid language tag bytes number: %d. Must be between 2 and 42.", languageTagBytesNumber)); } + byte[] languageTagBytes = new byte[languageTagBytesNumber]; // Can't use readBytes() since it is not byte-aligned here. - data.skipBits(languageTagBytesNumber * C.BITS_PER_BYTE); + data.readBits(languageTagBytes, 0, languageTagBytesNumber * 8); + ac4Presentation.language = new String(languageTagBytes); } /** @@ -784,20 +883,35 @@ private static int readVariableBits(ParsableBitArray data, int bitsPerRead) { private static final class Ac4Presentation { public boolean isChannelCoded; public @ChannelMode int channelMode; + public @ContentClassifier int contentClassifier; public int numOfUmxObjects; public boolean hasBackChannels; public int topChannelPairs; + public int programID; + public int groupIndex; + public boolean hasDialogEnhancements; + public boolean preVirtualized; public int version; public int level; - + @Nullable + public String language; + @Nullable + public String description; private Ac4Presentation() { isChannelCoded = true; channelMode = CHANNEL_MODE_UNKNOWN; + contentClassifier = COMPLETE_MAIN; numOfUmxObjects = -1; hasBackChannels = true; topChannelPairs = 2; - version = 1; + programID = -1; + groupIndex = -1; + hasDialogEnhancements = false; + preVirtualized = false; + version = 0; level = 0; + language = null; + description = null; } } diff --git a/libraries/test_data/src/test/assets/media/mp4/sample_ac4_multiple_presentations.mp4 b/libraries/test_data/src/test/assets/media/mp4/sample_ac4_multiple_presentations.mp4 new file mode 100644 index 0000000000..c4d024243e Binary files /dev/null and b/libraries/test_data/src/test/assets/media/mp4/sample_ac4_multiple_presentations.mp4 differ