Skip to content

RemoteCastPlayer reports empty tracks after a cast→local→cast cycle when a track was selected during the first cast session #3237

@okycelt

Description

@okycelt

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.

  1. Apply the attached diff (swaps PlayerManager for CastPlayer in MainActivity and adds DemoCastTrackSelector)
  2. Add a DASH or HLS stream with multiple audio tracks to DemoUtil.SAMPLES
  3. Start playback locally
  4. Start cast session
  5. Change audio language
  6. Stop casting
  7. 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

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions