Snap for 7316203 from ae75cc93e61e099e1aebe06e5b8b7f2a075832dc to rvc-platform-release

Change-Id: Id7a24defb92fd36c8cfb6fde8036715482858e00
diff --git a/res/layout/browse_node.xml b/res/layout/browse_node.xml
new file mode 100644
index 0000000..11cdaf5
--- /dev/null
+++ b/res/layout/browse_node.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2020, The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <!-- Clone the top guideline since we don't need the others here. -->
+    <Space
+        android:id="@+id/ui_content_top_guideline2"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_marginTop="@dimen/car_ui_toolbar_first_row_height"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+    />
+
+    <ImageView
+        android:id="@+id/error_icon"
+        android:layout_width="@dimen/missing_permission_icon_size"
+        android:layout_height="@dimen/missing_permission_icon_size"
+        app:layout_constraintTop_toBottomOf="@+id/ui_content_top_guideline2"
+        app:layout_constraintBottom_toTopOf="@+id/error_message"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintVertical_chainStyle="packed"
+        android:src="@drawable/error_illustration"
+        android:visibility="gone"
+        android:tint="@color/icon_tint"/>
+
+    <TextView
+        android:id="@+id/error_message"
+        android:layout_width="wrap_content"
+        style="@style/ErrorTextStyle"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/browse_state_error_margin_top"
+        app:layout_constraintTop_toBottomOf="@+id/error_icon"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        android:maxLines="3"
+        android:text="@string/nothing_to_play"
+        android:visibility="gone"/>
+
+    <com.android.car.ui.FocusArea
+        android:id="@+id/focus_area"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent">
+        <com.android.car.ui.recyclerview.CarUiRecyclerView
+            android:id="@+id/browse_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:clickable="true"
+            android:clipToPadding="false"
+            android:visibility="gone"
+            app:layoutStyle="grid"
+            app:numOfColumns="@integer/num_browse_columns"/>
+    </com.android.car.ui.FocusArea>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/res/layout/fragment_browse.xml b/res/layout/fragment_browse.xml
index 688ed26..597c48c 100644
--- a/res/layout/fragment_browse.xml
+++ b/res/layout/fragment_browse.xml
@@ -22,50 +22,14 @@
 
     <include layout="@layout/ui_guides"/>
 
-    <ImageView
-        android:id="@+id/error_icon"
-        android:layout_width="@dimen/missing_permission_icon_size"
-        android:layout_height="@dimen/missing_permission_icon_size"
-        app:layout_constraintTop_toBottomOf="@+id/ui_content_top_guideline"
-        app:layout_constraintBottom_toTopOf="@+id/error_message"
-        app:layout_constraintStart_toStartOf="@+id/ui_content_start_guideline"
-        app:layout_constraintEnd_toEndOf="@+id/ui_content_end_guideline"
-        app:layout_constraintVertical_chainStyle="packed"
-        android:src="@drawable/error_illustration"
-        android:visibility="gone"
-        android:tint="@color/icon_tint"/>
-
-    <TextView
-        android:id="@+id/error_message"
-        android:layout_width="wrap_content"
-        style="@style/ErrorTextStyle"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="@dimen/browse_state_error_margin_top"
-        app:layout_constraintTop_toBottomOf="@+id/error_icon"
-        app:layout_constraintBottom_toBottomOf="@+id/ui_content_bottom_guideline"
-        app:layout_constraintStart_toStartOf="@+id/ui_content_start_guideline"
-        app:layout_constraintEnd_toEndOf="@+id/ui_content_end_guideline"
-        android:maxLines="3"
-        android:text="@string/nothing_to_play"
-        android:visibility="gone"/>
-
-    <com.android.car.ui.FocusArea
-        android:id="@+id/focus_area"
+    <FrameLayout
+        android:id="@+id/browse_content_area"
         android:layout_width="0dp"
         android:layout_height="0dp"
         app:layout_constraintStart_toStartOf="@+id/ui_content_start_guideline"
         app:layout_constraintEnd_toEndOf="@+id/ui_content_end_guideline"
         app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintBottom_toTopOf="@+id/ui_content_bottom_guideline">
-        <com.android.car.ui.recyclerview.CarUiRecyclerView
-            android:id="@+id/browse_list"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:clickable="true"
-            android:clipToPadding="false"
-            android:visibility="gone"
-            app:layoutStyle="grid"
-            app:numOfColumns="@integer/num_browse_columns"/>
-    </com.android.car.ui.FocusArea>
+        app:layout_constraintBottom_toTopOf="@+id/ui_content_bottom_guideline"
+    />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/res/layout/media_activity.xml b/res/layout/media_activity.xml
index f5c0d33..07cb83b 100644
--- a/res/layout/media_activity.xml
+++ b/res/layout/media_activity.xml
@@ -33,13 +33,6 @@
     />
 
     <FrameLayout
-        android:id="@+id/search_container"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:visibility="gone"
-    />
-
-    <FrameLayout
         android:id="@+id/error_container"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
diff --git a/src/com/android/car/media/BrowseViewController.java b/src/com/android/car/media/BrowseViewController.java
index 01ddabd..5a4eb3c 100644
--- a/src/com/android/car/media/BrowseViewController.java
+++ b/src/com/android/car/media/BrowseViewController.java
@@ -16,23 +16,21 @@
 
 package com.android.car.media;
 
-import static com.android.car.arch.common.LiveDataFunctions.ifThenElse;
+import static com.android.car.apps.common.util.ViewUtils.removeFromParent;
 
-import android.car.content.pm.CarPackageManager;
-import android.content.Context;
+import android.content.res.Resources;
 import android.os.Handler;
-import android.text.TextUtils;
 import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
 import android.view.ViewGroup;
-import android.view.inputmethod.InputMethodManager;
 import android.widget.ImageView;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProviders;
 import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
@@ -43,89 +41,68 @@
 import com.android.car.media.browse.LimitedBrowseAdapter;
 import com.android.car.media.common.GridSpacingItemDecoration;
 import com.android.car.media.common.MediaItemMetadata;
-import com.android.car.media.common.browse.BrowsedMediaItems;
-import com.android.car.media.common.browse.MediaBrowserViewModel;
+import com.android.car.media.common.browse.MediaBrowserViewModelImpl;
+import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData;
 import com.android.car.media.common.source.MediaSource;
-import com.android.car.media.widgets.AppBarController;
 import com.android.car.ui.FocusArea;
 import com.android.car.ui.baselayout.Insets;
-import com.android.car.ui.toolbar.Toolbar;
 import com.android.car.uxr.LifeCycleObserverUxrContentLimiter;
 import com.android.car.uxr.UxrContentLimiterImpl;
 
-import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
-import java.util.Objects;
-import java.util.Stack;
 
 /**
- * A view controller that implements the content forward browsing experience.
- *
- * This can be used to display either search or browse results at the root level. Deeper levels will
- * be handled the same way between search and browse, using a back stack to return to the root.
+ * A view controller that displays the media item children of a {@link MediaItemMetadata}.
+ * The controller manages a recycler view where the items can be displayed as a list or a grid, as
+ * well as an error icon and a message used to indicate loading and errors.
+ * The content view is initialized with 0 alpha and needs to be animated or set to to full opacity
+ * to become visible.
  */
