Refactor media error handling and make the widget display fatal errors.

Fixes: 165084717
Test: manual
Change-Id: I5e734bcb66d84c6c46c61243bc236e4544969e1b
diff --git a/car-media-common/res/layout/playback_fragment.xml b/car-media-common/res/layout/playback_fragment.xml
index 4a33a27..bd77f74 100644
--- a/car-media-common/res/layout/playback_fragment.xml
+++ b/car-media-common/res/layout/playback_fragment.xml
@@ -97,6 +97,29 @@
                 app:layout_constraintEnd_toStartOf="@+id/app_selector_container"
                 app:layout_constraintTop_toBottomOf="@+id/title"/>
 
+            <com.android.car.apps.common.UxrTextView
+                android:id="@+id/error_message"
+                style="@style/FullScreenErrorMessageStyle"
+                android:layout_marginHorizontal="@dimen/playback_fragment_text_margin_x"
+                android:maxLines="@integer/widget_error_text_max_lines"
+                app:layout_constraintVertical_chainStyle="packed"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/app_name"
+                app:layout_constraintBottom_toTopOf="@+id/error_button"
+            />
+
+            <com.android.car.apps.common.UxrButton
+                android:id="@+id/error_button"
+                style="@style/FullScreenErrorButtonStyle"
+                android:layout_marginTop="@dimen/playback_fragment_error_button_margin_top"
+                android:layout_marginBottom="@dimen/playback_fragment_error_button_margin_bottom"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/error_message"
+                app:layout_constraintBottom_toBottomOf="parent"
+            />
+
             <FrameLayout
                 android:id="@+id/app_selector_container"
                 xmlns:android="http://schemas.android.com/apk/res/android"
diff --git a/car-media-common/res/values/dimens.xml b/car-media-common/res/values/dimens.xml
index d888e4b..058ec8e 100644
--- a/car-media-common/res/values/dimens.xml
+++ b/car-media-common/res/values/dimens.xml
@@ -24,6 +24,8 @@
     <!-- playback_fragment.xml -->
     <dimen name="playback_fragment_text_margin_top">@dimen/car_ui_padding_4</dimen>
     <dimen name="playback_fragment_text_margin_x">@dimen/car_ui_padding_4</dimen>
+    <dimen name="playback_fragment_error_button_margin_top">@dimen/car_ui_padding_5</dimen>
+    <dimen name="playback_fragment_error_button_margin_bottom">@dimen/car_ui_padding_4</dimen>
 
     <dimen name="playback_fragment_app_icon_margin_right">@dimen/car_ui_padding_4</dimen>
     <dimen name="playback_fragment_controls_margin_bottom">@dimen/car_ui_padding_4</dimen>
diff --git a/car-media-common/res/values/integers.xml b/car-media-common/res/values/integers.xml
index 2c6177d..cda2e06 100644
--- a/car-media-common/res/values/integers.xml
+++ b/car-media-common/res/values/integers.xml
@@ -36,4 +36,7 @@
     -->
     <integer name="media_items_bitmap_max_size_px">256</integer>
 
+    <!-- The maximum number of lines for the error message in the media widget. -->
+    <integer name="widget_error_text_max_lines">3</integer>
+
 </resources>
diff --git a/car-media-common/res/values/strings.xml b/car-media-common/res/values/strings.xml
index e5831bc..424e9f7 100644
--- a/car-media-common/res/values/strings.xml
+++ b/car-media-common/res/values/strings.xml
@@ -19,4 +19,29 @@
     <string name="album_art">Album Art</string>
     <!-- Default title of current track's metadata. [CHAR LIMIT=50] -->
     <string name="metadata_default_title">No Title</string>
