DO NOT MERGE Use PlaybackViewModel in place of PlaybackModel

Color code that was moved from MediaSource into MediaSourceColors is now removed since PlaybackModel no longer needs it.

Test: Manual smoke test
Change-Id: I7722321e9e4e9dfcac668702012838c7d5e0130d
diff --git a/car-arch-common/src/com/android/car/arch/common/LiveDataFunctions.java b/car-arch-common/src/com/android/car/arch/common/LiveDataFunctions.java
index e46b283..7d60f88 100644
--- a/car-arch-common/src/com/android/car/arch/common/LiveDataFunctions.java
+++ b/car-arch-common/src/com/android/car/arch/common/LiveDataFunctions.java
@@ -21,6 +21,7 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 
+import androidx.arch.core.util.Function;
 import androidx.core.util.Pair;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MediatorLiveData;
@@ -104,6 +105,43 @@
     }
 
     /**
+     * Returns a LiveData that emits the same value as {@code data}, but only notifies its observers
+     * when the new value is distinct ({@link Objects#equals(Object, Object)}
+     */
+    public static <T> LiveData<T> distinct(@NonNull LiveData<T> data) {
+        return new MediatorLiveData<T>() {
+            private boolean mInitialized = false;
+
+            {
+                addSource(data, value -> {
+                    if (!mInitialized || !Objects.equals(value, getValue())) {
+                        mInitialized = true;
+                        setValue(value);
+                    }
+                });
+            }
+        };
+    }
+
+    /**
+     * Similar to {@link Transformations#map(LiveData, Function)}, but emits {@code null} when
+     * {@code source} emits {@code null}. The input to {@code func} may be treated as not nullable.
+     */
+    public static <T, R> LiveData<R> mapNonNull(@NonNull LiveData<T> source,
+            @NonNull Function<T, R> func) {
+        return mapNonNull(source, null, func);
+    }
+
+    /**
+     * Similar to {@link Transformations#map(LiveData, Function)}, but emits {@code nullValue} when
+     * {@code source} emits {@code null}. The input to {@code func} may be treated as not nullable.
+     */
+    public static <T, R> LiveData<R> mapNonNull(@NonNull LiveData<T> source, @Nullable R nullValue,
+            @NonNull Function<T, R> func) {
+        return Transformations.map(source, value -> value == null ? nullValue : func.apply(value));
+    }
+
+    /**
      * Returns a LiveData that emits the logical AND of the two arguments. Also deals with {@code
      * null} and uninitalized values as follows:
      * <table>
diff --git a/car-arch-common/tests/robotests/src/com/android/car/arch/common/LiveDataFunctionsTest.java b/car-arch-common/tests/robotests/src/com/android/car/arch/common/LiveDataFunctionsTest.java
index df47259..bd2a0e3 100644
--- a/car-arch-common/tests/robotests/src/com/android/car/arch/common/LiveDataFunctionsTest.java
+++ b/car-arch-common/tests/robotests/src/com/android/car/arch/common/LiveDataFunctionsTest.java
@@ -17,6 +17,7 @@
 package com.android.car.arch.common;
 
 import static com.android.car.arch.common.LiveDataFunctions.dataOf;
+import static com.android.car.arch.common.LiveDataFunctions.distinct;
 import static com.android.car.arch.common.LiveDataFunctions.emitsNull;
 import static com.android.car.arch.common.LiveDataFunctions.falseLiveData;
 import static com.android.car.arch.common.LiveDataFunctions.ifThenElse;
@@ -105,6 +106,27 @@
     }
 
     @Test
+    public void testDistinct() {
+        CaptureObserver<Integer> observer = new CaptureObserver<>();
+        MutableLiveData<Integer> source = dataOf(0);
+        LiveData<Integer> distinct = distinct(source);
+        distinct.observe(mLifecycleOwner, observer);
+        observer.reset();
+
+        source.setValue(1);
+        assertThat(observer.hasBeenNotified()).isTrue();
+        assertThat(observer.getObservedValue()).isEqualTo(1);
+        observer.reset();
+
+        source.setValue(1);
+        assertThat(observer.hasBeenNotified()).isFalse();
+
+        source.setValue(2);
+        assertThat(observer.hasBeenNotified()).isTrue();
+        assertThat(observer.getObservedValue()).isEqualTo(2);
+    }
+
+    @Test
     public void testAnd_truthTable() {
         testBinaryOperator(
                 LiveDataFunctions::and,
diff --git a/car-media-common/src/com/android/car/media/common/ActiveMediaSourceManager.java b/car-media-common/src/com/android/car/media/common/ActiveMediaSourceManager.java
index 6806748..2d6d98f 100644
--- a/car-media-common/src/com/android/car/media/common/ActiveMediaSourceManager.java
+++ b/car-media-common/src/com/android/car/media/common/ActiveMediaSourceManager.java
@@ -25,6 +25,8 @@
 import android.os.Handler;
 import android.util.Log;
 
+import com.android.car.media.common.playback.PlaybackStateAnnotations;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.function.Consumer;
@@ -140,7 +142,7 @@
         }
     }
 
-    private String getStateName(@PlaybackState.State int state) {
+    private String getStateName(@PlaybackStateAnnotations.Actions int state) {
         switch (state) {
             case PlaybackState.STATE_NONE:
                 return "NONE";
@@ -180,7 +182,7 @@
     private MediaController getTopMostController(List<MediaController> controllers) {
         if (controllers != null && controllers.size() > 0) {
             for (MediaController candidate : controllers) {
-                @PlaybackState.State int state = candidate.getPlaybackState() != null
+                @PlaybackStateAnnotations.Actions int state = candidate.getPlaybackState() != null
                         ? candidate.getPlaybackState().getState()
                         : PlaybackState.STATE_NONE;
                 if (state == PlaybackState.STATE_BUFFERING
@@ -241,7 +243,7 @@
 
     /**
      * Stops following changes on the list of active media sources. This method could cause an
-     * immediate {@link PlaybackModel.PlaybackObserver#onSourceChanged()} event if a media source
+     * immediate {@link Observer#onActiveSourceChanged()} event if a media source
      * was already connected.
      */
     private void stop() {
diff --git a/car-media-common/src/com/android/car/media/common/CustomPlaybackAction.java b/car-media-common/src/com/android/car/media/common/CustomPlaybackAction.java
index 2e05d80..5391322 100644
--- a/car-media-common/src/com/android/car/media/common/CustomPlaybackAction.java
+++ b/car-media-common/src/com/android/car/media/common/CustomPlaybackAction.java
@@ -21,12 +21,13 @@
 import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 
+
 /**
  * Abstract representation of a custom playback action. A custom playback action represents a
  * visual element that can be used to trigger playback actions not included in the standard
  * {@link PlaybackControls} class.
  * Custom actions for the current media source are exposed through
- * {@link PlaybackModel#getCustomActions()}
+ * {@link com.android.car.media.common.playback.PlaybackViewModel.PlaybackInfo#getCustomActions()}
  */
 public class CustomPlaybackAction {
     /** Icon to display for this custom action */
diff --git a/car-media-common/src/com/android/car/media/common/MediaItemMetadata.java b/car-media-common/src/com/android/car/media/common/MediaItemMetadata.java
index 639f6bf..71dd7a2 100644
--- a/car-media-common/src/com/android/car/media/common/MediaItemMetadata.java
+++ b/car-media-common/src/com/android/car/media/common/MediaItemMetadata.java
@@ -124,8 +124,6 @@
     }
 
     /**
-     * An id that can be used on {@link PlaybackModel#onSkipToQueueItem(long)}
-     *
      * @return the id of this item in the session queue, or NULL if this is not a session queue
      * item.
      */
@@ -336,4 +334,4 @@
                 + (mMediaDescription != null ? mMediaDescription.getIconUri() : "-")
                 + "]";
     }
-}
\ No newline at end of file
+}
diff --git a/car-media-common/src/com/android/car/media/common/MediaSource.java b/car-media-common/src/com/android/car/media/common/MediaSource.java
index 32e5582..a0e977b 100644
--- a/car-media-common/src/com/android/car/media/common/MediaSource.java
+++ b/car-media-common/src/com/android/car/media/common/MediaSource.java
@@ -25,8 +25,6 @@
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.content.pm.ServiceInfo;
-import android.content.res.Resources;
-import android.content.res.TypedArray;
 import android.graphics.Bitmap;
 import android.graphics.Canvas;
 import android.graphics.Paint;
@@ -43,8 +41,6 @@
 import android.service.media.MediaBrowserService;
 import android.util.Log;
 