-public class BrowseViewController extends ViewControllerBase {
+public class BrowseViewController {
     private static final String TAG = "BrowseViewController";
 
-    private static final String REGULAR_BROWSER_VIEW_MODEL_KEY
-            = "com.android.car.media.regular_browser_view_model";
-    private static final String SEARCH_BROWSER_VIEW_MODEL_KEY
-            = "com.android.car.media.search_browser_view_model";
-
     private final Callbacks mCallbacks;
     private final FocusArea mFocusArea;
+    private final MediaItemMetadata mParentItem;
+    private final MediaItemsLiveData mMediaItems;
+    private final boolean mDisplayMediaItems;
     private final LifeCycleObserverUxrContentLimiter mUxrContentLimiter;
+    private final View mContent;
     private final RecyclerView mBrowseList;
     private final ImageView mErrorIcon;
     private final TextView mMessage;
     private final LimitedBrowseAdapter mLimitedBrowseAdapter;
-    private String mSearchQuery;
+
     private final int mFadeDuration;
     private final int mLoadingIndicatorDelay;
-    private final boolean mIsSearchController;
+
     private final boolean mSetFocusAreaHighlightBottom;
-    private final MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
+
     private final Handler mHandler = new Handler();
-    /**
-     * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack} or to
-     * {@link MediaActivity.ViewModel#getBrowseStack}. Updated in {@link #onMediaSourceChanged}.
-     */
-    private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
+
     private final MediaActivity.ViewModel mViewModel;
-    private final MediaBrowserViewModel mRootMediaBrowserViewModel;
-    private final MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
+
     private final BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
 
         @Override
-        protected void onPlayableItemClicked(MediaItemMetadata item) {
-            hideKeyboard();
-            getParent().onPlayableItemClicked(item);
+        protected void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
+            mCallbacks.onPlayableItemClicked(item);
         }
 
         @Override
-        protected void onBrowsableItemClicked(MediaItemMetadata item) {
-            hideKeyboard();
-            navigateInto(item);
+        protected void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
+            mCallbacks.onBrowsableItemClicked(item);
         }
     };
 
-    private boolean mBrowseTreeHasChildren;
-    private boolean mAcceptTabSelection = true;
-
-    /**
-     * Media items to display as tabs. If null, it means we haven't finished loading them yet. If
-     * empty, it means there are no tabs to show
-     */
-    @Nullable
-    private List<MediaItemMetadata> mTopItems;
-
     /**
      * The bottom padding of the FocusArea highlight.
      */
     private int mFocusAreaHighlightBottomPadding;
 
     /**
-     * Callbacks (implemented by the hosting Activity)
+     * Callbacks (implemented by the host)
      */
     public interface Callbacks {
         /**
@@ -133,136 +110,69 @@
          *
          * @param item item to be played.
          */
-        void onPlayableItemClicked(MediaItemMetadata item);
+        void onPlayableItemClicked(@NonNull MediaItemMetadata item);
 
-        /** Called once the list of the root node's children has been loaded. */
-        void onRootLoaded();
+        /** Invoked when the user clicks on a browsable item. */
+        void onBrowsableItemClicked(@NonNull MediaItemMetadata item);
 
-        /** Change to a new UI mode. */
-        void changeMode(MediaActivity.Mode mode);
+        /** Invoked when child nodes have been removed from this controller. */
+        void onChildrenNodesRemoved(@NonNull BrowseViewController controller,
+                @NonNull Collection<MediaItemMetadata> removedNodes);
 
         FragmentActivity getActivity();
     }
 
-    /**
-     * Moves the user one level up in the browse tree. Returns whether that was possible.
-     */
-    private boolean navigateBack() {
-        boolean result = false;
-        if (!isAtTopStack()) {
-            mBrowseStack.pop();
-            mMediaBrowserViewModel.search(mSearchQuery);
-            mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
-            updateAppBar();
-            result = true;
-        }
-        if (isAtTopStack()) {
-            mShowSearchResults.setValue(mIsSearchController);
-        }
-        return result;
-    }
-
-    private void reopenSearch() {
-        if (mIsSearchController) {
-            mBrowseStack.clear();
-            updateAppBar();
-            mShowSearchResults.setValue(true);
-        } else {
-            Log.e(TAG, "reopenSearch called on browse controller");
-        }
-    }
-
-    @NonNull
-    private Callbacks getParent() {
-        return mCallbacks;
-    }
-
     private FragmentActivity getActivity() {
         return mCallbacks.getActivity();
     }
 
     /**
-     * @return whether the user is at the top of the browsing stack.
+     * Creates a controller to display the children of the given parent {@link MediaItemMetadata}.
+     * This parent node can have been obtained from the browse tree, or from browsing the search
+     * results.
      */
-    private boolean isAtTopStack() {
-        if (mIsSearchController) {
-            return mBrowseStack.isEmpty();
-        } else {
-            // The mBrowseStack stack includes the tab...
-            return mBrowseStack.size() <= 1;
-        }
+    static BrowseViewController newBrowseController(Callbacks callbacks, ViewGroup container,
+            @NonNull MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
+            int rootBrowsableHint, int rootPlayableHint) {
+        return new BrowseViewController(callbacks, container, parentItem, mediaItems,
+                rootBrowsableHint, rootPlayableHint, true);
+    }
+
+    /** Creates a controller to display the top results of a search query (in a list). */
+    static BrowseViewController newSearchResultsController(Callbacks callbacks, ViewGroup container,
+            MediaItemsLiveData mediaItems) {
+        return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, true);
     }
 
     /**
-     * Creates a new instance of this controller meant to browse the root node.
-     * @return a fully initialized {@link BrowseViewController}
+     * Creates a controller to "display" the children of the root: the children are actually hidden
+     * since they are shown as tabs, and the controller is only used to display loading and error
+     * messages.
      */
-    public static BrowseViewController newInstance(Callbacks callbacks,
-            CarPackageManager carPackageManager, ViewGroup container) {
-        boolean isSearchController = false;
-        return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
+    static BrowseViewController newRootController(Callbacks callbacks, ViewGroup container,
+            MediaItemsLiveData mediaItems) {
+        return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, false);
     }
 
-    /**
-     * Creates a new instance of this controller meant to display search results. The root browse
-     * screen will be the search results for the provided query.
-     *
-     * @return a fully initialized {@link BrowseViewController}
-     */
-    static BrowseViewController newSearchInstance(Callbacks callbacks,
-            CarPackageManager carPackageManager, ViewGroup container) {
-        boolean isSearchController = true;
-        return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
-    }
 
-    private void updateSearchQuery(@Nullable String query) {
-        mSearchQuery = query;
-        mMediaBrowserViewModel.search(query);
-    }
-
-    /**
-     * Clears search state, removes any UI elements from previous results.
-     */
-    @Override
-    void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
-        super.onMediaSourceChanged(mediaSource);
-
-        mBrowseTreeHasChildren = false;
-
-        if (mIsSearchController) {
-            updateSearchQuery(mViewModel.getSearchQuery());
-            mAppBarController.setSearchQuery(mSearchQuery);
-            mBrowseStack = mViewModel.getSearchStack();
-            mShowSearchResults.setValue(isAtTopStack());
-        } else {
-            mBrowseStack = mViewModel.getBrowseStack();
-            mShowSearchResults.setValue(false);
-            updateTabs((mediaSource != null) ? null : new ArrayList<>());
-        }
-
-        mLimitedBrowseAdapter.submitItems(null, null);
-        stopLoadingIndicator();
-        ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
-        ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
-
-        mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
-
-        updateAppBar();
-    }
-
-    private BrowseViewController(Callbacks callbacks, CarPackageManager carPackageManager,
-            ViewGroup container, boolean isSearchController) {
-        super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse);
-
+    private BrowseViewController(Callbacks callbacks, ViewGroup container,
+            @Nullable MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
+            int rootBrowsableHint, int rootPlayableHint, boolean displayMediaItems) {
         mCallbacks = callbacks;
-        mIsSearchController = isSearchController;
+        mParentItem = parentItem;
+        mMediaItems = mediaItems;
+        mDisplayMediaItems = displayMediaItems;
+
+        LayoutInflater inflater = LayoutInflater.from(container.getContext());
+        mContent = inflater.inflate(R.layout.browse_node, container, false);
+        mContent.setAlpha(0f);
+        container.addView(mContent);
 
         mLoadingIndicatorDelay = mContent.getContext().getResources()
                 .getInteger(R.integer.progress_indicator_delay);
         mSetFocusAreaHighlightBottom = mContent.getContext().getResources().getBoolean(
                 R.bool.set_browse_list_focus_area_highlight_above_minimized_control_bar);
 
-        mAppBarController.setListener(mAppBarListener);
         mFocusArea = mContent.findViewById(R.id.focus_area);
         mBrowseList = mContent.findViewById(R.id.browse_list);
         mErrorIcon = mContent.findViewById(R.id.error_icon);
@@ -272,31 +182,15 @@
 
 
         FragmentActivity activity = callbacks.getActivity();
-
         mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
 
-        // Browse logic for the root node
-        mRootMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(
-                mMediaSourceVM, ViewModelProviders.of(activity));
-        mRootMediaBrowserViewModel.getBrowsedMediaItems()
-                .observe(activity, futureData -> onItemsUpdate(/* forRoot */ true, futureData));
-
-        mRootMediaBrowserViewModel.supportsSearch().observe(activity,
-                mAppBarController::setSearchSupported);
-
-
-        // Browse logic for current node
-        mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
-                mIsSearchController ? SEARCH_BROWSER_VIEW_MODEL_KEY : REGULAR_BROWSER_VIEW_MODEL_KEY,
-                ViewModelProviders.of(activity),
-                mMediaSourceVM.getConnectedMediaBrowser());
-
         mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
                 activity.getResources().getDimensionPixelSize(R.dimen.grid_item_spacing)));
 
         GridLayoutManager manager = (GridLayoutManager) mBrowseList.getLayoutManager();