+
+    <!-- Default error message [CHAR LIMIT=100] -->
+    <string name="default_error_message">Something’s wrong. Try later.</string>
+    <!-- Error message set when the application state is invalid to fulfill the request. [CHAR LIMIT=100] -->
+    <string name="error_code_app_error">Can’t do that right now</string>
+    <!-- Error message set when the request is not supported by the application. [CHAR LIMIT=100] -->
+    <string name="error_code_not_supported">This app can’t do that</string>
+    <!-- Error message set when the request cannot be performed because authentication has expired. [CHAR LIMIT=100] -->
+    <string name="error_code_authentication_expired">Sign in to use this app</string>
+    <!-- Error message set when a premium account is required for the request to succeed. [CHAR LIMIT=100] -->
+    <string name="error_code_premium_account_required">Premium access required</string>
+    <!-- Error message set when too many concurrent streams are detected. [CHAR LIMIT=100] -->
+    <string name="error_code_concurrent_stream_limit">Listening on too many devices</string>
+    <!-- Error message set when the content is blocked due to parental controls. [CHAR LIMIT=100] -->
+    <string name="error_code_parental_control_restricted">That content is blocked</string>
+    <!-- Error message set when the content is blocked due to being regionally unavailable. [CHAR LIMIT=100] -->
+    <string name="error_code_not_available_in_region">Can’t get that content here</string>
+    <!-- Error message set when the requested content is already playing. [CHAR LIMIT=100] -->
+    <string name="error_code_content_already_playing">Already playing that content</string>
+    <!-- Error message set when the application cannot skip any more songs because skip limit is reached. [CHAR LIMIT=100] -->
+    <string name="error_code_skip_limit_reached">Can’t skip any more tracks</string>
+    <!-- Error message set when the action is interrupted due to some external event. [CHAR LIMIT=100] -->
+    <string name="error_code_action_aborted">Couldn’t finish. Try again.</string>
+    <!-- Error message set when the playback navigation (previous, next) is not possible because the queue was exhausted. [CHAR LIMIT=100] -->
+    <string name="error_code_end_of_queue">Nothing else is queued up</string>
 </resources>
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackErrorViewController.java b/car-media-common/src/com/android/car/media/common/PlaybackErrorViewController.java
new file mode 100644
index 0000000..2d4c7c8
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/PlaybackErrorViewController.java
@@ -0,0 +1,93 @@
+/*
+ * 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.common;
+
+import android.app.PendingIntent;
+import android.car.drivingstate.CarUxRestrictions;
+import android.content.res.Resources;
+import android.util.Log;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.android.car.apps.common.UxrButton;
+import com.android.car.apps.common.UxrTextView;
+import com.android.car.apps.common.util.ViewUtils;
+
+
+/** Simple controller for the error message and the error button. */
+public class PlaybackErrorViewController {
+
+    private static final String TAG = "PlaybckErrViewCtrlr";
+
+    // mErrorMessageView is defined explicitly as a UxrTextView instead of a TextView to
+    // provide clarity as it may be misleading to assume that mErrorMessageView extends all TextView
+    // methods. In addition, it increases discoverability of runtime issues that may occur.
+    private final UxrTextView mErrorMessageView;
+    private final UxrButton mErrorButton;
+
+    private final int mFadeDuration;
+
+    public PlaybackErrorViewController(View content) {
+        mErrorMessageView = content.findViewById(R.id.error_message);
+        mErrorButton = content.findViewById(R.id.error_button);
+
+        Resources res = content.getContext().getResources();
+        mFadeDuration = res.getInteger(R.integer.new_album_art_fade_in_duration);
+    }
+
+    /** Animates away the error views. */
+    public void hideError() {
+        ViewUtils.hideViewAnimated(mErrorMessageView, mFadeDuration);
+        ViewUtils.hideViewAnimated(mErrorButton, mFadeDuration);
+    }
+
+    /** Hides the error views without animation. */
+    public void hideErrorNoAnim() {
+        ViewUtils.hideViewAnimated(mErrorMessageView, 0);
+        ViewUtils.hideViewAnimated(mErrorButton, 0);
+    }
+
+    /** Sets the error message and optionally the error button. */
+    public void setError(String message, @Nullable String label,
+            @Nullable PendingIntent pendingIntent, boolean isDistractionOptimized) {
+        mErrorMessageView.setText(message);
+        ViewUtils.showViewAnimated(mErrorMessageView, mFadeDuration);
+
+        // Only show the error button if the error is actionable.
+        if (label != null && pendingIntent != null) {
+            mErrorButton.setText(label);
+
+            mErrorButton.setUxRestrictions(isDistractionOptimized
+                    ? CarUxRestrictions.UX_RESTRICTIONS_BASELINE
+                    : CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP);
+
+            mErrorButton.setOnClickListener(v -> {
+                try {
+                    pendingIntent.send();
+                } catch (PendingIntent.CanceledException e) {
+                    if (Log.isLoggable(TAG, Log.ERROR)) {
+                        Log.e(TAG, "Pending intent canceled");
+                    }
+                }
+            });
+            ViewUtils.showViewAnimated(mErrorButton, mFadeDuration);
+        } else {
+            ViewUtils.hideViewAnimated(mErrorButton, mFadeDuration);
+        }
+    }
+}
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackErrorsHelper.java b/car-media-common/src/com/android/car/media/common/PlaybackErrorsHelper.java
new file mode 100644
index 0000000..475346d
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/PlaybackErrorsHelper.java
@@ -0,0 +1,150 @@
+/*
+ * 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.common;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.media.session.PlaybackStateCompat;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.car.media.common.playback.PlaybackViewModel.PlaybackStateWrapper;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Abstract class to factorize most of the error handling logic.
+ */
+public abstract class PlaybackErrorsHelper {
+
+    private static final Map<Integer, Integer> ERROR_CODE_MESSAGES_MAP;
+
+    static {
+        Map<Integer, Integer> map = new HashMap<>();
+        map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error);
+        map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported);
+        map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED,
+                R.string.error_code_authentication_expired);
+        map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED,
+                R.string.error_code_premium_account_required);
+        map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT,
+                R.string.error_code_concurrent_stream_limit);
+        map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED,
+                R.string.error_code_parental_control_restricted);
+        map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION,
+                R.string.error_code_not_available_in_region);
+        map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING,
+                R.string.error_code_content_already_playing);
+        map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED,
+                R.string.error_code_skip_limit_reached);
+        map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted);
+        map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue);
+        ERROR_CODE_MESSAGES_MAP = Collections.unmodifiableMap(map);
+    }
+
+    private final Context mContext;
+    private PlaybackStateWrapper mCurrentPlaybackStateWrapper;
+
+    public PlaybackErrorsHelper(Context context) {
+        mContext = context;
+    }
+
+    protected abstract void handleNewPlaybackState(String displayedMessage, PendingIntent intent,
+            String label);
+
+    /**
+     * Triggers updates of the error state.
+     * Must be called when the children list of the root of the browse tree changes AND when
+     * the playback state changes.
+     */
+    public void handlePlaybackState(@NonNull String tag, PlaybackStateWrapper state,
+            boolean ignoreSameState) {
+        if (Log.isLoggable(tag, Log.DEBUG)) {
+            Log.d(tag,
+                    "handlePlaybackState(); state change: " + (mCurrentPlaybackStateWrapper != null
+                            ? mCurrentPlaybackStateWrapper.getState() : null) + " -> " + (
+                            state != null ? state.getState() : null));
+        }
+
+        if (state == null) {
+            mCurrentPlaybackStateWrapper = null;
+            return;
+        }
+
+        String displayedMessage = getDisplayedMessage(mContext, state);
+        if (Log.isLoggable(tag, Log.DEBUG)) {
+            Log.d(tag, "Displayed error message: [" + displayedMessage + "]");
+        }
+        if (ignoreSameState && mCurrentPlaybackStateWrapper != null
+                && mCurrentPlaybackStateWrapper.getState() == state.getState()
+                && TextUtils.equals(displayedMessage,
+                getDisplayedMessage(mContext, mCurrentPlaybackStateWrapper))) {
+            if (Log.isLoggable(tag, Log.DEBUG)) {
+                Log.d(tag, "Ignore same playback state.");
+            }
+            return;
+        }
+
+        mCurrentPlaybackStateWrapper = state;
+
+        PendingIntent intent = getErrorResolutionIntent(state);
+        String label = getErrorResolutionLabel(state);
+        handleNewPlaybackState(displayedMessage, intent, label);
+    }
+
+
+    @Nullable
+    private String getDisplayedMessage(Context ctx, @Nullable PlaybackStateWrapper state) {
+        if (state == null) {
+            return null;
+        }
+        if (!TextUtils.isEmpty(state.getErrorMessage())) {
+            return state.getErrorMessage().toString();
+        }
+        // ERROR_CODE_UNKNOWN_ERROR means there is no error in PlaybackState.
+        if (state.getErrorCode() != PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR) {
+            Integer messageId = ERROR_CODE_MESSAGES_MAP.get(state.getErrorCode());
+            return messageId != null ? ctx.getString(messageId) : ctx.getString(
+                    R.string.default_error_message);
+        }
+        if (state.getState() == PlaybackStateCompat.STATE_ERROR) {
+            return ctx.getString(R.string.default_error_message);
+        }
+        return null;
+    }
+
+    @Nullable
+    private PendingIntent getErrorResolutionIntent(@NonNull PlaybackStateWrapper state) {
+        Bundle extras = state.getExtras();
+        return extras == null ? null : extras.getParcelable(
+                MediaConstants.ERROR_RESOLUTION_ACTION_INTENT);
+    }
+
+    @Nullable
+    private String getErrorResolutionLabel(@NonNull PlaybackStateWrapper state) {
+        Bundle extras = state.getExtras();
+        return extras == null ? null : extras.getString(
+                MediaConstants.ERROR_RESOLUTION_ACTION_LABEL);
+    }
+
+}
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackFragment.java b/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
index 9ddf601..60f4d18 100644
--- a/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
+++ b/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
@@ -21,10 +21,13 @@
 import static com.android.car.arch.common.LiveDataFunctions.mapNonNull;
 
 import android.app.Application;