-import androidx.annotation.ColorInt;
-
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -65,11 +61,6 @@
 public class MediaSource {
     private static final String TAG = "MediaSource";
 
-    /** Third-party defined application theme to use **/
-    private static final String THEME_META_DATA_NAME =
-            "com.google.android.gms.car.application.theme";
-    /** Mark used to indicate that we couldn't find a color and the default one should be used */
-    private static final int DEFAULT_COLOR = 0;
     /** Number of times we will retry obtaining the list of children of a certain node */
     private static final int CHILDREN_SUBSCRIPTION_RETRIES = 3;
     /** Time between retries while trying to obtain the list of children of a certain node */
@@ -85,9 +76,6 @@
     private List<Observer> mObservers = new ArrayList<>();
     private CharSequence mName;
     private String mRootNode;
-    private @ColorInt int mPrimaryColor;
-    private @ColorInt int mAccentColor;
-    private @ColorInt int mPrimaryColorDark;
 
     /**
      * Custom media sources which should not be templatized.
@@ -443,7 +431,6 @@
 
     private void extractComponentInfo(@NonNull String packageName,
             @Nullable String browseServiceClassName) {
-        TypedArray ta = null;
         try {
             ApplicationInfo applicationInfo =
                     mContext.getPackageManager().getApplicationInfo(packageName,
@@ -462,61 +449,11 @@
             } else {
                 mName = null;
             }
-
-            // Get the proper theme, check theme for service, then application.
-            Context packageContext = mContext.createPackageContext(packageName, 0);
-            int appTheme = applicationInfo.metaData != null
-                    ? applicationInfo.metaData.getInt(THEME_META_DATA_NAME)
-                    : 0;
-            appTheme = appTheme == 0
-                    ? applicationInfo.theme
-                    : appTheme;
-            packageContext.setTheme(appTheme);
-            Resources.Theme theme = packageContext.getTheme();
-            ta = theme.obtainStyledAttributes(new int[] {
-                    android.R.attr.colorPrimary,
-                    android.R.attr.colorAccent,
-                    android.R.attr.colorPrimaryDark
-            });
-            mPrimaryColor = ta.getColor(0, DEFAULT_COLOR);
-            mAccentColor = ta.getColor(1, DEFAULT_COLOR);
-            mPrimaryColorDark = ta.getColor(2, DEFAULT_COLOR);
         } catch (PackageManager.NameNotFoundException e) {
             Log.w(TAG, "Unable to update media client package attributes.", e);
-            mPrimaryColor = DEFAULT_COLOR;
-            mAccentColor = DEFAULT_COLOR;
-            mPrimaryColorDark = DEFAULT_COLOR;
-        } finally {
-            if (ta != null) {
-                ta.recycle();
-            }
         }
     }
 
-    /**
-     * @return media source primary color, or the given default color if the source metadata
-     * is not available.
-     */
-    public @ColorInt int getPrimaryColor(@ColorInt int defaultColor) {
-        return mPrimaryColor != DEFAULT_COLOR ? mPrimaryColor : defaultColor;
-    }
-
-    /**
-     * @return media source accent color, or the given default color if the source metadata
-     * is not available.
-     */
-    public @ColorInt int getAccentColor(@ColorInt int defaultColor) {
-        return mAccentColor != DEFAULT_COLOR ? mAccentColor : defaultColor;
-    }
-
-    /**
-     * @return media source primary dark color, or the given default color if the source metadata
-     * is not available.
-     */
-    public @ColorInt int getPrimaryColorDark(@ColorInt int defaultColor) {
-        return mPrimaryColorDark != DEFAULT_COLOR ? mPrimaryColorDark : defaultColor;
-    }
-
     private void notify(Consumer<Observer> notification) {
         mHandler.post(() -> {
             List<Observer> observers = new ArrayList<>(mObservers);
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackControls.java b/car-media-common/src/com/android/car/media/common/PlaybackControls.java
index 11fa15d..156119a 100644
--- a/car-media-common/src/com/android/car/media/common/PlaybackControls.java
+++ b/car-media-common/src/com/android/car/media/common/PlaybackControls.java
@@ -2,15 +2,20 @@
 
 import android.view.ViewGroup;
 
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LifecycleOwner;
+
+import com.android.car.media.common.playback.PlaybackViewModel;
+
 /**
- * Custom view that can be used to display playback controls. It accepts a {@link PlaybackModel}
+ * Custom view that can be used to display playback controls. It accepts a {@link PlaybackViewModel}
  * as its data source, automatically reacting to changes in playback state.
  */
 public interface PlaybackControls {
     /**
-     * Sets the {@link PlaybackModel} to use as the view model for this view.
+     * Sets the {@link PlaybackViewModel} to use as the view model for this view.
      */
-    void setModel(PlaybackModel model);
+    void setModel(@NonNull PlaybackViewModel model, @NonNull LifecycleOwner lifecycleOwner);
 
     /**
      * Collapses the playback controls if they were expanded.
@@ -45,4 +50,4 @@
          */
         void onToggleQueue();
     }
-}
\ No newline at end of file
+}
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackControlsActionBar.java b/car-media-common/src/com/android/car/media/common/PlaybackControlsActionBar.java
index 0a7de10..2b93ed8 100644
--- a/car-media-common/src/com/android/car/media/common/PlaybackControlsActionBar.java
+++ b/car-media-common/src/com/android/car/media/common/PlaybackControlsActionBar.java
@@ -16,6 +16,9 @@
 
 package com.android.car.media.common;
 
+import static com.android.car.arch.common.LiveDataFunctions.pair;
+import static com.android.car.arch.common.LiveDataFunctions.split;
+
 import android.content.Context;
 import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
@@ -27,8 +30,14 @@
 import android.widget.ImageButton;
 import android.widget.ProgressBar;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.car.widget.ActionBar;
 import androidx.cardview.widget.CardView;
+import androidx.lifecycle.LifecycleOwner;
+
+import com.android.car.media.common.playback.PlaybackViewModel;
+import com.android.car.media.common.source.MediaSourceColors;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -41,34 +50,16 @@
     private static final String TAG = "PlaybackView";
 
     private PlayPauseStopImageView mPlayPauseStopImageView;
-    private View mPlayPauseStopImageContainer;
     private ProgressBar mSpinner;
     private Context mContext;
     private ImageButton mSkipPrevButton;
     private ImageButton mSkipNextButton;
     private ImageButton mTrackListButton;
-    private Listener mListener;
-    private PlaybackModel mModel;
-    private PlaybackModel.PlaybackObserver mObserver = new PlaybackModel.PlaybackObserver() {
-        @Override
-        public void onPlaybackStateChanged() {
-            updateState();
-            updateCustomActions();
-        }
-
-        @Override
-        public void onSourceChanged() {
-            updateState();
-            updateCustomActions();
-            updateAccentColor();
-        }
-
-        @Override
-        public void onMetadataChanged() {
-            updateCustomActions();  // rating might have changed
-        }
-    };
     private ColorStateList mIconsColor;
+    private Listener mListener;
+
+    private PlaybackViewModel mModel;
+    private PlaybackViewModel.PlaybackController mController;
 
     /** Creates a {@link PlaybackControlsActionBar} view */
     public PlaybackControlsActionBar(Context context) {
@@ -92,52 +83,43 @@
         init(context);
     }
 
-    @Override
-    public void setModel(PlaybackModel model) {
-        if (mModel != null) {
-            mModel.unregisterObserver(mObserver);
-        }
-        mModel = model;
-        if (mModel != null) {
-            mModel.registerObserver(mObserver);
-        }
-        updateAccentColor();
-    }
-
     private void init(Context context) {
         mContext = context;
 
         CardView actionBarWrapper = findViewById(androidx.car.R.id.action_bar_wrapper);
         actionBarWrapper.setCardBackgroundColor(context.getColor(androidx.car.R.color.car_card));
 
-        mPlayPauseStopImageContainer = inflate(context, R.layout.car_play_pause_stop_button_layout,
+        View mPlayPauseStopImageContainer = inflate(context,
+                R.layout.car_play_pause_stop_button_layout,
                 null);
         mPlayPauseStopImageContainer.setOnClickListener(this::onPlayPauseStopClicked);
         mPlayPauseStopImageView = mPlayPauseStopImageContainer.findViewById(R.id.play_pause_stop);
+        mPlayPauseStopImageView.setVisibility(View.INVISIBLE);
         mSpinner = mPlayPauseStopImageContainer.findViewById(R.id.spinner);
+        mSpinner.setVisibility(View.INVISIBLE);
         mPlayPauseStopImageView.setAction(PlayPauseStopImageView.ACTION_DISABLED);
         mPlayPauseStopImageView.setOnClickListener(this::onPlayPauseStopClicked);
 
         mIconsColor = context.getResources().getColorStateList(R.color.playback_control_color,
                 null);
 
-        mSkipPrevButton = createIconButton(mContext, mIconsColor,
+        mSkipPrevButton = createIconButton(mContext,
                 context.getDrawable(R.drawable.ic_skip_previous));
         mSkipPrevButton.setVisibility(INVISIBLE);
         mSkipPrevButton.setOnClickListener(v -> {
-            if (mModel != null) {
-                mModel.onSkipPreviews();
+            if (mController != null) {
+                mController.skipToPrevious();
             }
         });
-        mSkipNextButton = createIconButton(mContext, mIconsColor,
+        mSkipNextButton = createIconButton(mContext,
                 context.getDrawable(R.drawable.ic_skip_next));
         mSkipNextButton.setVisibility(INVISIBLE);
         mSkipNextButton.setOnClickListener(v -> {
-            if (mModel != null) {
-                mModel.onSkipNext();
+            if (mController != null) {
+                mController.skipToNext();
             }
         });
-        mTrackListButton = createIconButton(mContext, mIconsColor,
+        mTrackListButton = createIconButton(mContext,
                 context.getDrawable(R.drawable.ic_tracklist));
         mTrackListButton.setOnClickListener(v -> {
             if (mListener != null) {
@@ -145,7 +127,7 @@
             }
         });
 
-        ImageButton overflowButton = createIconButton(context, mIconsColor,
+        ImageButton overflowButton = createIconButton(context,
                 context.getDrawable(androidx.car.R.drawable.ic_overflow));
 
         setView(mPlayPauseStopImageContainer, ActionBar.SLOT_MAIN);
@@ -154,98 +136,97 @@
         setExpandCollapseView(overflowButton);
     }
 
-    private ImageButton createIconButton(Context context, ColorStateList csl, Drawable icon) {
+    private ImageButton createIconButton(Context context, Drawable icon) {
         ImageButton button = new ImageButton(context, null, 0, R.style.PlaybackControl);
-        button.setImageTintList(csl);
+        button.setImageTintList(mIconsColor);
         button.setImageTintMode(PorterDuff.Mode.SRC_ATOP);
         button.setImageDrawable(icon);
         return button;
     }
 
-    private void updateState() {
+    @Override
+    public void setModel(@NonNull PlaybackViewModel model, @NonNull LifecycleOwner owner) {
         if (mModel != null) {
-            mPlayPauseStopImageView.setVisibility(View.VISIBLE);
-            mPlayPauseStopImageView.setAction(convertMainAction(mModel.getMainAction()));
-        } else {
-            mPlayPauseStopImageView.setVisibility(View.INVISIBLE);
+            Log.w(TAG, "PlaybackViewModel set more than once. Ignoring subsequent call.");
         }
-        mSpinner.setVisibility(mModel != null && mModel.isLoading()
-                ? View.VISIBLE : View.INVISIBLE);
-        mSkipPrevButton.setVisibility(mModel != null && mModel.isSkipPreviewsEnabled()
-                ? View.VISIBLE : View.INVISIBLE);
-        mSkipNextButton.setVisibility(mModel != null && mModel.isSkipNextEnabled()
-                ? View.VISIBLE : View.INVISIBLE);
+        mModel = model;
+        PlaybackViewModel.PlaybackInfo playbackInfo = model.getPlaybackInfo();
+
+        model.getPlaybackController().observe(owner, controller -> mController = controller);
+        mPlayPauseStopImageView.setVisibility(View.VISIBLE);
+        playbackInfo.getMainAction().observe(owner,
+                action -> mPlayPauseStopImageView.setAction(convertMainAction(action)));
+        playbackInfo.isLoading().observe(owner,
+                isLoading -> mSpinner.setVisibility(isLoading ? View.VISIBLE : View.INVISIBLE));
+        playbackInfo.isSkipPreviousEnabled().observe(owner,
+                enabled -> mSkipPrevButton.setVisibility(enabled ? VISIBLE : INVISIBLE));
+        playbackInfo.isSkipNextEnabled().observe(owner,
+                enabled -> mSkipNextButton.setVisibility(enabled ? VISIBLE : INVISIBLE));
+        model.getMediaSourceColors().observe(owner, this::applyColors);
+        pair(model.hasQueue(), playbackInfo.getCustomActions()).observe(owner,
+                split(this::updateCustomActions));
     }
 
     @PlayPauseStopImageView.Action
-    private int convertMainAction(@PlaybackModel.Action int action) {
+    private int convertMainAction(@PlaybackViewModel.Action int action) {
         switch (action) {
-            case PlaybackModel.ACTION_DISABLED:
+            case PlaybackViewModel.ACTION_DISABLED:
                 return PlayPauseStopImageView.ACTION_DISABLED;
-            case PlaybackModel.ACTION_PLAY:
+            case PlaybackViewModel.ACTION_PLAY:
                 return PlayPauseStopImageView.ACTION_PLAY;
-            case PlaybackModel.ACTION_PAUSE:
+            case PlaybackViewModel.ACTION_PAUSE:
                 return PlayPauseStopImageView.ACTION_PAUSE;
-            case PlaybackModel.ACTION_STOP:
+            case PlaybackViewModel.ACTION_STOP:
                 return PlayPauseStopImageView.ACTION_STOP;
         }
         Log.w(TAG, "Unknown action: " + action);
         return PlayPauseStopImageView.ACTION_DISABLED;
     }
 
-    private void updateAccentColor() {
-        int color = getMediaSourceColor();
+    private void applyColors(MediaSourceColors colors) {
+        int color = getMediaSourceColor(colors);
         int tintColor = ColorChecker.getTintColor(mContext, color);
         mPlayPauseStopImageView.setPrimaryActionColor(color, tintColor);
         mSpinner.setIndeterminateTintList(ColorStateList.valueOf(color));
     }
 
-    private int getMediaSourceColor() {
+    private int getMediaSourceColor(@Nullable MediaSourceColors colors) {
         int defaultColor = mContext.getResources().getColor(android.R.color.background_dark, null);
-        MediaSource mediaSource = mModel != null ? mModel.getMediaSource() : null;
-        return mediaSource != null ? mediaSource.getAccentColor(defaultColor) : defaultColor;
+        return colors != null ? colors.getAccentColor(defaultColor) : defaultColor;
     }
 
-    private List<ImageButton> getExtraActions() {
-        List<ImageButton> extraActions = new ArrayList<>();
-        if (mModel != null && mModel.hasQueue()) {
-            extraActions.add(mTrackListButton);
-        }
-        return extraActions;
-    }
-
-    private void updateCustomActions() {
-        if (mModel == null) {
-            setViews(new ImageButton[0]);
-            return;
-        }
+    private void updateCustomActions(boolean hasQueue,
+            List<PlaybackViewModel.RawCustomPlaybackAction> customActions) {
         List<ImageButton> combinedActions = new ArrayList<>();
-        combinedActions.addAll(getExtraActions());
-        combinedActions.addAll(mModel.getCustomActions()
+        if (hasQueue) {
+            combinedActions.add(mTrackListButton);
+        }
+        combinedActions.addAll(customActions
                 .stream()
+                .map(rawAction -> rawAction.fetchDrawable(getContext()))
                 .map(action -> {
-                    ImageButton button = createIconButton(getContext(), mIconsColor, action.mIcon);
+                    ImageButton button = createIconButton(getContext(), action.mIcon);
                     button.setOnClickListener(view ->
-                            mModel.onCustomAction(action.mAction, action.mExtras));
+                            mController.doCustomAction(action.mAction, action.mExtras));
                     return button;
                 })
                 .collect(Collectors.toList()));
-        setViews(combinedActions.toArray(new ImageButton[combinedActions.size()]));
+        setViews(combinedActions.toArray(new ImageButton[0]));
     }
 
     private void onPlayPauseStopClicked(View view) {
-        if (mModel == null) {
+        if (mController == null) {
             return;
         }
         switch (mPlayPauseStopImageView.getAction()) {
             case PlayPauseStopImageView.ACTION_PLAY:
-                mModel.onPlay();
+                mController.play();
                 break;
             case PlayPauseStopImageView.ACTION_PAUSE:
-                mModel.onPause();
+                mController.pause();
                 break;
             case PlayPauseStopImageView.ACTION_STOP:
-                mModel.onStop();
+                mController.stop();
                 break;
             default:
                 Log.i(TAG, "Play/Pause/Stop clicked on invalid state");
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 dd8f4f1..f70e9ff 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
@@ -16,8 +16,13 @@
 
 package com.android.car.media.common;
 
+import static com.android.car.arch.common.LiveDataFunctions.mapNonNull;
+
+import android.app.Application;
 import android.car.Car;
 import android.content.Intent;
+import android.graphics.Bitmap;
+import android.media.session.MediaController;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -28,80 +33,72 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModelProviders;
+
+import com.android.car.media.common.playback.AlbumArtLiveData;
+import com.android.car.media.common.playback.PlaybackViewModel;
 
 import com.bumptech.glide.request.target.Target;
 
-import java.util.Objects;
-
 /**
- * {@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.
+ * {@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 {
+    // TODO(keyboardr): replace with MediaSourceViewModel when available
     private ActiveMediaSourceManager mActiveMediaSourceManager;
-    private PlaybackModel mModel;
-    private CrossfadeImageView mAlbumBackground;
-    private PlaybackControls mPlaybackControls;
-    private ImageView mAppIcon;
-    private TextView mAppName;
-    private TextView mTitle;
-    private TextView mSubtitle;
-    private MediaItemMetadata mCurrentMetadata;
 
-    private PlaybackModel.PlaybackObserver mPlaybackObserver =
-            new PlaybackModel.PlaybackObserver() {
-        @Override
-        public void onSourceChanged() {
-            updateMetadata();
-        }
+    private MutableLiveData<MediaController> mMediaController = new MutableLiveData<>();
 
-        @Override
-        public void onMetadataChanged() {
-            updateMetadata();
-        }
-    };
+
     private ActiveMediaSourceManager.Observer mActiveSourceObserver =
             new ActiveMediaSourceManager.Observer() {
-        @Override
-        public void onActiveSourceChanged() {
-            mModel.setMediaController(mActiveMediaSourceManager.getMediaController());
-        }
-    };
+                @Override
+                public void onActiveSourceChanged() {
+                    mMediaController.setValue(mActiveMediaSourceManager.getMediaController());
+                }
+            };
 
     @Nullable
     @Override
     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
             Bundle savedInstanceState) {
+        PlaybackViewModel playbackViewModel = ViewModelProviders.of(getActivity())
+                .get(PlaybackViewModel.class);
+        playbackViewModel.setMediaController(mMediaController);
+        ViewModel innerViewModel = ViewModelProviders.of(getActivity()).get(ViewModel.class);
+        innerViewModel.init(playbackViewModel);
+
         View view = inflater.inflate(R.layout.car_playback_fragment, container, false);
         mActiveMediaSourceManager = new ActiveMediaSourceManager(getContext());
-        mModel = new PlaybackModel(getContext());
-        mAlbumBackground = view.findViewById(R.id.album_background);
-        mPlaybackControls = view.findViewById(R.id.playback_controls);
-        mPlaybackControls.setModel(mModel);
-        mAppIcon = view.findViewById(R.id.app_icon);
-        mAppName = view.findViewById(R.id.app_name);
-        mTitle = view.findViewById(R.id.title);
-        mSubtitle = view.findViewById(R.id.subtitle);
 
-        mAlbumBackground.setOnClickListener(v -> {
-            MediaSource mediaSource = mModel.getMediaSource();
-            Intent intent;
-            if (mediaSource != null && mediaSource.isCustom()) {
-                // We are playing a custom app. Jump to it, not to the template
-                intent = getContext().getPackageManager()
-                        .getLaunchIntentForPackage(mediaSource.getPackageName());
-            } else if (mediaSource != null) {
-                // We are playing a standard app. Open the template to browse it.
-                intent = new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
-                intent.putExtra(Car.CAR_EXTRA_MEDIA_PACKAGE, mediaSource.getPackageName());
-            } else {
-                // We are not playing. Open the template to start playing something.
-                intent = new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
-            }
-            startActivity(intent);
+        PlaybackControls playbackControls = view.findViewById(R.id.playback_controls);
+        playbackControls.setModel(playbackViewModel, getViewLifecycleOwner());
+
+        ImageView appIcon = view.findViewById(R.id.app_icon);
+        innerViewModel.getAppIcon().observe(getViewLifecycleOwner(), appIcon::setImageBitmap);
+
+        TextView appName = view.findViewById(R.id.app_name);
+        innerViewModel.getAppName().observe(getViewLifecycleOwner(), appName::setText);
+
+        TextView title = view.findViewById(R.id.title);
+        innerViewModel.getTitle().observe(getViewLifecycleOwner(), title::setText);
+
+        TextView subtitle = view.findViewById(R.id.subtitle);
+        innerViewModel.getSubtitle().observe(getViewLifecycleOwner(), subtitle::setText);
+
+        CrossfadeImageView albumBackground = view.findViewById(R.id.album_background);
+        innerViewModel.getAlbumArt().observe(getViewLifecycleOwner(),
+                albumArt -> albumBackground.setImageBitmap(albumArt, true));
+        LiveData<Intent> openIntent = innerViewModel.getOpenIntent();
+        openIntent.observe(getViewLifecycleOwner(), intent -> {
+            // Ensure open intent data stays fresh while view is clickable.
         });
-
+        albumBackground.setOnClickListener(v -> startActivity(openIntent.getValue()));
         return view;
     }
 
@@ -109,47 +106,86 @@
     public void onStart() {
         super.onStart();
         mActiveMediaSourceManager.registerObserver(mActiveSourceObserver);
-        mModel.registerObserver(mPlaybackObserver);
     }
 
     @Override
     public void onStop() {
         super.onStop();
         mActiveMediaSourceManager.unregisterObserver(mActiveSourceObserver);
-        mModel.unregisterObserver(mPlaybackObserver);
     }
 
-    private void updateMetadata() {
-        MediaSource mediaSource = mModel.getMediaSource();
+    /**
+     * ViewModel for the PlaybackFragment
+     */
+    public static class ViewModel extends AndroidViewModel {
 
-        if (mediaSource == null) {
-            mTitle.setText(null);
-            mSubtitle.setText(null);
-            mAppName.setText(null);
-            mAlbumBackground.setImageBitmap(null, true);
-            return;
+        private static final Intent MEDIA_TEMPLATE_INTENT =
+                new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
+
+        private LiveData<MediaSource> mMediaSource;
+        private LiveData<CharSequence> mAppName;
+        private LiveData<Bitmap> mAppIcon;
+        private LiveData<Intent> mOpenIntent;
+        private LiveData<CharSequence> mTitle;
+        private LiveData<CharSequence> mSubtitle;
+        private LiveData<Bitmap> mAlbumArt;
+
+        private PlaybackViewModel mPlaybackViewModel;
+
+        public ViewModel(Application application) {
+            super(application);
         }
 
-        MediaItemMetadata metadata = mModel.getMetadata();
-        if (Objects.equals(mCurrentMetadata, metadata)) {
-            return;
+        void init(PlaybackViewModel playbackViewModel) {
+            if (mPlaybackViewModel == playbackViewModel) {
+                return;
+            }
+            mPlaybackViewModel = playbackViewModel;
+            mMediaSource = mapNonNull(playbackViewModel.getMediaController(),
+                    controller -> new MediaSource(getApplication(), controller.getPackageName()));
+            mAppName = mapNonNull(mMediaSource, MediaSource::getName);
+            mAppIcon = mapNonNull(mMediaSource, MediaSource::getRoundPackageIcon);
+            mOpenIntent = mapNonNull(mMediaSource, MEDIA_TEMPLATE_INTENT, source -> {
+                if (source.isCustom()) {
+                    // We are playing a custom app. Jump to it, not to the template
+                    return getApplication().getPackageManager()
+                            .getLaunchIntentForPackage(source.getPackageName());
+                } else {
+                    // We are playing a standard app. Open the template to browse it.
+                    Intent intent = new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
+                    intent.putExtra(Car.CAR_EXTRA_MEDIA_PACKAGE, source.getPackageName());
+                    return intent;
+                }
+            });
+            mTitle = mapNonNull(playbackViewModel.getMetadata(), MediaItemMetadata::getTitle);
+            mSubtitle = mapNonNull(playbackViewModel.getMetadata(), MediaItemMetadata::getSubtitle);
+            mAlbumArt = AlbumArtLiveData.getAlbumArt(getApplication(),
+                    Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL, false,
+                    playbackViewModel.getMetadata());
         }
-        mCurrentMetadata = metadata;
-        mTitle.setText(metadata != null ? metadata.getTitle() : null);
-        mSubtitle.setText(metadata != null ? metadata.getSubtitle() : null);
-        if (metadata != null) {
-            metadata.getAlbumArt(getContext(),
-                    Target.SIZE_ORIGINAL,
-                    Target.SIZE_ORIGINAL,
-                    false)
-                    .thenAccept(bitmap -> {
-                        //bitmap = ImageUtils.blur(getContext(), bitmap, 1f, 10f);
-                        mAlbumBackground.setImageBitmap(bitmap, true);
-                    });
-        } else {
-            mAlbumBackground.setImageBitmap(null, true);
+
+        LiveData<CharSequence> getAppName() {
+            return mAppName;
         }
-        mAppName.setText(mediaSource.getName());
-        mAppIcon.setImageBitmap(mediaSource.getRoundPackageIcon());
+
+        LiveData<Bitmap> getAppIcon() {
+            return mAppIcon;
+        }
+
+        LiveData<Intent> getOpenIntent() {
+            return mOpenIntent;
+        }
+
+        LiveData<CharSequence> getTitle() {
+            return mTitle;
+        }
+
+        LiveData<CharSequence> getSubtitle() {
+            return mSubtitle;
+        }
+
+        LiveData<Bitmap> getAlbumArt() {
+            return mAlbumArt;
+        }
     }
 }
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackModel.java b/car-media-common/src/com/android/car/media/common/PlaybackModel.java
deleted file mode 100644
index e7942fb..0000000
--- a/car-media-common/src/com/android/car/media/common/PlaybackModel.java
+++ /dev/null
@@ -1,623 +0,0 @@
-/*
- * Copyright 2018 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.annotation.IntDef;
-import android.annotation.NonNull;
-import android.annotation.Nullable;
-import android.content.Context;
-import android.content.pm.PackageManager;
-import android.content.res.Resources;
-import android.graphics.drawable.Drawable;
-import android.media.MediaMetadata;
-import android.media.Rating;
-import android.media.session.MediaController;
-import android.media.session.MediaController.TransportControls;
-import android.media.session.MediaSession;
-import android.media.session.PlaybackState;
-import android.media.session.PlaybackState.Actions;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.SystemClock;
-import android.util.Log;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Consumer;
-import java.util.stream.Collectors;
-
-/**
- * Wrapper of {@link MediaSession}. It provides access to media session events and extended
- * information on the currently playing item metadata.
- */
-public class PlaybackModel {
-    private static final String TAG = "PlaybackModel";
-
-    private static final String ACTION_SET_RATING =
-            "com.android.car.media.common.ACTION_SET_RATING";
-    private static final String EXTRA_SET_HEART = "com.android.car.media.common.EXTRA_SET_HEART";
-
-    private final Handler mHandler = new Handler();
-    @Nullable
-    private final Context mContext;
-    private final List<PlaybackObserver> mObservers = new ArrayList<>();
-    private MediaController mMediaController;
-    private MediaSource mMediaSource;
-    private boolean mIsStarted;
-
-    /**
-     * An observer of this model
-     */
-    public abstract static class PlaybackObserver {
-        /**
-         * Called whenever the playback state of the current media item changes.
-         */
-        protected void onPlaybackStateChanged() {};
-
-        /**
-         * Called when the top source media app changes.
-         */
-        protected void onSourceChanged() {};
-
-        /**
-         * Called when the media item being played changes.
-         */
-        protected void onMetadataChanged() {};
-    }
-
-    private MediaController.Callback mCallback = new MediaController.Callback() {
-        @Override
-        public void onPlaybackStateChanged(PlaybackState state) {
-            if (Log.isLoggable(TAG, Log.DEBUG)) {
-                Log.d(TAG, "onPlaybackStateChanged: " + state);
-            }
-            PlaybackModel.this.notify(PlaybackObserver::onPlaybackStateChanged);
-        }
-
-        @Override
-        public void onMetadataChanged(MediaMetadata metadata) {
-            if (Log.isLoggable(TAG, Log.DEBUG)) {
-                Log.d(TAG, "onMetadataChanged: " + metadata);
-            }
-            PlaybackModel.this.notify(PlaybackObserver::onMetadataChanged);
-        }
-    };
-
-    /**
-     * Creates a {@link PlaybackModel}
-     */
-    public PlaybackModel(@NonNull Context context) {
-       this(context, null);
-    }
-
-    /**
-     * Creates a {@link PlaybackModel} wrapping to the given media controller
-     */
-    public PlaybackModel(@NonNull Context context, @Nullable MediaController controller) {
-        mContext = context;
-        changeMediaController(controller);
-    }
-
-    /**
-     * Sets the {@link MediaController} wrapped by this model.
-     */
-    public void setMediaController(@Nullable MediaController mediaController) {
-        changeMediaController(mediaController);
-    }
-
-    private void changeMediaController(@Nullable MediaController mediaController) {
-        if (Log.isLoggable(TAG, Log.DEBUG)) {
-            Log.d(TAG, "New media controller: " + (mediaController != null
-                    ? mediaController.getPackageName() : null));
-        }
-        if ((mediaController == null && mMediaController == null)
-                || (mediaController != null && mMediaController != null
-                && mediaController.getPackageName().equals(mMediaController.getPackageName()))) {
-            // If no change, do nothing.
-            return;
-        }
-        if (mMediaController != null) {
-            mMediaController.unregisterCallback(mCallback);
-        }
-        mMediaController = mediaController;
-        mMediaSource = mMediaController != null
-            ? new MediaSource(mContext, mMediaController.getPackageName()) : null;
-        if (mMediaController != null && mIsStarted) {
-            mMediaController.registerCallback(mCallback);
-        }
-        if (mIsStarted) {
-            notify(PlaybackObserver::onSourceChanged);
-        }
-    }
-
-    /**
-     * Starts following changes on the playback state of the given source. If any changes happen,
-     * all observers registered through {@link #registerObserver(PlaybackObserver)} will be
-     * notified.
-     */
-    private void start() {
-        if (mMediaController != null) {
-            mMediaController.registerCallback(mCallback);
-        }
-        mIsStarted = true;
-    }
-
-    /**
-     * Stops following changes on the list of active media sources.
-     */
-    private void stop() {
-        if (mMediaController != null) {
-            mMediaController.unregisterCallback(mCallback);
-        }
-        mIsStarted = false;
-    }
-
-    private void notify(Consumer<PlaybackObserver> notification) {
-        mHandler.post(() -> {
-            List<PlaybackObserver> observers = new ArrayList<>(mObservers);
-            for (PlaybackObserver observer : observers) {
-                notification.accept(observer);
-            }
-        });
-    }
-
-    /**
-     * @return a {@link MediaSource} providing access to metadata of the currently playing media
-     * source, or NULL if the media source has no active session.
-     */
-    @Nullable
-    public MediaSource getMediaSource() {
-        return mMediaSource;
-    }
-
-    /**
-     * @return a {@link MediaController} that can be used to control this media source, or NULL
-     * if the media source has no active session.
-     */
-    @Nullable
-    public MediaController getMediaController() {
-        return mMediaController;
-    }
-
-    /**
-     * @return {@link Action} selected as the main action for the current media item, based on the
-     * current playback state and the available actions reported by the media source.
-     * Changes on this value will be notified through
-     * {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    @Action
-    public int getMainAction() {
-        return getMainAction(mMediaController != null ? mMediaController.getPlaybackState() : null);
-    }
-
-    /**
-     * @return {@link MediaItemMetadata} of the currently selected media item in the media source.
-     * Changes on this value will be notified through {@link PlaybackObserver#onMetadataChanged()}
-     */
-    @Nullable
-    public MediaItemMetadata getMetadata() {
-        if (mMediaController == null) {
-            return null;
-        }
-        MediaMetadata metadata = mMediaController.getMetadata();
-        if (metadata == null) {
-            return null;
-        }
-        return new MediaItemMetadata(metadata);
-    }
-
-    /**
-     * @return duration of the media item, in milliseconds. The current position in this duration
-     * can be obtained by calling {@link #getProgress()}.
-     * Changes on this value will be notified through {@link PlaybackObserver#onMetadataChanged()}
-     */
-    public long getMaxProgress() {
-        if (mMediaController == null || mMediaController.getMetadata() == null) {
-            return 0;
-        } else {
-            return mMediaController.getMetadata()
-                    .getLong(MediaMetadata.METADATA_KEY_DURATION);
-        }
-    }
-
-    /**
-     * Sends a 'play' command to the media source
-     */
-    public void onPlay() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().play();
-        }
-    }
-
-    /**
-     * Sends a 'skip previews' command to the media source
-     */
-    public void onSkipPreviews() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().skipToPrevious();
-        }
-    }
-
-    /**
-     * Sends a 'skip next' command to the media source
-     */
-    public void onSkipNext() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().skipToNext();
-        }
-    }
-
-    /**
-     * Sends a 'pause' command to the media source
-     */
-    public void onPause() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().pause();
-        }
-    }
-
-    /**
-     * Sends a 'stop' command to the media source
-     */
-    public void onStop() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().stop();
-        }
-    }
-
-    /**
-     * Sends a custom action to the media source
-     * @param action identifier of the custom action
-     * @param extras additional data to send to the media source.
-     */
-    public void onCustomAction(String action, Bundle extras) {
-        if (mMediaController == null) return;
-        TransportControls cntrl = mMediaController.getTransportControls();
-
-        if (ACTION_SET_RATING.equals(action)) {
-            boolean setHeart = extras != null && extras.getBoolean(EXTRA_SET_HEART, false);
-            cntrl.setRating(Rating.newHeartRating(setHeart));
-        } else {
-            cntrl.sendCustomAction(action, extras);
-        }
-
-        mMediaController.getTransportControls().sendCustomAction(action, extras);
-    }
-
-    /**
-     * Starts playing a given media item. This id corresponds to {@link MediaItemMetadata#getId()}.
-     */
-    public void onPlayItem(String mediaItemId) {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().playFromMediaId(mediaItemId, null);
-        }
-    }
-
-    /**
-     * Skips to a particular item in the media queue. This id is {@link MediaItemMetadata#mQueueId}
-     * of the items obtained through {@link #getQueue()}.
-     */
-    public void onSkipToQueueItem(long queueId) {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().skipToQueueItem(queueId);
-        }
-    }
-
-    /**
-     * Prepares the current media source for playback.
-     */
-    public void onPrepare() {
-        if (mMediaController != null) {
-            mMediaController.getTransportControls().prepare();
-        }
-    }
-
-    /**
-     * Possible main actions.
-     */
-    @IntDef({ACTION_PLAY, ACTION_STOP, ACTION_PAUSE, ACTION_DISABLED})
-    @Retention(RetentionPolicy.SOURCE)
-    public @interface Action {}
-
-    /** Main action is disabled. The source can't play media at this time */
-    public static final int ACTION_DISABLED = 0;
-    /** Start playing */
-    public static final int ACTION_PLAY = 1;
-    /** Stop playing */
-    public static final int ACTION_STOP = 2;
-    /** Pause playing */
-    public static final int ACTION_PAUSE = 3;
-
-    @Action
-    private static int getMainAction(PlaybackState state) {
-        if (state == null) {
-            return ACTION_DISABLED;
-        }
-
-        @Actions long actions = state.getActions();
-        int stopAction = ACTION_DISABLED;
-        if ((actions & (PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_PLAY_PAUSE)) != 0) {
-            stopAction = ACTION_PAUSE;
-        } else if ((actions & PlaybackState.ACTION_STOP) != 0) {
-            stopAction = ACTION_STOP;
-        }
-
-        switch (state.getState()) {
-            case PlaybackState.STATE_PLAYING:
-            case PlaybackState.STATE_BUFFERING:
-            case PlaybackState.STATE_CONNECTING:
-            case PlaybackState.STATE_FAST_FORWARDING:
-            case PlaybackState.STATE_REWINDING:
-            case PlaybackState.STATE_SKIPPING_TO_NEXT:
-            case PlaybackState.STATE_SKIPPING_TO_PREVIOUS:
-            case PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM:
-                return stopAction;
-            case PlaybackState.STATE_STOPPED:
-            case PlaybackState.STATE_PAUSED:
-            case PlaybackState.STATE_NONE:
-            case PlaybackState.STATE_ERROR:
-                return (actions & PlaybackState.ACTION_PLAY) != 0 ? ACTION_PLAY : ACTION_DISABLED;
-            default:
-                Log.w(TAG, String.format("Unknown PlaybackState: %d", state.getState()));
-                return ACTION_DISABLED;
-        }
-    }
-
-    /**
-     * @return the current playback progress, in milliseconds. This is a value between 0 and
-     * {@link #getMaxProgress()} or PROGRESS_UNKNOWN of the current position is unknown.
-     */
-    public long getProgress() {
-        if (mMediaController == null) {
-            return 0;
-        }
-        PlaybackState state = mMediaController.getPlaybackState();
-        if (state == null) {
-            return 0;
-        }
-        if (state.getPosition() == PlaybackState.PLAYBACK_POSITION_UNKNOWN) {
-            return PlaybackState.PLAYBACK_POSITION_UNKNOWN;
-        }
-        long timeDiff = SystemClock.elapsedRealtime() - state.getLastPositionUpdateTime();
-        float speed = state.getPlaybackSpeed();
-        if (state.getState() == PlaybackState.STATE_PAUSED
-                || state.getState() == PlaybackState.STATE_STOPPED) {
-            // This guards against apps who don't keep their playbackSpeed to spec (b/62375164)
-            speed = 0f;
-        }
-        long posDiff = (long) (timeDiff * speed);
-        return Math.min(posDiff + state.getPosition(), getMaxProgress());
-    }
-
-    /**
-     * @return true if the current media source is playing a media item. Changes on this value
-     * would be notified through {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    public boolean isPlaying() {
-        return mMediaController != null
-                && mMediaController.getPlaybackState() != null
-                && mMediaController.getPlaybackState().getState() == PlaybackState.STATE_PLAYING;
-    }
-
-    /**
-     * Registers an observer to be notified of media events. If the model is not started yet it
-     * will start right away. If the model was already started, the observer will receive an
-     * immediate {@link PlaybackObserver#onSourceChanged()} event.
-     */
-    public void registerObserver(PlaybackObserver observer) {
-        mObservers.add(observer);
-        if (!mIsStarted) {
-            start();
-        } else {
-            observer.onSourceChanged();
-        }
-    }
-
-    /**
-     * Unregisters an observer previously registered using
-     * {@link #registerObserver(PlaybackObserver)}. There are no other observers the model will
-     * stop tracking changes right away.
-     */
-    public void unregisterObserver(PlaybackObserver observer) {
-        mObservers.remove(observer);
-        if (mObservers.isEmpty() && mIsStarted) {
-            stop();
-        }
-    }
-
-    /**
-     * @return true if the media source supports skipping to next item. Changes on this value
-     * will be notified through {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    public boolean isSkipNextEnabled() {
-        return mMediaController != null
-                && mMediaController.getPlaybackState() != null
-                && (mMediaController.getPlaybackState().getActions()
-                    & PlaybackState.ACTION_SKIP_TO_NEXT) != 0;
-    }
-
-    /**
-     * @return true if the media source supports skipping to previous item. Changes on this value
-     * will be notified through {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    public boolean isSkipPreviewsEnabled() {
-        return mMediaController != null
-                && mMediaController.getPlaybackState() != null
-                && (mMediaController.getPlaybackState().getActions()
-                    & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0;
-    }
-
-    /**
-     * @return true if the media source is loading (e.g.: buffering, connecting, etc.). Changes on
-     * this value would be notified through {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    public boolean isLoading() {
-        if (mMediaController == null) {
-            return false;
-        }
-
-        PlaybackState playbackState = mMediaController.getPlaybackState();
-
-        if (playbackState == null) {
-            return false;
-        }
-
-        int state = playbackState.getState();
-
-        return state == PlaybackState.STATE_BUFFERING
-                || state == PlaybackState.STATE_CONNECTING
-                || state == PlaybackState.STATE_FAST_FORWARDING
-                || state == PlaybackState.STATE_REWINDING
-                || state == PlaybackState.STATE_SKIPPING_TO_NEXT
-                || state == PlaybackState.STATE_SKIPPING_TO_PREVIOUS
-                || state == PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM;
-    }
-
-    /**
-     * @return a human readable description of the error that cause the media source to be in a
-     * non-playable state, or null if there is no error. Changes on this value will be notified
-     * through {@link PlaybackObserver#onPlaybackStateChanged()}
-     */
-    @Nullable
-    public CharSequence getErrorMessage() {
-        return mMediaController != null && mMediaController.getPlaybackState() != null
-                ? mMediaController.getPlaybackState().getErrorMessage()
-                : null;
-    }
-
-    /**
-     * @return a sorted list of {@link MediaItemMetadata} corresponding to the queue of media items
-     * as reported by the media source. Changes on this value will be notified through
-     * {@link PlaybackObserver#onPlaybackStateChanged()}.
-     */
-    @NonNull
-    public List<MediaItemMetadata> getQueue() {
-        if (mMediaController == null) {
-            return new ArrayList<>();
-        }
-        List<MediaSession.QueueItem> items = mMediaController.getQueue();
-        if (items != null) {
-            return items.stream()
-                    .filter(item -> item.getDescription() != null
-                        && item.getDescription().getTitle() != null)
-                    .map(MediaItemMetadata::new)
-                    .collect(Collectors.toList());
-        } else {
-            return new ArrayList<>();
-        }
-    }
-
-    /**
-     * @return the title of the queue or NULL if not available.
-     */
-    @Nullable
-    public CharSequence getQueueTitle() {
-        if (mMediaController == null) {
-            return null;
-        }
-        return mMediaController.getQueueTitle();
-    }
-
-    /**
-     * @return queue id of the currently playing queue item, or
-     * {@link MediaSession.QueueItem#UNKNOWN_ID} if none of the items is currently playing.
-     */
-    public long getActiveQueueItemId() {
-        PlaybackState playbackState = mMediaController.getPlaybackState();
-        if (playbackState == null) return MediaSession.QueueItem.UNKNOWN_ID;
-        return playbackState.getActiveQueueItemId();
-    }
-
-    /**
-     * @return true if the media queue is not empty. Detailed information can be obtained by
-     * calling to {@link #getQueue()}. Changes on this value will be notified through
-     * {@link PlaybackObserver#onPlaybackStateChanged()}.
-     */
-    public boolean hasQueue() {
-        if (mMediaController == null) {
-            return false;
-        }
-        List<MediaSession.QueueItem> items = mMediaController.getQueue();
-        return items != null && !items.isEmpty();
-    }
-
-    private @Nullable CustomPlaybackAction getRatingAction() {
-        PlaybackState playbackState = mMediaController.getPlaybackState();
-        if (playbackState == null) return null;
-
-        long stdActions = playbackState.getActions();
-        if ((stdActions & PlaybackState.ACTION_SET_RATING) == 0) return null;
-
-        int ratingType = mMediaController.getRatingType();
-        if (ratingType != Rating.RATING_HEART) return null;
-
-        MediaMetadata metadata = mMediaController.getMetadata();
-        boolean hasHeart = false;
-        if (metadata != null) {
-            Rating rating = metadata.getRating(MediaMetadata.METADATA_KEY_USER_RATING);
-            hasHeart = rating != null && rating.hasHeart();
-        }
-
-        int iconResource = hasHeart ? R.drawable.ic_star_filled : R.drawable.ic_star_empty;
-        Drawable icon = mContext.getResources().getDrawable(iconResource, null);
-        Bundle extras = new Bundle();
-        extras.putBoolean(EXTRA_SET_HEART, !hasHeart);
-        return new CustomPlaybackAction(icon, ACTION_SET_RATING, extras);
-    }
-
-    /**
-     * @return a sorted list of custom actions, as reported by the media source. Changes on this
-     * value will be notified through
-     * {@link PlaybackObserver#onPlaybackStateChanged()}.
-     */
-    public List<CustomPlaybackAction> getCustomActions() {
-        List<CustomPlaybackAction> actions = new ArrayList<>();
-        if (mMediaController == null) return actions;
-        PlaybackState playbackState = mMediaController.getPlaybackState();
-        if (playbackState == null) return actions;
-
-        CustomPlaybackAction ratingAction = getRatingAction();
-        if (ratingAction != null) actions.add(ratingAction);
-
-        for (PlaybackState.CustomAction action : playbackState.getCustomActions()) {
-            Resources resources = getResourcesForPackage(mMediaController.getPackageName());
-            if (resources == null) {
-                actions.add(null);
-            } else {
-                // the resources may be from another package. we need to update the configuration
-                // using the context from the activity so we get the drawable from the correct DPI
-                // bucket.
-                resources.updateConfiguration(mContext.getResources().getConfiguration(),
-                        mContext.getResources().getDisplayMetrics());
-                Drawable icon = resources.getDrawable(action.getIcon(), null);
-                actions.add(new CustomPlaybackAction(icon, action.getAction(), action.getExtras()));
-            }
-        }
-        return actions;
-    }
-
-    private Resources getResourcesForPackage(String packageName) {
-        try {
-            return mContext.getPackageManager().getResourcesForApplication(packageName);
-        } catch (PackageManager.NameNotFoundException e) {
-            Log.e(TAG, "Unable to get resources for " + packageName);
-            return null;
-        }
-    }
-}
diff --git a/car-media-common/src/com/android/car/media/common/playback/AlbumArtLiveData.java b/car-media-common/src/com/android/car/media/common/playback/AlbumArtLiveData.java
new file mode 100644
index 0000000..9a3589c
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/playback/AlbumArtLiveData.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2018 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.playback;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MediatorLiveData;
+
+import com.android.car.media.common.MediaItemMetadata;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * LiveData class for loading album art from a MediaItemMetadata. This type should not be used
+ * directly; instances should be created via {@link #getAlbumArt(Context, int, int, boolean,
+ * LiveData)}.
+ */
+public class AlbumArtLiveData extends MediatorLiveData<Bitmap> {
+
+    /**
+     * Returns a new LiveData that emits a Bitmap representation of the {@link MediaItemMetadata}'s
+     * album art. While the MediaItemMetadata returns a {@link CompletableFuture}, this LiveData
+     * only updates once the future has completed. If it completes exceptionally or the source emits
+     * {@code null}, the LiveData's value is set to {@code null}.
+     *
+     * @see MediaItemMetadata#getAlbumArt(Context, int, int, boolean)
+     */
+    public static LiveData<Bitmap> getAlbumArt(Context context, int width, int height, boolean fit,
+            LiveData<MediaItemMetadata> source) {
+        return new AlbumArtLiveData(context, width, height, fit, source);
+    }
+
+    private final Context mContext;
+    private final int mWidth;
+    private final int mHeight;
+    private final boolean mFit;
+    private CompletableFuture<Bitmap> mFuture;
+
+    private AlbumArtLiveData(Context context, int width, int height, boolean fit,
+            LiveData<MediaItemMetadata> source) {
+        mContext = context.getApplicationContext();
+        mWidth = width;
+        mHeight = height;
+        mFit = fit;
+        addSource(source, this::update);
+    }
+
+    private void update(MediaItemMetadata metadata) {
+        if (mFuture != null && !mFuture.isDone()) {
+            mFuture.cancel(true);
+        }
+        if (metadata == null) {
+            setValue(null);
+            mFuture = null;
+        } else {
+            mFuture = metadata.getAlbumArt(mContext, mWidth, mHeight, mFit);
+            mFuture.whenComplete((result, throwable) -> {
+                if (throwable != null) {
+                    postValue(null);
+                } else {
+                    postValue(result);
+                }
+            });
+        }
+    }
+}
diff --git a/car-media-common/src/com/android/car/media/common/playback/MediaMetadataLiveData.java b/car-media-common/src/com/android/car/media/common/playback/MediaMetadataLiveData.java
index 4d77e65..98f1f00 100644
--- a/car-media-common/src/com/android/car/media/common/playback/MediaMetadataLiveData.java
+++ b/car-media-common/src/com/android/car/media/common/playback/MediaMetadataLiveData.java
@@ -44,6 +44,7 @@
 
     @Override
     protected void onActive() {
+        setValue(mMediaController.getMetadata());
         mMediaController.registerCallback(mCallback);
     }
 
diff --git a/car-media-common/src/com/android/car/media/common/playback/PlaybackStateLiveData.java b/car-media-common/src/com/android/car/media/common/playback/PlaybackStateLiveData.java
index 3ee0400..41fc685 100644
--- a/car-media-common/src/com/android/car/media/common/playback/PlaybackStateLiveData.java
+++ b/car-media-common/src/com/android/car/media/common/playback/PlaybackStateLiveData.java
@@ -43,6 +43,7 @@
 
     @Override
     protected void onActive() {
+        setValue(mMediaController.getPlaybackState());
         mMediaController.registerCallback(mCallback);
     }
 
diff --git a/car-media-common/src/com/android/car/media/common/playback/PlaybackViewModel.java b/car-media-common/src/com/android/car/media/common/playback/PlaybackViewModel.java
index a49589d..ac67ee1 100644
--- a/car-media-common/src/com/android/car/media/common/playback/PlaybackViewModel.java
+++ b/car-media-common/src/com/android/car/media/common/playback/PlaybackViewModel.java
@@ -20,6 +20,7 @@
 import static androidx.lifecycle.Transformations.switchMap;
 
 import static com.android.car.arch.common.LiveDataFunctions.dataOf;
+import static com.android.car.arch.common.LiveDataFunctions.distinct;
 import static com.android.car.arch.common.LiveDataFunctions.nullLiveData;
 import static com.android.car.arch.common.LiveDataFunctions.pair;
 import static com.android.car.media.common.playback.PlaybackStateAnnotations.Actions;
@@ -55,6 +56,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
@@ -105,8 +107,8 @@
     private final LiveData<MediaMetadata> mMetadata = switchMap(mMediaController,
             mediaController -> mediaController == null ? nullLiveData()
                     : new MediaMetadataLiveData(mediaController));
-    private final LiveData<MediaItemMetadata> mWrappedMetadata = map(mMetadata,
-            MediaItemMetadata::new);
+    private final LiveData<MediaItemMetadata> mWrappedMetadata = distinct(map(mMetadata,
+            metadata -> metadata == null ? null : new MediaItemMetadata(metadata)));
 
     private final LiveData<PlaybackState> mPlaybackState = switchMap(mMediaController,
             mediaController -> mediaController == null ? nullLiveData()
@@ -117,25 +119,24 @@
                     : new QueueLiveData(mediaController));
 
     // Filters out queue items with no description or title and converts them to MediaItemMetadatas
-    private final LiveData<List<MediaItemMetadata>> mSanitizedQueue = map(mQueue,
+    private final LiveData<List<MediaItemMetadata>> mSanitizedQueue = distinct(map(mQueue,
             queue -> queue == null ? Collections.emptyList()
                     : queue.stream()
                             .filter(item -> item.getDescription() != null
                                     && item.getDescription().getTitle() != null)
                             .map(MediaItemMetadata::new)
-                            .collect(Collectors.toList()));
+                            .collect(Collectors.toList())));
 
-    private final LiveData<Boolean> mHasQueue = map(mQueue,
-            queue -> queue != null && !queue.isEmpty());
+    private final LiveData<Boolean> mHasQueue = distinct(map(mQueue,
+            queue -> queue != null && !queue.isEmpty()));
 
     private final LiveData<PlaybackController> mPlaybackControls = map(mMediaController,
             PlaybackController::new);
 
-    private final LiveData<CombinedInfo> mCombinedInfo =
-            map(
-                    pair(mMediaController, pair(mMetadata, mPlaybackState)),
-                    input ->
-                            new CombinedInfo(input.first, input.second.first, input.second.second));
+    private final LiveData<CombinedInfo> mCombinedInfo = map(
+            pair(mMediaController, pair(mMetadata, mPlaybackState)),
+            input -> input.first == null ? null
+                    : new CombinedInfo(input.first, input.second.first, input.second.second));
 
     private final PlaybackInfo mPlaybackInfo = new PlaybackInfo();
 
@@ -217,8 +218,8 @@
     }
 
     /**
-     * Contains LiveDatas related to the current PlaybackState. A single instance of this object
-     * is created for each PlaybackViewModel.
+     * Contains LiveDatas related to the current PlaybackState. A single instance of this object is
+     * created for each PlaybackViewModel.
      */
     public class PlaybackInfo {
         private LiveData<Integer> mMainAction = map(mPlaybackState, state -> {
@@ -247,9 +248,9 @@
                 case PlaybackState.STATE_STOPPED:
                 case PlaybackState.STATE_PAUSED:
                 case PlaybackState.STATE_NONE:
-                    return ACTION_PLAY;
                 case PlaybackState.STATE_ERROR:
-                    return ACTION_DISABLED;
+                    return (actions & PlaybackState.ACTION_PLAY) != 0 ? ACTION_PLAY
+                            : ACTION_DISABLED;
                 default:
                     Log.w(TAG, String.format("Unknown PlaybackState: %d", state.getState()));
                     return ACTION_DISABLED;
@@ -276,8 +277,22 @@
                 state -> state != null
                         && (state.getActions() & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0);
 
-        private final LiveData<Boolean> mIsBuffering = map(mPlaybackState,
-                state -> state != null && state.getState() == PlaybackState.STATE_BUFFERING);
+        // true if the media source is loading (e.g.: buffering, connecting, etc.)
+        private final LiveData<Boolean> mIsLoading = map(mPlaybackState,
+                playbackState -> {
+                    if (playbackState == null) {
+                        return false;
+                    }
+
+                    int state = playbackState.getState();
+                    return state == PlaybackState.STATE_BUFFERING
+                            || state == PlaybackState.STATE_CONNECTING
+                            || state == PlaybackState.STATE_FAST_FORWARDING
+                            || state == PlaybackState.STATE_REWINDING
+                            || state == PlaybackState.STATE_SKIPPING_TO_NEXT
+                            || state == PlaybackState.STATE_SKIPPING_TO_PREVIOUS
+                            || state == PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM;
+                });
 
         private final LiveData<CharSequence> mErrorMessage = map(mPlaybackState,
                 state -> state == null ? null : state.getErrorMessage());
@@ -286,8 +301,9 @@
                 state -> state == null ? MediaSession.QueueItem.UNKNOWN_ID
                         : state.getActiveQueueItemId());
 
-        private final LiveData<List<RawCustomPlaybackAction>> mCustomActions = map(mCombinedInfo,
-                this::getCustomActions);
+        private final LiveData<List<RawCustomPlaybackAction>> mCustomActions = distinct(
+                map(mCombinedInfo,
+                        this::getCustomActions));
 
         private PlaybackInfo() {
         }
@@ -298,45 +314,14 @@
         }
 
         /**
-         * Returns a sorted list of custom actions available. Call {@link #fetchDrawable(Context,
-         * RawCustomPlaybackAction)} to get the appropriate icon Drawable.
+         * Returns a sorted list of custom actions available. Call {@link
+         * RawCustomPlaybackAction#fetchDrawable(Context)} to get the appropriate icon Drawable.
          */
         public LiveData<List<RawCustomPlaybackAction>> getCustomActions() {
             return mCustomActions;
         }
 
         /**
-         * Converts a {@link RawCustomPlaybackAction} into a {@link CustomPlaybackAction} by
-         * fetching the appropriate drawable for the icon.
-         *
-         * @param context Context into which the icon will be drawn
-         * @param action  RawCustomPlaybackAction that contains info to convert
-         * @return the converted CustomPlaybackAction
-         */
-        public CustomPlaybackAction fetchDrawable(@NonNull Context context,
-                @NonNull RawCustomPlaybackAction action) {
-            Drawable icon;
-            if (action.mPackageName == null) {
-                icon = context.getDrawable(action.mIcon);
-            } else {
-                Resources resources = getResourcesForPackage(context, action.mPackageName);
-                if (resources == null) {
-                    icon = null;
-                } else {
-                    // the resources may be from another package. we need to update the
-                    // configuration
-                    // using the context from the activity so we get the drawable from the
-                    // correct DPI
-                    // bucket.
-                    resources.updateConfiguration(context.getResources().getConfiguration(),
-                            context.getResources().getDisplayMetrics());
-                    icon = resources.getDrawable(action.mIcon, null);
-                }
-            }
-            return new CustomPlaybackAction(icon, action.mAction, action.mExtras);
-        }
-
-        /**
          * Returns a LiveData that emits the duration of the media item, in milliseconds. The
          * current position in this duration can be obtained by calling {@link #getProgress()}.
          */
@@ -380,10 +365,11 @@
         }
 
         /**
-         * Returns a LiveData that emits {@code true} iff the media source is buffering
+         * Returns a LiveData that emits {@code true} iff the media source is loading (e.g.:
+         * buffering, connecting, etc.)
          */
-        public LiveData<Boolean> isBuffering() {
-            return mIsBuffering;
+        public LiveData<Boolean> isLoading() {
+            return mIsLoading;
         }
 
         /**
@@ -395,8 +381,8 @@
         }
 
         /**
-         * Returns a LiveData that emits the queue id of the currently playing queue item, or
-         * {@link MediaSession.QueueItem#UNKNOWN_ID} if none of the items is currently playing.
+         * Returns a LiveData that emits the queue id of the currently playing queue item, or {@link
+         * MediaSession.QueueItem#UNKNOWN_ID} if none of the items is currently playing.
          */
         public LiveData<Long> getActiveQueueItemId() {
             return mActiveQueueItemId;
@@ -404,8 +390,9 @@
 
         private List<RawCustomPlaybackAction> getCustomActions(
                 @Nullable CombinedInfo info) {
-            PlaybackState playbackState = info.mPlaybackState;
             List<RawCustomPlaybackAction> actions = new ArrayList<>();
+            if (info == null) return actions;
+            PlaybackState playbackState = info.mPlaybackState;
             if (playbackState == null) return actions;
 
             RawCustomPlaybackAction ratingAction = getRatingAction(info);
@@ -423,6 +410,7 @@
 
         @Nullable
         private RawCustomPlaybackAction getRatingAction(@Nullable CombinedInfo info) {
+            if (info == null) return null;
             PlaybackState playbackState = info.mPlaybackState;
             if (playbackState == null) return null;
 
@@ -445,15 +433,6 @@
             return new RawCustomPlaybackAction(iconResource, null, ACTION_SET_RATING,
                     extras);
         }
-
-        private Resources getResourcesForPackage(Context context, String packageName) {
-            try {
-                return context.getPackageManager().getResourcesForApplication(packageName);
-            } catch (PackageManager.NameNotFoundException e) {
-                Log.e(TAG, "Unable to get resources for " + packageName);
-                return null;
-            }
-        }
     }
 
     /**
@@ -567,9 +546,9 @@
      * {@link PlaybackController} class. Custom actions for the current media source are exposed
      * through {@link PlaybackInfo#getCustomActions()}
      * <p>
-     * Does not contain a {@link Drawable} representation of the icon. Instances of
-     * this object should be converted to a {@link CustomPlaybackAction} via {@link
-     * PlaybackInfo#fetchDrawable(Context, RawCustomPlaybackAction)} for display.
+     * Does not contain a {@link Drawable} representation of the icon. Instances of this object
+     * should be converted to a {@link CustomPlaybackAction} via {@link
+     * RawCustomPlaybackAction#fetchDrawable(Context)} for display.
      */
     public static class RawCustomPlaybackAction {
         // TODO (keyboardr): This class (and associtated translation code) will be merged with
@@ -606,6 +585,64 @@
             mAction = action;
             mExtras = extras;
         }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+
+            RawCustomPlaybackAction that = (RawCustomPlaybackAction) o;
+
+            return mIcon == that.mIcon
+                    && Objects.equals(mPackageName, that.mPackageName)
+                    && Objects.equals(mAction, that.mAction)
+                    && Objects.equals(mExtras, that.mExtras);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mIcon, mPackageName, mAction, mExtras);
+        }
+
+        /**
+         * Converts this {@link RawCustomPlaybackAction} into a {@link CustomPlaybackAction} by
+         * fetching the appropriate drawable for the icon.
+         *
+         * @param context Context into which the icon will be drawn
+         * @return the converted CustomPlaybackAction or null if appropriate {@link Resources}
+         * cannot be obtained
+         */
+        @Nullable
+        public CustomPlaybackAction fetchDrawable(@NonNull Context context) {
+            Drawable icon;
+            if (mPackageName == null) {
+                icon = context.getDrawable(mIcon);
+            } else {
+                Resources resources = getResourcesForPackage(context, mPackageName);
+                if (resources == null) {
+                    return null;
+                } else {
+                    // the resources may be from another package. we need to update the
+                    // configuration
+                    // using the context from the activity so we get the drawable from the
+                    // correct DPI
+                    // bucket.
+                    resources.updateConfiguration(context.getResources().getConfiguration(),
+                            context.getResources().getDisplayMetrics());
+                    icon = resources.getDrawable(mIcon, null);
+                }
+            }
+            return new CustomPlaybackAction(icon, mAction, mExtras);
+        }
+
+        private Resources getResourcesForPackage(Context context, String packageName) {
+            try {
+                return context.getPackageManager().getResourcesForApplication(packageName);
+            } catch (PackageManager.NameNotFoundException e) {
+                Log.e(TAG, "Unable to get resources for " + packageName);
+                return null;
+            }
+        }
     }
 
     static class CombinedInfo {
diff --git a/car-media-common/src/com/android/car/media/common/playback/ProgressLiveData.java b/car-media-common/src/com/android/car/media/common/playback/ProgressLiveData.java
index ed7385c..1ae61c8 100644
--- a/car-media-common/src/com/android/car/media/common/playback/ProgressLiveData.java
+++ b/car-media-common/src/com/android/car/media/common/playback/ProgressLiveData.java
@@ -56,7 +56,11 @@
 
     private void updateProgress() {
         setValue(getProgress());
-        mTimerHandler.postDelayed(this::updateProgress, UPDATE_INTERVAL_MS);
+        if (mPlaybackState.getState() != PlaybackState.STATE_PAUSED
+                && mPlaybackState.getState() != PlaybackState.STATE_STOPPED
+                && mPlaybackState.getPlaybackSpeed() != 0) {
+            mTimerHandler.postDelayed(this::updateProgress, UPDATE_INTERVAL_MS);
+        }
     }
 
     private long getProgress() {
diff --git a/car-media-common/src/com/android/car/media/common/playback/QueueLiveData.java b/car-media-common/src/com/android/car/media/common/playback/QueueLiveData.java
index 99855a4..ad0d41d 100644
--- a/car-media-common/src/com/android/car/media/common/playback/QueueLiveData.java
+++ b/car-media-common/src/com/android/car/media/common/playback/QueueLiveData.java
@@ -47,6 +47,7 @@
 
     @Override
     protected void onActive() {
+        setValue(mMediaController.getQueue());
         mMediaController.registerCallback(mCallback);
     }
 
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/MediaMetadataLiveDataTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/MediaMetadataLiveDataTest.java
index bae29bd..802aeb9 100644
--- a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/MediaMetadataLiveDataTest.java
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/MediaMetadataLiveDataTest.java
@@ -20,6 +20,7 @@
 
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.media.MediaMetadata;
 import android.media.session.MediaController;
@@ -77,6 +78,15 @@
     }
 
     @Test
+    public void testGetValueOnActive() {
+        when(mMediaController.getMetadata()).thenReturn(mMetadata);
+        CaptureObserver<MediaMetadata> observer = new CaptureObserver<>();
+        mMediaMetadataLiveData.observe(mLifecycleOwner, observer);
+
+        assertThat(observer.getObservedValue()).isEqualTo(mMetadata);
+    }
+
+    @Test
     public void testDeliversValueToObserver() {
         CaptureObserver<MediaMetadata> observer = new CaptureObserver<>();
         mMediaMetadataLiveData.observe(mLifecycleOwner, observer);
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackStateLiveDataTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackStateLiveDataTest.java
index 9a03c01..9537e56 100644
--- a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackStateLiveDataTest.java
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackStateLiveDataTest.java
@@ -20,6 +20,7 @@
 
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.media.session.MediaController;
 import android.media.session.PlaybackState;
@@ -77,6 +78,15 @@
     }
 
     @Test
+    public void testGetValueOnActive() {
+        when(mMediaController.getPlaybackState()).thenReturn(mPlaybackState);
+        CaptureObserver<PlaybackState> observer = new CaptureObserver<>();
+        mPlaybackStateLiveData.observe(mLifecycleOwner, observer);
+
+        assertThat(observer.getObservedValue()).isEqualTo(mPlaybackState);
+    }
+
+    @Test
     public void testDeliversValueToObserver() {
         CaptureObserver<PlaybackState> observer = new CaptureObserver<>();
         mPlaybackStateLiveData.observe(mLifecycleOwner, observer);
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/QueueLiveDataTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/QueueLiveDataTest.java
index 47159ce..bad5073 100644
--- a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/QueueLiveDataTest.java
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/QueueLiveDataTest.java
@@ -20,6 +20,7 @@
 
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.media.session.MediaController;
 import android.media.session.MediaSession;
@@ -78,6 +79,16 @@
     }
 
     @Test
+    public void testGetValueOnActive() {
+        List<MediaSession.QueueItem> queue = Collections.emptyList();
+        when(mMediaController.getQueue()).thenReturn(queue);
+        CaptureObserver<List<MediaSession.QueueItem>> observer = new CaptureObserver<>();
+        mQueueLiveData.observe(mLifecycleOwner, observer);
+
+        assertThat(observer.getObservedValue()).isEqualTo(queue);
+    }
+
+    @Test
     public void testDeliversValueToObserver() {
         CaptureObserver<List<MediaSession.QueueItem>> observer = new CaptureObserver<>();
         mQueueLiveData.observe(mLifecycleOwner, observer);