-        mLimitedBrowseAdapter = new LimitedBrowseAdapter(
-                new BrowseAdapter(mBrowseList.getContext()), manager, mBrowseAdapterObserver);
+        BrowseAdapter browseAdapter = new BrowseAdapter(mBrowseList.getContext());
+        mLimitedBrowseAdapter = new LimitedBrowseAdapter(browseAdapter, manager,
+                mBrowseAdapterObserver);
         mBrowseList.setAdapter(mLimitedBrowseAdapter);
 
         mUxrContentLimiter = new LifeCycleObserverUxrContentLimiter(
@@ -304,46 +198,65 @@
         mUxrContentLimiter.setAdapter(mLimitedBrowseAdapter);
         activity.getLifecycle().addObserver(mUxrContentLimiter);
 
-        mMediaBrowserViewModel.rootBrowsableHint().observe(activity,
-                hint -> mLimitedBrowseAdapter.getBrowseAdapter().setRootBrowsableViewType(hint));
-        mMediaBrowserViewModel.rootPlayableHint().observe(activity,
-                hint -> mLimitedBrowseAdapter.getBrowseAdapter().setRootPlayableViewType(hint));
-        LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
-                mMediaBrowserViewModel.getSearchedMediaItems(),
-                mMediaBrowserViewModel.getBrowsedMediaItems());
+        browseAdapter.setRootBrowsableViewType(rootBrowsableHint);
+        browseAdapter.setRootPlayableViewType(rootPlayableHint);
 
-        mediaItems.observe(activity, futureData -> onItemsUpdate(/* forRoot */ false, futureData));
-
-        updateAppBar();
+        mMediaItems.observe(activity, mItemsObserver);
     }
 
-    private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() {
-        @Override
-        public void onTabSelected(MediaItemMetadata item) {
-            if (mAcceptTabSelection) {
-                showTopItem(item);
-            }
-        }
+    public MediaItemMetadata getParentItem() {
+        return mParentItem;
+    }
 
-        @Override
-        public void onSearchSelection() {
-            if (mIsSearchController) {
-                reopenSearch();
-            } else {
-                mCallbacks.changeMode(MediaActivity.Mode.SEARCHING);
-            }
-        }
+    /** Shares the browse adapter with the given view... #local-hack. */
+    public void shareBrowseAdapterWith(RecyclerView view) {
+        view.setAdapter(mLimitedBrowseAdapter);
+    }
 
-        @Override
-        public void onSearch(String query) {
-            if (Log.isLoggable(TAG, Log.DEBUG)) {
-                Log.d(TAG, "onSearch: " + query);
-            }
-            mViewModel.setSearchQuery(query);
-            updateSearchQuery(query);
-        }
-    };
+    private final Observer<FutureData<List<MediaItemMetadata>>> mItemsObserver =
+            this::onItemsUpdate;
 
+    View getContent() {
+        return mContent;
+    }
+
+    String getDebugInfo() {
+        StringBuilder log = new StringBuilder();
+        log.append("[");
+        log.append((mParentItem != null) ? mParentItem.getTitle() : "Root");
+        log.append("]");
+        FutureData<List<MediaItemMetadata>> children = mMediaItems.getValue();
+        if (children == null) {
+            log.append(" null future data");
+        } else if (children.isLoading()) {
+            log.append(" loading");
+        } else if (children.getData() == null) {
+            log.append(" null list");
+        } else {
+            List<MediaItemMetadata> nodes = children.getData();
+            log.append(" ");
+            log.append(nodes.size());
+            log.append(" {");
+            if (nodes.size() > 0) {
+                log.append(nodes.get(0).getTitle().toString());
+            }
+            if (nodes.size() > 1) {
+                log.append(", ");
+                log.append(nodes.get(1).getTitle().toString());
+            }
+            if (nodes.size() > 2) {
+                log.append(", ...");
+            }
+            log.append(" }");
+        }
+        return log.toString();
+    }
+
+    void destroy() {
+        mCallbacks.getActivity().getLifecycle().removeObserver(mUxrContentLimiter);
+        mMediaItems.removeObserver(mItemsObserver);
+        removeFromParent(mContent);
+    }
 
     private Runnable mLoadingIndicatorRunnable = new Runnable() {
         @Override
@@ -353,19 +266,6 @@
         }
     };
 
-    boolean onBackPressed() {
-        boolean success = navigateBack();
-        if (!success && (mIsSearchController)) {
-            mCallbacks.changeMode(MediaActivity.Mode.BROWSING);
-            return true;
-        }
-        return success;
-    }
-
-    boolean browseTreeHasChildren() {
-        return mBrowseTreeHasChildren;
-    }
-
     private void startLoadingIndicator() {
         // Display the indicator after a certain time, to avoid flashing the indicator constantly,
         // even when performance is acceptable.
@@ -377,33 +277,6 @@
         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
     }
 
-    private void navigateInto(@Nullable MediaItemMetadata item) {
-        if (item != null) {
-            mBrowseStack.push(item);
-            mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
-        } else {
-            mMediaBrowserViewModel.setCurrentBrowseId(null);
-        }
-
-        mShowSearchResults.setValue(false);
-        updateAppBar();
-    }
-
-    /**
-     * @return the current item being displayed
-     */
-    @Nullable
-    private MediaItemMetadata getCurrentMediaItem() {
-        return mBrowseStack.isEmpty() ? null : mBrowseStack.lastElement();
-    }
-
-    @Nullable
-    private String getCurrentMediaItemId() {
-        MediaItemMetadata currentItem = getCurrentMediaItem();
-        return currentItem != null ? currentItem.getId() : null;
-    }
-
-    @Override
     public void onCarUiInsetsChanged(@NonNull Insets insets) {
         int leftPadding = mBrowseList.getPaddingLeft();
         int rightPadding = mBrowseList.getPaddingRight();
@@ -421,10 +294,9 @@
         int leftPadding = mBrowseList.getPaddingLeft();
         int topPadding = mBrowseList.getPaddingTop();
         int rightPadding = mBrowseList.getPaddingRight();
+        Resources res = getActivity().getResources();
         int bottomPadding = visible
-                ? getActivity().getResources().getDimensionPixelOffset(
-                        R.dimen.browse_fragment_bottom_padding)
-                : 0;
+                ? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding) : 0;
         mBrowseList.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
         int highlightBottomPadding = mSetFocusAreaHighlightBottom ? bottomPadding : 0;
         if (highlightBottomPadding > mFocusAreaHighlightBottomPadding) {
@@ -442,102 +314,9 @@
         mMessage.setLayoutParams(messageLayout);
     }
 