+import android.app.PendingIntent;
 import android.car.Car;
+import android.car.content.pm.CarPackageManager;
 import android.content.Intent;
 import android.graphics.Bitmap;
 import android.os.Bundle;
+import android.text.TextUtils;
 import android.util.Size;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -38,65 +41,103 @@
 import androidx.fragment.app.FragmentActivity;
 import androidx.lifecycle.AndroidViewModel;
 import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModelProviders;
 
 import com.android.car.apps.common.BitmapUtils;
 import com.android.car.apps.common.CrossfadeImageView;
 import com.android.car.apps.common.imaging.ImageBinder;
 import com.android.car.apps.common.imaging.ImageBinder.PlaceholderType;
+import com.android.car.apps.common.util.CarPackageManagerUtils;
 import com.android.car.apps.common.util.ViewUtils;
+import com.android.car.arch.common.FutureData;
+import com.android.car.media.common.browse.BrowsedMediaItems;
+import com.android.car.media.common.browse.MediaBrowserViewModel;
 import com.android.car.media.common.playback.PlaybackViewModel;
+import com.android.car.media.common.playback.PlaybackViewModel.PlaybackStateWrapper;
 import com.android.car.media.common.source.MediaSource;
 import com.android.car.media.common.source.MediaSourceViewModel;
 
