Version
Media3 main branch
More version details
Devices that reproduce the issue
- Pixel 10 emulator (Android 16)
Devices that do not reproduce the issue
No response
Reproducible in the demo app?
Yes
Reproduction steps
After a user-initiated track selection during a Cast session, RemoteCastPlayer silently drops every subsequent track update. The visible symptom is that the PlayerView track menu becomes empty after disconnecting and reconnecting Cast, with no way for the app to recover.
- Apply the attached diff (swaps
PlayerManager for CastPlayer in MainActivity and adds DemoCastTrackSelector)
- Add a DASH or HLS stream with multiple audio tracks to
DemoUtil.SAMPLES
- Start playback locally
- Start cast session
- Change audio language
- Stop casting
- Start cast session of the same item again
diff --git a/demos/cast/src/main/java/androidx/media3/demo/cast/DemoCastTrackSelector.java b/demos/cast/src/main/java/androidx/media3/demo/cast/DemoCastTrackSelector.java
new file mode 100644
index 0000000000..4402117b3b
--- /dev/null
+++ b/demos/cast/src/main/java/androidx/media3/demo/cast/DemoCastTrackSelector.java
@@ -0,0 +1,197 @@
+package androidx.media3.demo.cast;
+
+import androidx.annotation.Nullable;
+import androidx.media3.cast.CastTrackSelector;
+import androidx.media3.common.C;
+import androidx.media3.common.TrackGroup;
+import androidx.media3.common.TrackSelectionOverride;
+import androidx.media3.common.TrackSelectionParameters;
+import com.google.android.gms.cast.MediaTrack;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal two-way {@link CastTrackSelector} for the cast demo.
+ *
+ * <ul>
+ * <li>On {@link #TRACK_SELECTION_REQUEST_REASON_RECEIVER_UPDATE}: accept the receiver's current
+ * selection and rewrite {@link TrackSelectionParameters} to reflect it, so a subsequent
+ * sender-side invalidation does not revert the receiver-side choice.
+ * <li>On {@link #TRACK_SELECTION_REQUEST_REASON_PARAMETER_CHANGE} and {@link
+ * #TRACK_SELECTION_REQUEST_REASON_INVALIDATION}: pick selections from {@link
+ * TrackSelectionParameters} — explicit overrides first, then preferred language match, then
+ * fall back to keeping the receiver-side selection (audio gets the first track if nothing
+ * else matches).
+ * </ul>
+ */
+/* package */ final class DemoCastTrackSelector extends CastTrackSelector {
+ @Override
+ public CastTrackSelectorResult evaluate(CastTrackSelectorRequest request) {
+ if (request.trackSelectionRequestReason == TRACK_SELECTION_REQUEST_REASON_RECEIVER_UPDATE) {
+ return request
+ .buildResultUpon()
+ .setTrackSelectionParameters(syncParamsToReceiverSelection(request))
+ .build();
+ }
+
+ return request.buildResultUpon().setSelections(pickSelections(request)).build();
+ }
+
+ private static ImmutableSet<TrackGroup> pickSelections(CastTrackSelectorRequest request) {
+ Map<Integer, List<TrackGroup>> byType = groupsByType(request);
+ TrackSelectionParameters params = request.trackSelectionParameters;
+ ImmutableSet<TrackGroup> current = request.currentlySelectedTrackGroups;
+ ImmutableSet.Builder<TrackGroup> builder = ImmutableSet.builder();
+
+ TrackGroup audio =
+ pickForType(
+ byType.get(MediaTrack.TYPE_AUDIO),
+ params,
+ params.preferredAudioLanguages,
+ current,
+ C.TRACK_TYPE_AUDIO);
+ if (audio != null) {
+ builder.add(audio);
+ }
+
+ TrackGroup text =
+ pickForType(
+ byType.get(MediaTrack.TYPE_TEXT),
+ params,
+ params.preferredTextLanguages,
+ current,
+ C.TRACK_TYPE_TEXT);
+ if (text != null) {
+ builder.add(text);
+ }
+
+ List<TrackGroup> video = byType.get(MediaTrack.TYPE_VIDEO);
+ if (video != null) {
+ for (TrackGroup group : video) {
+ if (current.contains(group)) {
+ builder.add(group);
+ break;
+ }
+ }
+ }
+ return builder.build();
+ }
+
+ @Nullable
+ private static TrackGroup pickForType(
+ @Nullable List<TrackGroup> candidates,
+ TrackSelectionParameters params,
+ List<String> preferredLanguages,
+ ImmutableSet<TrackGroup> currentSelection,
+ @C.TrackType int trackType) {
+ if (candidates == null || candidates.isEmpty()) {
+ return null;
+ }
+
+ if (params.disabledTrackTypes.contains(trackType)) {
+ return null;
+ }
+
+ for (TrackGroup group : candidates) {
+ if (params.overrides.containsKey(group)) {
+ return group;
+ }
+ }
+
+ for (String preferred : preferredLanguages) {
+ for (TrackGroup group : candidates) {
+ String language = group.getFormat(0).language;
+ if (language != null && language.equals(preferred)) {
+ return group;
+ }
+ }
+ }
+
+ for (TrackGroup group : candidates) {
+ if (currentSelection.contains(group)) {
+ return group;
+ }
+ }
+
+ return trackType == C.TRACK_TYPE_AUDIO ? candidates.get(0) : null;
+ }
+
+ private static TrackSelectionParameters syncParamsToReceiverSelection(
+ CastTrackSelectorRequest request) {
+ Map<Integer, List<TrackGroup>> byType = groupsByType(request);
+ ImmutableSet<TrackGroup> current = request.currentlySelectedTrackGroups;
+ TrackSelectionParameters.Builder builder = request.trackSelectionParameters.buildUpon();
+
+ TrackGroup selectedAudio = firstSelected(byType.get(MediaTrack.TYPE_AUDIO), current);
+ if (selectedAudio != null) {
+ builder.setOverrideForType(new TrackSelectionOverride(selectedAudio, ImmutableList.of(0)));
+ String language = selectedAudio.getFormat(0).language;
+ if (language != null) {
+ builder.setPreferredAudioLanguage(language);
+ }
+ }
+
+ List<TrackGroup> textCandidates = byType.get(MediaTrack.TYPE_TEXT);
+ TrackGroup selectedText = firstSelected(textCandidates, current);
+ if (selectedText != null) {
+ builder
+ .setOverrideForType(new TrackSelectionOverride(selectedText, ImmutableList.of(0)))
+ .setPreferredTextLanguage(selectedText.getFormat(0).language)
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false);
+ } else if (textCandidates != null && !textCandidates.isEmpty()) {
+ builder
+ .clearOverridesOfType(C.TRACK_TYPE_TEXT)
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true);
+ }
+ return builder.build();
+ }
+
+ @Nullable
+ private static TrackGroup firstSelected(
+ @Nullable List<TrackGroup> candidates, ImmutableSet<TrackGroup> selected) {
+ if (candidates == null) {
+ return null;
+ }
+
+ for (TrackGroup group : candidates) {
+ if (selected.contains(group)) {
+ return group;
+ }
+ }
+
+ return null;
+ }
+
+ private static Map<Integer, List<TrackGroup>> groupsByType(CastTrackSelectorRequest request) {
+ Map<Integer, List<TrackGroup>> byType = new HashMap<>();
+ for (int i = 0; i < request.trackGroupList.size(); i++) {
+ int type = request.mediaTracks.get(i).getType();
+ List<TrackGroup> bucket = byType.get(type);
+ if (bucket == null) {
+ bucket = new ArrayList<>();
+ byType.put(type, bucket);
+ }
+ bucket.add(request.trackGroupList.get(i));
+ }
+ return byType;
+ }
+}
diff --git a/demos/cast/src/main/java/androidx/media3/demo/cast/MainActivity.java b/demos/cast/src/main/java/androidx/media3/demo/cast/MainActivity.java
index a0c075c319..09adc078d5 100644
--- a/demos/cast/src/main/java/androidx/media3/demo/cast/MainActivity.java
+++ b/demos/cast/src/main/java/androidx/media3/demo/cast/MainActivity.java
@@ -28,14 +28,19 @@ import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
-import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.ColorUtils;
+import androidx.media3.cast.CastPlayer;
import androidx.media3.cast.MediaRouteButtonFactory;
+import androidx.media3.cast.RemoteCastPlayer;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
+import androidx.media3.common.Player;
+import androidx.media3.common.Player.DiscontinuityReason;
+import androidx.media3.common.Player.TimelineChangeReason;
+import androidx.media3.common.Timeline;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.ui.PlayerView;
@@ -43,16 +48,18 @@ import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
+import java.util.ArrayList;
/**
* An activity that plays video using {@link ExoPlayer} and supports casting using ExoPlayer's Cast
* extension.
*/
-public class MainActivity extends AppCompatActivity
- implements OnClickListener, PlayerManager.Listener {
+public class MainActivity extends AppCompatActivity implements OnClickListener, Player.Listener {
private PlayerView playerView;
- private PlayerManager playerManager;
+ private CastPlayer castPlayer;
+ private ArrayList<MediaItem> mediaQueue;
+ private int currentItemIndex;
private RecyclerView mediaQueueList;
private MediaQueueListAdapter mediaQueueListAdapter;
@@ -87,7 +94,21 @@ public class MainActivity extends AppCompatActivity
@Override
public void onResume() {
super.onResume();
- playerManager = new PlayerManager(/* listener= */ this, this, playerView);
+ mediaQueue = new ArrayList<>();
+ currentItemIndex = C.INDEX_UNSET;
+
+ RemoteCastPlayer remotePlayer =
+ new RemoteCastPlayer.Builder(this)
+ .setTrackSelector(new DemoCastTrackSelector())
+ .build();
+ castPlayer =
+ new CastPlayer.Builder(this)
+ .setLocalPlayer(new ExoPlayer.Builder(this).build())
+ .setRemotePlayer(remotePlayer)
+ .build();
+ castPlayer.addListener(this);
+ playerView.setPlayer(castPlayer);
+
mediaQueueList.setAdapter(mediaQueueListAdapter);
}
@@ -96,8 +117,11 @@ public class MainActivity extends AppCompatActivity
super.onPause();
mediaQueueListAdapter.notifyItemRangeRemoved(0, mediaQueueListAdapter.getItemCount());
mediaQueueList.setAdapter(null);
- playerManager.release();
- playerManager = null;
+ playerView.setPlayer(null);
+ castPlayer.release();
+ castPlayer = null;
+ mediaQueue = null;
+ currentItemIndex = C.INDEX_UNSET;
}
// Activity input.
@@ -105,7 +129,7 @@ public class MainActivity extends AppCompatActivity
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// If the event was not handled then see if the player view can handle it.
- return super.dispatchKeyEvent(event) || playerManager.dispatchKeyEvent(event);
+ return super.dispatchKeyEvent(event) || playerView.dispatchKeyEvent(event);
}
@Override
@@ -118,31 +142,80 @@ public class MainActivity extends AppCompatActivity
.show();
}
- // PlayerManager.Listener implementation.
+ // Player.Listener implementation.
@Override
- public void onQueuePositionChanged(int previousIndex, int newIndex) {
- if (previousIndex != C.INDEX_UNSET) {
- mediaQueueListAdapter.notifyItemChanged(previousIndex);
- }
- if (newIndex != C.INDEX_UNSET) {
- mediaQueueListAdapter.notifyItemChanged(newIndex);
- }
+ public void onPlaybackStateChanged(@Player.State int playbackState) {
+ updateCurrentItemIndex();
}
@Override
- public void onUnsupportedTrack(int trackType) {
- if (trackType == C.TRACK_TYPE_AUDIO) {
- showToast(R.string.error_unsupported_audio);
- } else if (trackType == C.TRACK_TYPE_VIDEO) {
- showToast(R.string.error_unsupported_video);
- }
+ public void onPositionDiscontinuity(
+ Player.PositionInfo oldPosition,
+ Player.PositionInfo newPosition,
+ @DiscontinuityReason int reason) {
+ updateCurrentItemIndex();
+ }
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
+ updateCurrentItemIndex();
}
// Internal methods.
- private void showToast(int messageId) {
- Toast.makeText(getApplicationContext(), messageId, Toast.LENGTH_LONG).show();
+ private void updateCurrentItemIndex() {
+ int playbackState = castPlayer.getPlaybackState();
+ int newIndex =
+ playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
+ ? castPlayer.getCurrentMediaItemIndex()
+ : C.INDEX_UNSET;
+ if (newIndex != currentItemIndex) {
+ int previousIndex = currentItemIndex;
+ currentItemIndex = newIndex;
+ if (previousIndex != C.INDEX_UNSET) {
+ mediaQueueListAdapter.notifyItemChanged(previousIndex);
+ }
+ if (newIndex != C.INDEX_UNSET) {
+ mediaQueueListAdapter.notifyItemChanged(newIndex);
+ }
+ }
+ }
+
+ private void addItem(MediaItem item) {
+ mediaQueue.add(item);
+ castPlayer.addMediaItem(item);
+ }
+
+ private void selectQueueItem(int itemIndex) {
+ if (castPlayer.getCurrentTimeline().getWindowCount() != mediaQueue.size()) {
+ // This only happens with the cast player. The receiver app in the cast device clears the
+ // timeline when the last item of the timeline has been played to end.
+ castPlayer.setMediaItems(mediaQueue, itemIndex, C.TIME_UNSET);
+ } else {
+ castPlayer.seekTo(itemIndex, C.TIME_UNSET);
+ }
+ castPlayer.setPlayWhenReady(true);
+ }
+
+ private boolean removeQueueItem(MediaItem item) {
+ int itemIndex = mediaQueue.indexOf(item);
+ if (itemIndex == -1) {
+ return false;
+ }
+ castPlayer.removeMediaItem(itemIndex);
+ mediaQueue.remove(itemIndex);
+ return true;
+ }
+
+ private boolean moveQueueItem(MediaItem item, int newIndex) {
+ int fromIndex = mediaQueue.indexOf(item);
+ if (fromIndex == -1) {
+ return false;
+ }
+ castPlayer.moveMediaItem(fromIndex, newIndex);
+ mediaQueue.add(newIndex, mediaQueue.remove(fromIndex));
+ return true;
}
private View buildSampleListView() {
@@ -151,8 +224,8 @@ public class MainActivity extends AppCompatActivity
sampleList.setAdapter(new SampleListAdapter(this));
sampleList.setOnItemClickListener(
(parent, view, position, id) -> {
- playerManager.addItem(DemoUtil.SAMPLES.get(position));
- mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1);
+ addItem(DemoUtil.SAMPLES.get(position));
+ mediaQueueListAdapter.notifyItemInserted(mediaQueue.size() - 1);
});
return dialogList;
}
@@ -172,20 +245,19 @@ public class MainActivity extends AppCompatActivity
@Override
public void onBindViewHolder(QueueItemViewHolder holder, int position) {
- holder.item = checkNotNull(playerManager.getItem(position));
+ holder.item = checkNotNull(mediaQueue.get(position));
TextView view = holder.textView;
view.setText(holder.item.mediaMetadata.title);
// TODO: Solve coloring using the theme's ColorStateList.
view.setTextColor(
ColorUtils.setAlphaComponent(
- view.getCurrentTextColor(),
- position == playerManager.getCurrentItemIndex() ? 255 : 100));
+ view.getCurrentTextColor(), position == currentItemIndex ? 255 : 100));
}
@Override
public int getItemCount() {
- return playerManager.getMediaQueueSize();
+ return mediaQueue.size();
}
}
@@ -218,7 +290,7 @@ public class MainActivity extends AppCompatActivity
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
int position = viewHolder.getBindingAdapterPosition();
QueueItemViewHolder queueItemHolder = (QueueItemViewHolder) viewHolder;
- if (playerManager.removeItem(queueItemHolder.item)) {
+ if (removeQueueItem(queueItemHolder.item)) {
mediaQueueListAdapter.notifyItemRemoved(position);
// Update whichever item took its place, in case it became the new selected item.
mediaQueueListAdapter.notifyItemChanged(position);
@@ -231,7 +303,7 @@ public class MainActivity extends AppCompatActivity
if (draggingFromPosition != C.INDEX_UNSET) {
QueueItemViewHolder queueItemHolder = (QueueItemViewHolder) viewHolder;
// A drag has ended. We reflect the media queue change in the player.
- if (!playerManager.moveItem(queueItemHolder.item, draggingToPosition)) {
+ if (!moveQueueItem(queueItemHolder.item, draggingToPosition)) {
// The move failed. The entire sequence of onMove calls since the drag started needs to be
// invalidated.
mediaQueueListAdapter.notifyDataSetChanged();
@@ -255,7 +327,7 @@ public class MainActivity extends AppCompatActivity
@Override
public void onClick(View v) {
- playerManager.selectQueueItem(getBindingAdapterPosition());
+ selectQueueItem(getBindingAdapterPosition());
}
}
This is what fixed the issue for us. Alternatively, as a workaround, it's also possible to call CastTrackSelector.invalidate() when playback state changes to STATE_READY.
diff --git a/libraries/cast/src/main/java/androidx/media3/cast/RemoteCastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/RemoteCastPlayer.java
index 05c10040cf..d5d8aa29e4 100644
--- a/libraries/cast/src/main/java/androidx/media3/cast/RemoteCastPlayer.java
+++ b/libraries/cast/src/main/java/androidx/media3/cast/RemoteCastPlayer.java
@@ -1504,8 +1504,14 @@ public final class RemoteCastPlayer extends BasePlayer {
@Override
public void onResult(MediaChannelResult result) {
if (remoteMediaClient != null) {
+ // Clear the mask before re-entering; otherwise it stays set forever and all
+ // subsequent receiver updates get dropped at the acceptsUpdate check.
+ if (currentTracks.pendingResultCallback == this) {
+ currentTracks.clearPendingResultCallback();
+ }
updateTracksAndNotifyIfChanged(
- this, TRACK_SELECTION_REQUEST_REASON_RECEIVER_UPDATE);
+ /* resultCallback= */ null,
+ TRACK_SELECTION_REQUEST_REASON_RECEIVER_UPDATE);
listeners.flushEvents();
}
}
@@ -1782,6 +1788,12 @@ public final class RemoteCastPlayer extends BasePlayer {
this.remoteMediaClient.getMediaQueue().unregisterCallback(mediaQueueCallback);
}
this.remoteMediaClient = remoteMediaClient;
+ // Reset per-session state on session swap: dedup cache (used by
+ // updateTracksAndNotifyIfChanged) and any in-flight setActiveMediaTracks result callback
+ // masking currentTracks. Without these, both leak across the new RemoteMediaClient and
+ // silently corrupt track state in subsequent sessions.
+ lastSelectionRequest = null;
+ currentTracks.clearPendingResultCallback();
if (remoteMediaClient != null) {
if (sessionAvailabilityListener != null) {
sessionAvailabilityListener.onCastSessionAvailable();
Aside from that, may I ask what is the recommended approach to carry over track selection from local to cast session? In our tests, the default Cast receiver always seemed to pick the default audio from MPD.
Expected result
The track menu contains all tracks
Actual result
The track menu is empty
Media
Sent via email, but any DASH or HLS with multiple audios should do
Bug Report
Version
Media3 main branch
More version details
Devices that reproduce the issue
Devices that do not reproduce the issue
No response
Reproducible in the demo app?
Yes
Reproduction steps
After a user-initiated track selection during a Cast session,
RemoteCastPlayersilently drops every subsequent track update. The visible symptom is that thePlayerViewtrack menu becomes empty after disconnecting and reconnecting Cast, with no way for the app to recover.PlayerManagerforCastPlayerinMainActivityand addsDemoCastTrackSelector)DemoUtil.SAMPLESThis is what fixed the issue for us. Alternatively, as a workaround, it's also possible to call
CastTrackSelector.invalidate()when playback state changes toSTATE_READY.Aside from that, may I ask what is the recommended approach to carry over track selection from local to cast session? In our tests, the default Cast receiver always seemed to pick the default audio from MPD.
Expected result
The track menu contains all tracks
Actual result
The track menu is empty
Media
Sent via email, but any DASH or HLS with multiple audios should do
Bug Report
adb bugreportto android-media-github@google.com after filing this issue.