-    private void hideKeyboard() {
-        InputMethodManager in =
-                (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
-        in.hideSoftInputFromWindow(mContent.getWindowToken(), 0);
-    }
-
-    private void showTopItem(@Nullable MediaItemMetadata item) {
-        mViewModel.getBrowseStack().clear();
-        navigateInto(item);
-    }
-
-    /**
-     * Updates the tabs displayed on the app bar, based on the top level items on the browse tree.
-     * If there is at least one browsable item, we show the browse content of that node. If there
-     * are only playable items, then we show those items. If there are not items at all, we show the
-     * empty message. If we receive null, we show the error message.
-     *
-     * @param items top level items, null if the items are still being loaded, or empty list if
-     *              items couldn't be loaded.
-     */
-    private void updateTabs(@Nullable List<MediaItemMetadata> items) {
-        if (Objects.equals(mTopItems, items)) {
-            // When coming back to the app, the live data sends an update even if the list hasn't
-            // changed. Updating the tabs then recreates the browse view, which produces jank
-            // (b/131830876), and also resets the navigation to the top of the first tab...
-            return;
-        }
-        mTopItems = items;
-
-        if (mTopItems == null || mTopItems.isEmpty()) {
-            mAppBarController.setItems(null);
-            mAppBarController.setActiveItem(null);
-            if (items != null) {
-                // Only do this when not loading the tabs or we loose the saved one.
-                showTopItem(null);
-            }
-            updateAppBar();
-            return;
-        }
-
-        MediaItemMetadata oldTab = mViewModel.getSelectedTab();
-        try {
-            mAcceptTabSelection = false;
-            mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems);
-            updateAppBar();
-
-            if (items.contains(oldTab)) {
-                mAppBarController.setActiveItem(oldTab);
-            } else {
-                showTopItem(items.get(0));
-            }
-        }  finally {
-            mAcceptTabSelection = true;
-        }
-    }
-
-    private void updateAppBarTitle() {
-        boolean isStacked = !isAtTopStack();
-
-        final CharSequence title;
-        if (isStacked) {
-            // If not at top level, show the current item as title
-            title = getCurrentMediaItem().getTitle();
-        } else if (mTopItems == null) {
-            // If still loading the tabs, force to show an empty bar.
-            title = "";
-        } else if (mTopItems.size() == 1) {
-            // If we finished loading tabs and there is only one, use that as title.
-            title = mTopItems.get(0).getTitle();
-        } else {
-            // Otherwise (no tabs or more than 1 tabs), show the current media source title.
-            MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
-            title = getAppBarDefaultTitle(mediaSource);
-        }
-
-        mAppBarController.setTitle(title);
-    }
-
-    /**
-     * Update elements of the appbar that change depending on where we are in the browse.
-     */
-    private void updateAppBar() {
-        boolean isStacked = !isAtTopStack();
-        if (Log.isLoggable(TAG, Log.DEBUG)) {
-            Log.d(TAG, "App bar is in stacked state: " + isStacked);
-        }
-        Toolbar.State unstackedState =
-                mIsSearchController ? Toolbar.State.SEARCH : Toolbar.State.HOME;
-        updateAppBarTitle();
-        mAppBarController.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
-        mAppBarController.showSearchIfSupported(!mIsSearchController || isStacked);
-    }
-
-    private String getErrorMessage(boolean forRoot) {
-        if (forRoot) {
-            MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
+    private String getErrorMessage() {
+        if (/*root*/ !mDisplayMediaItems) {
+            MediaSource mediaSource = mViewModel.getBrowsedMediaSource().getValue();
             return getActivity().getString(
                     R.string.cannot_connect_to_app,
                     mediaSource != null
@@ -549,58 +328,40 @@
         }
     }
 
-
-
-    private void onItemsUpdate(boolean forRoot, FutureData<List<MediaItemMetadata>> futureData) {
-
-        // Prevent showing loading spinner or any error messages if search is uninitialized
-        if (mIsSearchController && TextUtils.isEmpty(mSearchQuery)) {
-            return;
-        }
-
-        if (!forRoot && !mBrowseTreeHasChildren && !mIsSearchController) {
-            // Ignore live data ghost values
-            return;
-        }
-
-        if (futureData.isLoading()) {
-            startLoadingIndicator();
+    private void onItemsUpdate(@Nullable FutureData<List<MediaItemMetadata>> futureData) {
+        if (futureData == null || futureData.isLoading()) {
             ViewUtils.hideViewAnimated(mErrorIcon, 0);
             ViewUtils.hideViewAnimated(mMessage, 0);
+
             // TODO(b/139759881) build a jank-free animation of the transition.
             mBrowseList.setAlpha(0f);
             mLimitedBrowseAdapter.submitItems(null, null);
 
-            if (forRoot) {
-                if (Log.isLoggable(TAG, Log.INFO)) {
-                    Log.i(TAG, "Loading browse tree...");
-                }
-                mBrowseTreeHasChildren = false;
-                updateTabs(null);
+            if (futureData != null) {
+                startLoadingIndicator();
             }
             return;
         }
 
         stopLoadingIndicator();
 
-        List<MediaItemMetadata> items =
-                BrowsedMediaItems.filterItems(forRoot, futureData.getData());
-        if (forRoot) {
-            boolean browseTreeHasChildren = items != null && !items.isEmpty();
-            if (Log.isLoggable(TAG, Log.INFO)) {
-                Log.i(TAG, "Browse tree loaded, status (has children or not) changed: "
-                        + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren);
+        List<MediaItemMetadata> items = MediaBrowserViewModelImpl.filterItems(
+                /*root*/ !mDisplayMediaItems, futureData.getData());
+        if (mDisplayMediaItems) {
+            mLimitedBrowseAdapter.submitItems(mParentItem, items);
+
+            List<MediaItemMetadata> lastNodes =
+                    MediaBrowserViewModelImpl.selectBrowseableItems(futureData.getPastData());
+            Collection<MediaItemMetadata> removedNodes =
+                    MediaBrowserViewModelImpl.computeRemovedItems(lastNodes, items);
+            if (!removedNodes.isEmpty()) {
+                mCallbacks.onChildrenNodesRemoved(this, removedNodes);
             }
-            mBrowseTreeHasChildren = browseTreeHasChildren;
-            mCallbacks.onRootLoaded();
-            updateTabs(items != null ? items : new ArrayList<>());
-        } else {
-            mLimitedBrowseAdapter.submitItems(getCurrentMediaItem(), items);
         }
 
-        int duration = forRoot ? 0 : mFadeDuration;
+        int duration = mFadeDuration;
         if (items == null) {
-            mMessage.setText(getErrorMessage(forRoot));
+            mMessage.setText(getErrorMessage());
             ViewUtils.hideViewAnimated(mBrowseList, duration);
             ViewUtils.showViewAnimated(mMessage, duration);
             ViewUtils.showViewAnimated(mErrorIcon, duration);
@@ -609,10 +370,14 @@
             ViewUtils.hideViewAnimated(mBrowseList, duration);
             ViewUtils.hideViewAnimated(mErrorIcon, duration);
             ViewUtils.showViewAnimated(mMessage, duration);
-        } else if (!forRoot) {
+        } else {
             ViewUtils.showViewAnimated(mBrowseList, duration);
             ViewUtils.hideViewAnimated(mErrorIcon, duration);
             ViewUtils.hideViewAnimated(mMessage, duration);
         }
+
+        if (Log.isLoggable(TAG, Log.VERBOSE)) {
+            Log.v(TAG, "onItemsUpdate " + getDebugInfo());
+        }
     }
 }
diff --git a/src/com/android/car/media/MediaActivity.java b/src/com/android/car/media/MediaActivity.java
index a2991fd..e2d6394 100644
--- a/src/com/android/car/media/MediaActivity.java
+++ b/src/com/android/car/media/MediaActivity.java
@@ -18,6 +18,7 @@
 import static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_BROWSE;
 
 import static com.android.car.apps.common.util.VectorMath.EPSILON;
+import static com.android.car.arch.common.LiveDataFunctions.dataOf;
 
 import android.annotation.SuppressLint;
 import android.app.AlertDialog;
@@ -26,7 +27,6 @@
 import android.car.Car;
 import android.car.content.pm.CarPackageManager;
 import android.car.drivingstate.CarUxRestrictions;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Resources;
@@ -56,9 +56,9 @@
 import com.android.car.media.common.MediaItemMetadata;
 import com.android.car.media.common.MinimizedPlaybackControlBar;
 import com.android.car.media.common.PlaybackErrorsHelper;
+import com.android.car.media.common.browse.MediaItemsRepository;
 import com.android.car.media.common.playback.PlaybackViewModel;
 import com.android.car.media.common.source.MediaSource;
-import com.android.car.media.common.source.MediaSourceViewModel;
 import com.android.car.ui.AlertDialogBuilder;
 import com.android.car.ui.utils.CarUxRestrictionsUtil;
 
@@ -70,7 +70,7 @@
  * This activity controls the UI of media. It also updates the connection status for the media app
  * by broadcast.
  */
-public class MediaActivity extends FragmentActivity implements BrowseViewController.Callbacks {
+public class MediaActivity extends FragmentActivity implements MediaActivityController.Callbacks {
     private static final String TAG = "MediaActivity";
 
     /** Configuration (controlled from resources) */
@@ -80,16 +80,13 @@
     private PlaybackViewModel.PlaybackController mPlaybackController;
 
     /** Layout views */
-    private View mRootView;
     private PlaybackFragment mPlaybackFragment;
-    private BrowseViewController mSearchController;
-    private BrowseViewController mBrowseController;
+    private MediaActivityController mMediaActivityController;
     private MinimizedPlaybackControlBar mMiniPlaybackControls;
     private ViewGroup mBrowseContainer;
     private ViewGroup mPlaybackContainer;
     private ViewGroup mErrorContainer;
     private ErrorScreenController mErrorController;
-    private ViewGroup mSearchContainer;
 
     private Toast mToast;
     private AlertDialog mDialog;
@@ -119,14 +116,14 @@
 
     /**
      * Possible modes of the application UI
+     * Todo: refactor into non exclusive flags to allow concurrent modes (eg: play details & browse)
+     * (b/179292793).
      */
     enum Mode {
-        /** The user is browsing a media source */
+        /** The user is browsing or searching a media source */
         BROWSING,
         /** The user is interacting with the full screen playback UI */
         PLAYBACK,
-        /** The user is searching within a media source */
-        SEARCHING,
         /** There's no browse tree and playback doesn't work. */
         FATAL_ERROR
     }
@@ -142,8 +139,6 @@
         mCloseVectorY = res.getFloat(R.dimen.media_activity_close_vector_y);
         mCloseVectorNorm = VectorMath.norm2(mCloseVectorX, mCloseVectorY);
 
-
-        MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel();
         // TODO(b/151174811): Use appropriate modes, instead of just MEDIA_SOURCE_MODE_BROWSE
         PlaybackViewModel playbackViewModel = getPlaybackViewModel();
         ViewModel localViewModel = getInnerViewModel();
@@ -156,10 +151,7 @@
         }
         mMode = localViewModel.getSavedMode();
 
-        mRootView = findViewById(R.id.media_activity_root);
-
-        mediaSourceViewModel.getPrimaryMediaSource().observe(this,
-                this::onMediaSourceChanged);
+        localViewModel.getBrowsedMediaSource().observe(this, this::onMediaSourceChanged);
 
         mPlaybackFragment = new PlaybackFragment();
         mPlaybackFragment.setListener(mPlaybackFragmentListener);
@@ -174,15 +166,12 @@
         mBrowseContainer = findViewById(R.id.fragment_container);
         mErrorContainer = findViewById(R.id.error_container);
         mPlaybackContainer = findViewById(R.id.playback_container);
-        mSearchContainer = findViewById(R.id.search_container);
         getSupportFragmentManager().beginTransaction()
                 .replace(R.id.playback_container, mPlaybackFragment)
                 .commit();
 
-        mBrowseController = BrowseViewController.newInstance(this,
+        mMediaActivityController = new MediaActivityController(this, getMediaItemsRepository(),
                 mCarPackageManager, mBrowseContainer);
-        mSearchController = BrowseViewController.newSearchInstance(this,
-                mCarPackageManager, mSearchContainer);
 
         playbackViewModel.getPlaybackController().observe(this,
                 playbackController -> {
@@ -201,17 +190,13 @@
         mCarUxRestrictionsUtil.register(mListener);
 
         mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this));
-
-        localViewModel.getMiniControlsVisible().observe(this, visible -> {
-            mBrowseController.onPlaybackControlsChanged(visible);
-            mSearchController.onPlaybackControlsChanged(visible);
-        });
     }
 
     @Override
     protected void onDestroy() {
         mCarUxRestrictionsUtil.unregister(mListener);
         mCar.disconnect();
+        mMediaActivityController.onDestroy();
         super.onDestroy();
     }
 
@@ -244,7 +229,7 @@
 
             boolean isFatalError = false;
             if (!TextUtils.isEmpty(displayedMessage)) {
-                if (mBrowseController.browseTreeHasChildren()) {
+                if (mMediaActivityController.browseTreeHasChildren()) {
                     if (intent != null && !isUxRestricted()) {
                         showDialog(intent, displayedMessage, label,
                                 getString(android.R.string.cancel));
@@ -271,7 +256,7 @@
     private ErrorScreenController getErrorController() {
         if (mErrorController == null) {
             mErrorController = new ErrorScreenController(this, mCarPackageManager, mErrorContainer);
-            MediaSource mediaSource = getMediaSourceViewModel().getPrimaryMediaSource().getValue();
+            MediaSource mediaSource = getInnerViewModel().getBrowsedMediaSource().getValue();
             mErrorController.onMediaSourceChanged(mediaSource);
         }
         return mErrorController;
@@ -319,11 +304,8 @@
             case PLAYBACK:
                 changeMode(Mode.BROWSING);
                 break;
-            case SEARCHING:
-                mSearchController.onBackPressed();
-                break;
             case BROWSING:
-                boolean handled = mBrowseController.onBackPressed();
+                boolean handled = mMediaActivityController.onBackPressed();
                 if (handled) return;
                 // Fall through.
             case FATAL_ERROR:
@@ -338,16 +320,7 @@
      * @param mediaSource the new media source we are going to try to browse
      */
     private void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
-        ComponentName savedMediaSource = getInnerViewModel().getSavedMediaSource();
-        if (Log.isLoggable(TAG, Log.INFO)) {
-            Log.i(TAG, "MediaSource changed from " + savedMediaSource + " to " + mediaSource);
-        }
 
-        savedMediaSource = mediaSource != null ? mediaSource.getBrowseServiceComponentName() : null;
-        getInnerViewModel().saveMediaSource(savedMediaSource);
-
-        mBrowseController.onMediaSourceChanged(mediaSource);
-        mSearchController.onMediaSourceChanged(mediaSource);
         if (mErrorController != null) {
             mErrorController.onMediaSourceChanged(mediaSource);
         }
@@ -369,8 +342,7 @@
         }
     }
 
-    @Override
-    public void changeMode(Mode mode) {
+    private void changeMode(Mode mode) {
         if (mMode == mode) {
             if (Log.isLoggable(TAG, Log.INFO)) {
                 Log.i(TAG, "Mode " + mMode + " change is ignored");
@@ -398,7 +370,6 @@
                 ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration);
                 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
-                ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
                 break;
             case PLAYBACK:
                 mPlaybackContainer.setX(0);
@@ -407,27 +378,18 @@
                 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
                 ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration);
                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
-                ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
                 break;
             case BROWSING:
                 if (oldMode == Mode.PLAYBACK) {
                     ViewUtils.hideViewAnimated(mErrorContainer, 0);
                     ViewUtils.showViewAnimated(mBrowseContainer, 0);
-                    ViewUtils.hideViewAnimated(mSearchContainer, 0);
                     animateOutPlaybackContainer(fadeOutDuration);
                 } else {
                     ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
                     ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
                     ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration);
-                    ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
                 }
                 break;
-            case SEARCHING:
-                ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
-                ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
-                ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
-                ViewUtils.showViewAnimated(mSearchContainer, mFadeDuration);
-                break;
         }
     }
 
@@ -477,7 +439,10 @@
         final boolean shouldShowMiniPlaybackControls =
                 mCanShowMiniPlaybackControls && mMode != Mode.PLAYBACK;
         if (shouldShowMiniPlaybackControls) {
-            ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration);
+            Boolean visible = getInnerViewModel().getMiniControlsVisible().getValue();
+            if (visible != Boolean.TRUE) {
+                ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration);
+            }
         } else {
             ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration);
         }
@@ -485,14 +450,12 @@
     }
 
     @Override
-    public void onPlayableItemClicked(MediaItemMetadata item) {
+    public void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
         mPlaybackController.playItem(item);
         boolean switchToPlayback = getResources().getBoolean(
                 R.bool.switch_to_playback_view_when_playable_item_is_clicked);
         if (switchToPlayback) {
             changeMode(Mode.PLAYBACK);
-        } else if (mMode == Mode.SEARCHING) {
-            changeMode(Mode.BROWSING);
         }
         setIntent(null);
     }
@@ -508,8 +471,8 @@
         return this;
     }
 
-    private MediaSourceViewModel getMediaSourceViewModel() {
-        return MediaSourceViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
+    private MediaItemsRepository getMediaItemsRepository() {
+        return MediaItemsRepository.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
     }
 
     private PlaybackViewModel getPlaybackViewModel() {
@@ -526,14 +489,18 @@
             Mode mMode = Mode.BROWSING;
             Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
             Stack<MediaItemMetadata> mSearchStack = new Stack<>();
+            /** True when the search bar has been opened or when the search results are browsed. */
+            boolean mSearching;
+            /** True iif the list of search results is being shown (implies mIsSearching). */
+            boolean mShowingSearchResults;
             String mSearchQuery;
             boolean mQueueVisible = false;
         }
 
         private boolean mNeedsInitialization = true;
         private PlaybackViewModel mPlaybackViewModel;
-        private ComponentName mMediaSource;
-        private final Map<ComponentName, MediaServiceState> mStates = new HashMap<>();
+        private final MutableLiveData<MediaSource> mBrowsedMediaSource = dataOf(null);
+        private final Map<MediaSource, MediaServiceState> mStates = new HashMap<>();
         private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>();
 
         public ViewModel(@NonNull Application application) {
@@ -561,10 +528,11 @@
         }
 
         MediaServiceState getSavedState() {
-            MediaServiceState state = mStates.get(mMediaSource);
+            MediaSource source = mBrowsedMediaSource.getValue();
+            MediaServiceState state = mStates.get(source);
             if (state == null) {
                 state = new MediaServiceState();
-                mStates.put(mMediaSource, state);
+                mStates.put(source, state);
             }
             return state;
         }
@@ -591,26 +559,43 @@
             return getSavedState().mQueueVisible;
         }
 
-        void saveMediaSource(ComponentName mediaSource) {
-            mMediaSource = mediaSource;
+        void saveBrowsedMediaSource(MediaSource mediaSource) {
+            mBrowsedMediaSource.setValue(mediaSource);
         }
 
-        ComponentName getSavedMediaSource() {
-            return mMediaSource;
+        LiveData<MediaSource> getBrowsedMediaSource() {
+            return mBrowsedMediaSource;
         }
 
-        Stack<MediaItemMetadata> getBrowseStack() {
+        @NonNull Stack<MediaItemMetadata> getBrowseStack() {
             return getSavedState().mBrowseStack;
         }
 
-        Stack<MediaItemMetadata> getSearchStack() {
+        @NonNull Stack<MediaItemMetadata> getSearchStack() {
             return getSavedState().mSearchStack;
         }
 
+        /** Returns whether search mode is on (showing search results or browsing them). */
+        boolean isSearching() {
+            return getSavedState().mSearching;
+        }
+
+        boolean isShowingSearchResults() {
+            return getSavedState().mShowingSearchResults;
+        }
+
         String getSearchQuery() {
             return getSavedState().mSearchQuery;
         }
 
+        void setSearching(boolean isSearching) {
+            getSavedState().mSearching = isSearching;
+        }
+
+        void setShowingSearchResults(boolean isShowing) {
+            getSavedState().mShowingSearchResults = isShowing;
+        }
+
         void setSearchQuery(String searchQuery) {
             getSavedState().mSearchQuery = searchQuery;
         }
diff --git a/src/com/android/car/media/MediaActivityController.java b/src/com/android/car/media/MediaActivityController.java
new file mode 100644
index 0000000..a00088f
--- /dev/null
+++ b/src/com/android/car/media/MediaActivityController.java
@@ -0,0 +1,624 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.media;
+
+import static com.android.car.apps.common.util.ViewUtils.showHideViewAnimated;
+
+import android.car.content.pm.CarPackageManager;
+import android.content.Context;
+import android.support.v4.media.MediaBrowserCompat;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.car.apps.common.util.ViewUtils;
+import com.android.car.apps.common.util.ViewUtils.ViewAnimEndListener;
+import com.android.car.arch.common.FutureData;
+import com.android.car.media.common.MediaItemMetadata;
+import com.android.car.media.common.browse.MediaBrowserViewModelImpl;
+import com.android.car.media.common.browse.MediaItemsRepository;
+import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData;
+import com.android.car.media.common.source.MediaBrowserConnector.BrowsingState;
+import com.android.car.media.common.source.MediaSource;
+import com.android.car.media.widgets.AppBarController;
+import com.android.car.ui.baselayout.Insets;
+import com.android.car.ui.toolbar.Toolbar;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Stack;
+
+/**
+ * Controls the views of the {@link MediaActivity}.
+ * TODO: finish moving control code out of MediaActivity (b/179292809).
+ */
+public class MediaActivityController extends ViewControllerBase {
+
+    private static final String TAG = "MediaActivityCtr";
+
+    private final MediaItemsRepository mMediaItemsRepository;
+    private final Callbacks mCallbacks;
+    private final ViewGroup mBrowseArea;
+    private Insets mCarUiInsets;
+    private boolean mPlaybackControlsVisible;
+
+    private final Map<MediaItemMetadata, BrowseViewController> mBrowseViewControllersByNode =
+            new HashMap<>();
+
+    // Controllers that should be destroyed once their view is hidden.
+    private final Map<View, BrowseViewController> mBrowseViewControllersToDestroy = new HashMap<>();
+
+    private final BrowseViewController mRootLoadingController;
+    private final BrowseViewController mSearchResultsController;
+
+    /**
+     * Stores the reference to {@link MediaActivity.ViewModel#getBrowseStack}.
+     * Updated in {@link #onMediaSourceChanged}.
+     */
+    private Stack<MediaItemMetadata> mBrowseStack;
+    /**
+     * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack}.
+     * Updated in {@link #onMediaSourceChanged}.
+     */
+    private Stack<MediaItemMetadata> mSearchStack;
+    private final MediaActivity.ViewModel mViewModel;
+
+    private int mRootBrowsableHint;
+    private int mRootPlayableHint;
+    private boolean mBrowseTreeHasChildren;
+    private boolean mAcceptTabSelection = true;
+
+    /**
+     * Media items to display as tabs. If null, it means we haven't finished loading them yet. If
+     * empty, it means there are no tabs to show
+     */
+    @Nullable
+    private List<MediaItemMetadata> mTopItems;
+
+    private final Observer<BrowsingState> mMediaBrowsingObserver =
+            this::onMediaBrowsingStateChanged;
+
+    /**
+     * Callbacks (implemented by the hosting Activity)
+     */
+    public interface Callbacks {
+
+        /** Invoked when the user clicks on a browsable item. */
+        void onPlayableItemClicked(@NonNull MediaItemMetadata item);
+
+        /** Called once the list of the root node's children has been loaded. */
+        void onRootLoaded();
+
+        /** Returns the activity. */
+        FragmentActivity getActivity();
+    }
+
+    /**
+     * Moves the user one level up in the browse/search tree. Returns whether that was possible.
+     */
+    private boolean navigateBack() {
+        boolean result = false;
+        if (!isAtTopStack()) {
+            hideAndDestroyControllerForItem(getStack().pop());
+
+            // Show the parent (if any)
+            showCurrentNode(true);
+
+            if (isAtTopStack() && mViewModel.isSearching()) {
+                showSearchResults(true);
+            }
+
+            updateAppBar();
+            result = true;
+        }
+        return result;
+    }
+
+    private void reopenSearch() {
+        clearStack(mSearchStack);
+        showSearchResults(true);
+        updateAppBar();
+    }
+
+    private FragmentActivity getActivity() {
+        return mCallbacks.getActivity();
+    }
+
+    /** Returns the browse or search stack. */
+    private Stack<MediaItemMetadata> getStack() {
+        return mViewModel.isSearching() ? mSearchStack : mBrowseStack;
+    }
+
+    /**
+     * @return whether the user is at the top of the browsing stack.
+     */
+    private boolean isAtTopStack() {
+        if (mViewModel.isSearching()) {
+            return mSearchStack.isEmpty();
+        } else {
+            // The mBrowseStack stack includes the tab...
+            return mBrowseStack.size() <= 1;
+        }
+    }
+
+    private void clearMediaSource() {
+        showSearchMode(false);
+        for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
+            controller.destroy();
+        }
+        mBrowseViewControllersByNode.clear();
+        mBrowseTreeHasChildren = false;
+    }
+
+    private void updateSearchQuery(@Nullable String query) {
+        mMediaItemsRepository.setSearchQuery(query);
+    }
+
+    /**
+     * Clears search state, removes any UI elements from previous results.
+     */
+    @Override
+    void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
+        super.onMediaSourceChanged(mediaSource);
+
+        updateTabs((mediaSource != null) ? null : new ArrayList<>());
+
+        mSearchStack = mViewModel.getSearchStack();
+        mBrowseStack = mViewModel.getBrowseStack();
+
+        updateAppBar();
+    }
+
+    private void onMediaBrowsingStateChanged(BrowsingState newBrowsingState) {
+        switch (newBrowsingState.mConnectionStatus) {
+            case CONNECTING:
+                break;
+            case CONNECTED:
+                MediaBrowserCompat browser = newBrowsingState.mBrowser;
+                mRootBrowsableHint = MediaBrowserViewModelImpl.getRootBrowsableHint(browser);
+                mRootPlayableHint = MediaBrowserViewModelImpl.getRootPlayableHint(browser);
+
+                boolean canSearch = MediaBrowserViewModelImpl.getSupportsSearch(browser);
+                mAppBarController.setSearchSupported(canSearch);
+                break;
+
+            case DISCONNECTING:
+            case REJECTED:
+            case SUSPENDED:
+                clearMediaSource();
+                break;
+        }
+
+        MediaSource savedSource = mViewModel.getBrowsedMediaSource().getValue();
+        MediaSource mediaSource = newBrowsingState.mMediaSource;
+        if (Log.isLoggable(TAG, Log.INFO)) {
+            Log.i(TAG, "MediaSource changed from " + savedSource + " to " + mediaSource);
+        }
+
+        mViewModel.saveBrowsedMediaSource(mediaSource);
+        onMediaSourceChanged(mediaSource);
+    }
+
+
+    MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo,
+            CarPackageManager carPackageManager, ViewGroup container) {
+        super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse);
+
+        FragmentActivity activity = callbacks.getActivity();
+        mCallbacks = callbacks;
+        mMediaItemsRepository = mediaItemsRepo;
+        mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
+        mSearchStack = mViewModel.getSearchStack();
+        mBrowseStack = mViewModel.getBrowseStack();
+        mBrowseArea = mContent.requireViewById(R.id.browse_content_area);
+
+        MediaItemsLiveData rootMediaItems = mediaItemsRepo.getRootMediaItems();
+        mRootLoadingController = BrowseViewController.newRootController(
+                mBrowseCallbacks, mBrowseArea, rootMediaItems);
+        mRootLoadingController.getContent().setAlpha(1f);
+
+        mSearchResultsController = BrowseViewController.newSearchResultsController(
+                mBrowseCallbacks, mBrowseArea, mMediaItemsRepository.getSearchMediaItems());
+
+        boolean showingSearch = mViewModel.isShowingSearchResults();
+        ViewUtils.setVisible(mSearchResultsController.getContent(), showingSearch);
+        if (showingSearch) {
+            mSearchResultsController.getContent().setAlpha(1f);
+        }
+
+        mAppBarController.setListener(mAppBarListener);
+        mAppBarController.setSearchQuery(mViewModel.getSearchQuery());
+        if (mAppBarController.canShowSearchResultsView()) {
+            // TODO(b/180441965) eliminate the need to create a different view and use
+            //     mSearchResultsController.getContent() instead.
+            RecyclerView toolbarSearchResultsView = new RecyclerView(activity);
+            mSearchResultsController.shareBrowseAdapterWith(toolbarSearchResultsView);
+
+            ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+            toolbarSearchResultsView.setLayoutParams(params);
+            toolbarSearchResultsView.setLayoutManager(new LinearLayoutManager(activity));
+            toolbarSearchResultsView.setBackground(
+                    activity.getDrawable(R.drawable.car_ui_ime_wide_screen_background));
+
+            mAppBarController.setSearchResultsView(toolbarSearchResultsView);
+        }
+
+        updateAppBar();
+
+        // Observe forever ensures the caches are destroyed even while the activity isn't resumed.
+        mediaItemsRepo.getBrowsingState().observeForever(mMediaBrowsingObserver);
+
+        rootMediaItems.observe(activity, this::onRootMediaItemsUpdate);
+        mViewModel.getMiniControlsVisible().observe(activity, this::onPlaybackControlsChanged);
+    }
+
+    void onDestroy() {
+        mMediaItemsRepository.getBrowsingState().removeObserver(mMediaBrowsingObserver);
+    }
+
+    private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() {
+        @Override
+        public void onTabSelected(MediaItemMetadata item) {
+            if (mAcceptTabSelection && (item != null) && (item != mViewModel.getSelectedTab())) {
+                clearStack(mBrowseStack);
+                mBrowseStack.push(item);
+                showCurrentNode(true);
+            }
+        }
+
+        @Override
+        public void onSearchSelection() {
+            if (mViewModel.isSearching()) {
+                reopenSearch();
+            } else {
+                showSearchMode(true);
+                updateAppBar();
+            }
+        }
+
+        @Override
+        public void onSearch(String query) {
+            if (Log.isLoggable(TAG, Log.DEBUG)) {
+                Log.d(TAG, "onSearch: " + query);
+            }
+            mViewModel.setSearchQuery(query);
+            updateSearchQuery(query);
+        }
+    };
+
+    private final BrowseViewController.Callbacks mBrowseCallbacks =
+            new BrowseViewController.Callbacks() {
+        @Override
+        public void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
+            hideKeyboard();
+            mCallbacks.onPlayableItemClicked(item);
+        }
+
+        @Override
+        public void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
+            hideKeyboard();
+            navigateInto(item);
+        }
+
+        @Override
+            public void onChildrenNodesRemoved(@NonNull BrowseViewController controller,
+                    @NonNull Collection<MediaItemMetadata> removedNodes) {
+
+            if (mBrowseStack.contains(controller.getParentItem())) {
+                for (MediaItemMetadata node : removedNodes) {
+                    int indexOfNode = mBrowseStack.indexOf(node);
+                    if (indexOfNode >= 0) {
+                        clearStack(mBrowseStack.subList(indexOfNode, mBrowseStack.size()));
+                        if (!mViewModel.isShowingSearchResults()) {
+                            showCurrentNode(true);
+                            updateAppBar();
+                        }
+                        break; // The stack contains at most one of the removed nodes.
+                    }
+                }
+            }
+        }
+
+        @Override
+        public FragmentActivity getActivity() {
+            return mCallbacks.getActivity();
+        }
+    };
+
+    private final ViewAnimEndListener mViewAnimEndListener = view -> {
+        BrowseViewController toDestroy = mBrowseViewControllersToDestroy.remove(view);
+        if (toDestroy != null) {
+            toDestroy.destroy();
+        }
+    };
+
+    boolean onBackPressed() {
+        boolean success = navigateBack();
+        if (!success && mViewModel.isSearching()) {
+            showSearchMode(false);
+            updateAppBar();
+            return true;
+        }
+        return success;
+    }
+
+    boolean browseTreeHasChildren() {
+        return mBrowseTreeHasChildren;
+    }
+
+    private void navigateInto(@NonNull MediaItemMetadata item) {
+        showSearchResults(false);
+
+        // Hide the current node (parent)
+        showCurrentNode(false);
+
+        // Make item the current node
+        getStack().push(item);
+
+        // Show the current node (item)
+        showCurrentNode(true);
+
+        updateAppBar();
+    }
+
+    private BrowseViewController getControllerForItem(@NonNull MediaItemMetadata item) {
+        BrowseViewController controller = mBrowseViewControllersByNode.get(item);
+        if (controller == null) {
+            controller = BrowseViewController.newBrowseController(mBrowseCallbacks, mBrowseArea,
+                    item, mMediaItemsRepository.getMediaChildren(item.getId()), mRootBrowsableHint,
+                    mRootPlayableHint);
+
+            if (mCarUiInsets != null) {
+                controller.onCarUiInsetsChanged(mCarUiInsets);
+            }
+            controller.onPlaybackControlsChanged(mPlaybackControlsVisible);
+
+            mBrowseViewControllersByNode.put(item, controller);
+        }
+        return controller;
+    }
+
+    private void showCurrentNode(boolean show) {
+        MediaItemMetadata currentNode = getCurrentMediaItem();
+        if (currentNode == null) {
+            return;
+        }
+        // Only create a controller to show it.
+        BrowseViewController controller = show ? getControllerForItem(currentNode) :
+                mBrowseViewControllersByNode.get(currentNode);
+
+        if (controller != null) {
+            showHideViewAnimated(show, controller.getContent(), mFadeDuration,
+                    mViewAnimEndListener);
+        }
+    }
+
+    private void showSearchResults(boolean show) {
+        if (mViewModel.isShowingSearchResults() != show) {
+            mViewModel.setShowingSearchResults(show);
+            showHideViewAnimated(show, mSearchResultsController.getContent(), mFadeDuration, null);
+        }
+    }
+
+    private void showSearchMode(boolean show) {
+        if (mViewModel.isSearching() != show) {
+            if (show) {
+                showCurrentNode(false);
+            }
+
+            mViewModel.setSearching(show);
+            showSearchResults(show);
+
+            if (!show) {
+                showCurrentNode(true);
+            }
+        }
+    }
+
+    /**
+     * @return the current item being displayed
+     */
+    @Nullable
+    private MediaItemMetadata getCurrentMediaItem() {
+        Stack<MediaItemMetadata> stack = getStack();
+        return stack.isEmpty() ? null : stack.lastElement();
+    }
+
+    @Override
+    public void onCarUiInsetsChanged(@NonNull Insets insets) {
+        mCarUiInsets = insets;
+        for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
+            controller.onCarUiInsetsChanged(mCarUiInsets);
+        }
+        mRootLoadingController.onCarUiInsetsChanged(mCarUiInsets);
+        mSearchResultsController.onCarUiInsetsChanged(mCarUiInsets);
+    }
+
+    void onPlaybackControlsChanged(boolean visible) {
+        mPlaybackControlsVisible = visible;
+        for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
+            controller.onPlaybackControlsChanged(mPlaybackControlsVisible);
+        }
+        mRootLoadingController.onPlaybackControlsChanged(mPlaybackControlsVisible);
+        mSearchResultsController.onPlaybackControlsChanged(mPlaybackControlsVisible);
+    }
+
+    private void hideKeyboard() {
+        InputMethodManager in =
+                (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
+        in.hideSoftInputFromWindow(mContent.getWindowToken(), 0);
+    }
+
+    private void hideAndDestroyControllerForItem(@Nullable MediaItemMetadata item) {
+        if (item == null) {
+            return;
+        }
+        BrowseViewController controller = mBrowseViewControllersByNode.get(item);
+        if (controller == null) {
+            return;
+        }
+
+        if (controller.getContent().getVisibility() == View.VISIBLE) {
+            View view = controller.getContent();
+            mBrowseViewControllersToDestroy.put(view, controller);
+            showHideViewAnimated(false, view, mFadeDuration, mViewAnimEndListener);
+        } else {
+            controller.destroy();
+        }
+        mBrowseViewControllersByNode.remove(item);
+    }
+
+    /**
+     * Clears the given stack (or a portion of a stack) and destroys the old controllers (after
+     * their view is hidden).
+     */
+    private void clearStack(List<MediaItemMetadata> stack) {
+        for (MediaItemMetadata item : stack) {
+            hideAndDestroyControllerForItem(item);
+        }
+        stack.clear();
+    }
+
+    /**
+     * Updates the tabs displayed on the app bar, based on the top level items on the browse tree.
+     * If there is at least one browsable item, we show the browse content of that node. If there
+     * are only playable items, then we show those items. If there are not items at all, we show the
+     * empty message. If we receive null, we show the error message.
+     *
+     * @param items top level items, null if the items are still being loaded, or empty list if
+     *              items couldn't be loaded.
+     */
+    private void updateTabs(@Nullable List<MediaItemMetadata> items) {
+        if (Objects.equals(mTopItems, items)) {
+            // When coming back to the app, the live data sends an update even if the list hasn't
+            // changed. Updating the tabs then recreates the browse view, which produces jank
+            // (b/131830876), and also resets the navigation to the top of the first tab...
+            return;
+        }
+        mTopItems = items;
+
+        if (mTopItems == null || mTopItems.isEmpty()) {
+            mAppBarController.setItems(null);
+            mAppBarController.setActiveItem(null);
+            if (items != null) {
+                // Only do this when not loading the tabs or we loose the saved one.
+                clearStack(mBrowseStack);
+            }
+            updateAppBar();
+            return;
+        }
+
+        MediaItemMetadata oldTab = mViewModel.getSelectedTab();
+        MediaItemMetadata newTab = items.contains(oldTab) ? oldTab : items.get(0);
+
+        try {
+            mAcceptTabSelection = false;
+            mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems);
+            mAppBarController.setActiveItem(newTab);
+
+            if (oldTab != newTab) {
+                // Tabs belong to the browse stack.
+                clearStack(mBrowseStack);
+                mBrowseStack.push(newTab);
+            }
+
+            if (!mViewModel.isShowingSearchResults()) {
+                // Needed when coming back to an app after a config change or from another app,
+                // or when the tab actually changes.
+                showCurrentNode(true);
+            }
+        }  finally {
+            mAcceptTabSelection = true;
+        }
+        updateAppBar();
+    }
+
+    private void updateAppBarTitle() {
+        boolean isStacked = !isAtTopStack();
+
+        final CharSequence title;
+        if (isStacked) {
+            // If not at top level, show the current item as title
+            title = getCurrentMediaItem().getTitle();
+        } else if (mTopItems == null) {
+            // If still loading the tabs, force to show an empty bar.
+            title = "";
+        } else if (mTopItems.size() == 1) {
+            // If we finished loading tabs and there is only one, use that as title.
+            title = mTopItems.get(0).getTitle();
+        } else {
+            // Otherwise (no tabs or more than 1 tabs), show the current media source title.
+            MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
+            title = getAppBarDefaultTitle(mediaSource);
+        }
+
+        mAppBarController.setTitle(title);
+    }
+
+    /**
+     * Update elements of the appbar that change depending on where we are in the browse.
+     */
+    private void updateAppBar() {
+        boolean isSearching = mViewModel.isSearching();
+        boolean isStacked = !isAtTopStack();
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, "App bar is in stacked state: " + isStacked);
+        }
+        Toolbar.State unstackedState = isSearching ? Toolbar.State.SEARCH : Toolbar.State.HOME;
+        updateAppBarTitle();
+        mAppBarController.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
+        mAppBarController.showSearchIfSupported(!isSearching || isStacked);
+    }
+
+    private void onRootMediaItemsUpdate(FutureData<List<MediaItemMetadata>> data) {
+        if (data.isLoading()) {
+            if (Log.isLoggable(TAG, Log.INFO)) {
+                Log.i(TAG, "Loading browse tree...");
+            }
+            mBrowseTreeHasChildren = false;
+            updateTabs(null);
+            return;
+        }
+
+        List<MediaItemMetadata> items =
+                MediaBrowserViewModelImpl.filterItems(/*forRoot*/ true, data.getData());
+
+        boolean browseTreeHasChildren = items != null && !items.isEmpty();
+        if (Log.isLoggable(TAG, Log.INFO)) {
+            Log.i(TAG, "Browse tree loaded, status (has children or not) changed: "
+                    + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren);
+        }
+        mBrowseTreeHasChildren = browseTreeHasChildren;
+        mCallbacks.onRootLoaded();
+        updateTabs(items != null ? items : new ArrayList<>());
+    }
+
+}
diff --git a/src/com/android/car/media/MediaDispatcherActivity.java b/src/com/android/car/media/MediaDispatcherActivity.java
index b44feb9..973937b 100644
--- a/src/com/android/car/media/MediaDispatcherActivity.java
+++ b/src/com/android/car/media/MediaDispatcherActivity.java
@@ -67,6 +67,8 @@
             // Launch custom app (e.g. Radio)
             String srcPackage = mediaSrc.getPackageName();
             newIntent = getPackageManager().getLaunchIntentForPackage(srcPackage);