+import java.util.List;
+
 /**
  * {@link Fragment} that can be used to display and control the currently playing media item. Its
  * requires the android.Manifest.permission.MEDIA_CONTENT_CONTROL permission be held by the hosting
  * application.
  */
 public class PlaybackFragment extends Fragment {
+    private static final String TAG = "PlaybackFragmentWidget";
+
+    private Car mCar;
+    private CarPackageManager mCarPackageManager;
     private Intent mAppSelectorIntent;
     private MediaSourceViewModel mMediaSourceViewModel;
+    private PlaybackViewModel mPlaybackViewModel;
     private ImageBinder<MediaItemMetadata.ArtworkRef> mAlbumArtBinder;
+    private ViewModel mInnerViewModel;
+
+    private PlaybackErrorViewController mPlaybackErrorViewController;
+    private PlaybackErrorsHelper mErrorsHelper;
+    private boolean mIsFatalError;
 
     @Nullable
     @Override
     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
             Bundle savedInstanceState) {
         FragmentActivity activity = requireActivity();
-        PlaybackViewModel playbackViewModel = PlaybackViewModel.get(activity.getApplication(),
+        mCar = Car.createCar(activity);
+        mCarPackageManager = (CarPackageManager) mCar.getCarManager(Car.PACKAGE_SERVICE);
+
+        mPlaybackViewModel = PlaybackViewModel.get(activity.getApplication(),
                 MEDIA_SOURCE_MODE_PLAYBACK);
         mMediaSourceViewModel = MediaSourceViewModel.get(activity.getApplication(),
                 MEDIA_SOURCE_MODE_PLAYBACK);
         mAppSelectorIntent = MediaSource.getSourceSelectorIntent(getContext(), true);
 
-        ViewModel innerViewModel = ViewModelProviders.of(activity).get(ViewModel.class);
-        innerViewModel.init(mMediaSourceViewModel, playbackViewModel);
+        mInnerViewModel = ViewModelProviders.of(activity).get(ViewModel.class);
+        mInnerViewModel.init(activity, mMediaSourceViewModel, mPlaybackViewModel);
 
         View view = inflater.inflate(R.layout.playback_fragment, container, false);
 
+        mPlaybackErrorViewController = new PlaybackErrorViewController(view);
+
         PlaybackControlsActionBar playbackControls = view.findViewById(R.id.playback_controls);
-        playbackControls.setModel(playbackViewModel, getViewLifecycleOwner());
-        playbackViewModel.getPlaybackStateWrapper().observe(getViewLifecycleOwner(),
-                state -> ViewUtils.setVisible(playbackControls,
-                        (state != null) && state.shouldDisplay()));
+        playbackControls.setModel(mPlaybackViewModel, getViewLifecycleOwner());
+        mPlaybackViewModel.getPlaybackStateWrapper().observe(getViewLifecycleOwner(),
+                state -> {
+                    ViewUtils.setVisible(playbackControls,
+                            (state != null) && state.shouldDisplay());
+                    if (mErrorsHelper != null) {
+                        mErrorsHelper.handlePlaybackState(TAG, state, /*ignoreSameState*/ true);
+                    }
+                });
 
         TextView appName = view.findViewById(R.id.app_name);
-        innerViewModel.getAppName().observe(getViewLifecycleOwner(), appName::setText);
+        mInnerViewModel.getAppName().observe(getViewLifecycleOwner(), appName::setText);
 
         TextView title = view.findViewById(R.id.title);
-        innerViewModel.getTitle().observe(getViewLifecycleOwner(), title::setText);
+        mInnerViewModel.getTitle().observe(getViewLifecycleOwner(), title::setText);
 
         TextView subtitle = view.findViewById(R.id.subtitle);
-        innerViewModel.getSubtitle().observe(getViewLifecycleOwner(), subtitle::setText);
+        mInnerViewModel.getSubtitle().observe(getViewLifecycleOwner(), subtitle::setText);
 
         ImageView appIcon = view.findViewById(R.id.app_icon);
-        innerViewModel.getAppIcon().observe(getViewLifecycleOwner(), appIcon::setImageBitmap);
+        mInnerViewModel.getAppIcon().observe(getViewLifecycleOwner(), appIcon::setImageBitmap);
+
+        mInnerViewModel.getBrowseTreeHasChildren().observe(getViewLifecycleOwner(),
+                this::onBrowseTreeHasChildrenChanged);
+
+        mMediaSourceViewModel.getPrimaryMediaSource().observe(getViewLifecycleOwner(),
+                this::onMediaSourceChanged);
 
         View playbackScrim = view.findViewById(R.id.playback_scrim);
         playbackScrim.setOnClickListener(
                 // Let the Media center trampoline figure out what to open.
-                v -> startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE)));
+                v -> {
+                    if (!mIsFatalError) {
+                        startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE));
+                    }
+                });
 
         CrossfadeImageView albumBackground = view.findViewById(R.id.album_background);
         int max = activity.getResources().getInteger(R.integer.media_items_bitmap_max_size_px);