+            newIntent.putExtra(Car.CAR_EXTRA_MEDIA_COMPONENT,
+                    mediaSrc.getBrowseServiceComponentName().flattenToString());
             if (Log.isLoggable(TAG, Log.DEBUG)) {
                 Log.d(TAG, "Getting launch intent for package : " + srcPackage + (newIntent != null
                         ? " succeeded" : " failed"));
diff --git a/src/com/android/car/media/browse/BrowseAdapter.java b/src/com/android/car/media/browse/BrowseAdapter.java
index abf770d..847d270 100644
--- a/src/com/android/car/media/browse/BrowseAdapter.java
+++ b/src/com/android/car/media/browse/BrowseAdapter.java
@@ -33,7 +33,6 @@
 import com.android.car.media.common.MediaItemMetadata;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
@@ -100,13 +99,13 @@
         /**
          * Callback invoked when a user clicks on a playable item.
          */
-        protected void onPlayableItemClicked(MediaItemMetadata item) {
+        protected void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
         }
 
         /**
          * Callback invoked when a user clicks on a browsable item.
          */
-        protected void onBrowsableItemClicked(MediaItemMetadata item) {
+        protected void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
         }
 
         /**
diff --git a/src/com/android/car/media/browse/LimitedBrowseAdapter.java b/src/com/android/car/media/browse/LimitedBrowseAdapter.java
index bbca980..6d3c6a7 100644
--- a/src/com/android/car/media/browse/LimitedBrowseAdapter.java
+++ b/src/com/android/car/media/browse/LimitedBrowseAdapter.java
@@ -65,10 +65,6 @@
                 }
             };
 
-    public BrowseAdapter getBrowseAdapter() {
-        return mBrowseAdapter;
-    }
-
     /**
      * @see BrowseAdapter#submitItems(MediaItemMetadata, List)
      */
diff --git a/src/com/android/car/media/widgets/AppBarController.java b/src/com/android/car/media/widgets/AppBarController.java
index 3c174a3..962f2ac 100644
--- a/src/com/android/car/media/widgets/AppBarController.java
+++ b/src/com/android/car/media/widgets/AppBarController.java
@@ -4,6 +4,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.graphics.drawable.Drawable;
+import android.view.View;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -34,6 +35,8 @@
     private int mMaxTabs;
     private final ToolbarController mToolbarController;
 
+    private final boolean mUseSourceLogoForAppSelector;
+
     @NonNull
     private AppBarListener mListener = new AppBarListener();
     private MenuItem mSearch;
@@ -43,6 +46,7 @@
 
     private boolean mSearchSupported;
     private boolean mShowSearchIfSupported;
+    private String mSearchQuery;
 
     private Intent mAppSelectorIntent;
 
@@ -80,11 +84,19 @@
         mToolbarController = controller;
         mMaxTabs = context.getResources().getInteger(R.integer.max_tabs);
 
+        mUseSourceLogoForAppSelector =
+                context.getResources().getBoolean(R.bool.use_media_source_logo_for_app_selector);
+
         mAppSelectorIntent = MediaSource.getSourceSelectorIntent(context, false);
 
         mToolbarController.registerOnTabSelectedListener(tab ->
                 mListener.onTabSelected(((MediaItemTab) tab).getItem()));
-        mToolbarController.registerOnSearchListener(query -> mListener.onSearch(query));
+        mToolbarController.registerOnSearchListener(query -> {
+            mSearchQuery = query;
+            mListener.onSearch(query);
+        });
+        mToolbarController.registerOnSearchCompletedListener(
+                () -> mListener.onSearch(mSearchQuery));
         mSearch = MenuItem.builder(context)
                 .setToSearch()
                 .setOnClickListener(v -> mListener.onSearchSelection())
@@ -101,7 +113,9 @@
                 .build();
         mAppSelector = MenuItem.builder(context)
                 .setTitle(R.string.menu_item_app_selector_title)
-                .setIcon(R.drawable.ic_app_switch)
+                .setTinted(!mUseSourceLogoForAppSelector)
+                .setIcon(mUseSourceLogoForAppSelector
+                        ? null : context.getDrawable(R.drawable.ic_app_switch))
                 .setOnClickListener(m -> context.startActivity(mAppSelectorIntent))
                 .build();
         mToolbarController.setMenuItems(
@@ -202,7 +216,11 @@
     }
 
     public void setLogo(Drawable drawable) {
-        mToolbarController.setLogo(drawable);
+        if (mUseSourceLogoForAppSelector) {
+            mAppSelector.setIcon(drawable);
+        } else {
+            mToolbarController.setLogo(drawable);
+        }
     }
 
     public void setSearchIcon(Drawable drawable) {
@@ -229,11 +247,17 @@
         mToolbarController.setBackgroundShown(shown);
     }
 
-    public void registerOnBackListener(Toolbar.OnBackListener listener) {
-        mToolbarController.registerOnBackListener(listener);
-    }
-
     public void setNavButtonMode(Toolbar.NavButtonMode mode) {
         mToolbarController.setNavButtonMode(mode);
     }
+
+    /** See {@link ToolbarController#canShowSearchResultItems}. */
+    public boolean canShowSearchResultsView() {
+        return mToolbarController.canShowSearchResultsView();
+    }
+
+    /** See {@link ToolbarController#setSearchResultsView}. */
+    public void setSearchResultsView(View view) {
+        mToolbarController.setSearchResultsView(view);
+    }
 }