@@ -108,16 +149,59 @@
                     albumBackground.setImageBitmap(bitmap, true);
                 });
 
-        playbackViewModel.getMetadata().observe(getViewLifecycleOwner(),
+        mPlaybackViewModel.getMetadata().observe(getViewLifecycleOwner(),
                 item -> mAlbumArtBinder.setImage(PlaybackFragment.this.getContext(),
                         item != null ? item.getArtworkKey() : null));
         View appSelector = view.findViewById(R.id.app_selector_container);
         appSelector.setVisibility(mAppSelectorIntent != null ? View.VISIBLE : View.GONE);
         appSelector.setOnClickListener(e -> getContext().startActivity(mAppSelectorIntent));
 
+
+        mErrorsHelper = new PlaybackErrorsHelper(activity) {
+
+            @Override
+            public void handleNewPlaybackState(String displayedMessage, PendingIntent intent,
+                    String label) {
+                mIsFatalError = false;
+                if (!TextUtils.isEmpty(displayedMessage)) {
+                    Boolean hasChildren = mInnerViewModel.getBrowseTreeHasChildren().getValue();
+                    if (hasChildren != null && !hasChildren) {
+                        boolean isDistractionOptimized =
+                                intent != null && CarPackageManagerUtils.isDistractionOptimized(
+                                        mCarPackageManager, intent);
+                        mPlaybackErrorViewController.setError(displayedMessage, label, intent,
+                                isDistractionOptimized);
+                        mIsFatalError = true;
+                    }
+                }
+
+                if (!mIsFatalError) {
+                    mPlaybackErrorViewController.hideError();
+                }
+            }
+        };
+
         return view;
     }
 
+    @Override
+    public void onDestroyView() {
+        super.onDestroyView();
+        mCar.disconnect();
+        mErrorsHelper = null;
+    }
+
+    private void onBrowseTreeHasChildrenChanged(@Nullable Boolean hasChildren) {
+        if (hasChildren != null && mErrorsHelper != null) {
+            PlaybackStateWrapper state = mPlaybackViewModel.getPlaybackStateWrapper().getValue();
+            mErrorsHelper.handlePlaybackState(TAG, state, /*ignoreSameState*/ false);
+        }
+    }
+
+    private void onMediaSourceChanged(MediaSource source) {
+        mPlaybackErrorViewController.hideErrorNoAnim();
+    }
+
     /**
      * ViewModel for the PlaybackFragment
      */
@@ -128,15 +212,18 @@
         private LiveData<Bitmap> mAppIcon;
         private LiveData<CharSequence> mTitle;
         private LiveData<CharSequence> mSubtitle;
+        private MutableLiveData<Boolean> mBrowseTreeHasChildren = new MutableLiveData<>();
 
         private PlaybackViewModel mPlaybackViewModel;
         private MediaSourceViewModel mMediaSourceViewModel;
+        private MediaBrowserViewModel mRootMediaBrowserViewModel;
 
         public ViewModel(Application application) {
             super(application);
         }
 
-        void init(MediaSourceViewModel mediaSourceViewModel, PlaybackViewModel playbackViewModel) {
+        void init(FragmentActivity activity, MediaSourceViewModel mediaSourceViewModel,
+                PlaybackViewModel playbackViewModel) {
             if (mMediaSourceViewModel == mediaSourceViewModel
                     && mPlaybackViewModel == playbackViewModel) {
                 return;
@@ -148,6 +235,11 @@
             mAppIcon = mapNonNull(mMediaSource, MediaSource::getCroppedPackageIcon);
             mTitle = mapNonNull(playbackViewModel.getMetadata(), MediaItemMetadata::getTitle);
             mSubtitle = mapNonNull(playbackViewModel.getMetadata(), MediaItemMetadata::getArtist);
+
+            mRootMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(
+                    mMediaSourceViewModel, ViewModelProviders.of(activity));
+            mRootMediaBrowserViewModel.getBrowsedMediaItems()
+                    .observe(activity, this::onItemsUpdate);
         }
 
         LiveData<CharSequence> getAppName() {
@@ -165,5 +257,22 @@
         LiveData<CharSequence> getSubtitle() {
             return mSubtitle;
         }
+
+        LiveData<Boolean> getBrowseTreeHasChildren() {
+            return mBrowseTreeHasChildren;
+        }
+
+        private void onItemsUpdate(FutureData<List<MediaItemMetadata>> futureData) {
+            if (futureData.isLoading()) {
+                mBrowseTreeHasChildren.setValue(null);
+                return;
+            }
+
+            List<MediaItemMetadata> items =
+                    BrowsedMediaItems.filterItems(/*forRoot*/ true, futureData.getData());
+
+            boolean browseTreeHasChildren = items != null && !items.isEmpty();
+            mBrowseTreeHasChildren.setValue(browseTreeHasChildren);
+        }
     }
 }
diff --git a/car-media-common/src/com/android/car/media/common/browse/BrowsedMediaItems.java b/car-media-common/src/com/android/car/media/common/browse/BrowsedMediaItems.java
index f93be0a..c9a5864 100644
--- a/car-media-common/src/com/android/car/media/common/browse/BrowsedMediaItems.java
+++ b/car-media-common/src/com/android/car/media/common/browse/BrowsedMediaItems.java
@@ -21,19 +21,21 @@
 import android.support.v4.media.MediaBrowserCompat;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 
 import com.android.car.media.common.MediaItemMetadata;
 
 import java.util.List;
 import java.util.Objects;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 /**
  * A LiveData that provides access the a MediaBrowser's children
  */
 
-class BrowsedMediaItems extends LiveData<List<MediaItemMetadata>> {
+public class BrowsedMediaItems extends LiveData<List<MediaItemMetadata>> {
 
     /**
      * Number of times we will retry obtaining the list of children of a certain node
@@ -58,6 +60,19 @@
         mParentId = parentId;
     }
 
+    /**
+     * Filters the items that are valid for the root (tabs) or the current node. Returns null when
+     * the given list is null to preserve its error signal.
+     */
+    @Nullable
+    public static List<MediaItemMetadata> filterItems(boolean forRoot,
+            @Nullable List<MediaItemMetadata> items) {
+        if (items == null) return null;
+        Predicate<MediaItemMetadata> predicate = forRoot ? MediaItemMetadata::isBrowsable
+                : item -> (item.isPlayable() || item.isBrowsable());
+        return items.stream().filter(predicate).collect(Collectors.toList());
+    }
+
     @Override
     protected void onActive() {
         super.onActive();