MediaRouteControllerDialog.java

/*
 * 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 androidx.mediarouter.app;

import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP;

import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.SystemClock;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.view.animation.Transformation;
import android.view.animation.TranslateAnimation;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
import android.widget.TextView;

import androidx.appcompat.app.AlertDialog;
import androidx.core.util.ObjectsCompat;
import androidx.core.view.accessibility.AccessibilityEventCompat;
import androidx.mediarouter.R;
import androidx.mediarouter.media.MediaRouteSelector;
import androidx.mediarouter.media.MediaRouter;
import androidx.palette.graphics.Palette;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * This class implements the route controller dialog for {@link MediaRouter}.
 * <p>
 * This dialog allows the user to control or disconnect from the currently selected route.
 * </p>
 *
 * @see MediaRouteButton
 * @see MediaRouteActionProvider
 */
public class MediaRouteControllerDialog extends AlertDialog {
    // Tags should be less than 24 characters long (see docs for android.util.Log.isLoggable())
    static final String TAG = "MediaRouteCtrlDialog";
    static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    // Time to wait before updating the volume when the user lets go of the seek bar
    // to allow the route provider time to propagate the change and publish a new
    // route descriptor.
    static final int VOLUME_UPDATE_DELAY_MILLIS = 500;
    static final int CONNECTION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30L);

    private static final int BUTTON_NEUTRAL_RES_ID = android.R.id.button3;
    static final int BUTTON_DISCONNECT_RES_ID = android.R.id.button2;
    static final int BUTTON_STOP_RES_ID = android.R.id.button1;

    final MediaRouter mRouter;
    private final MediaRouterCallback mCallback;
    final MediaRouter.RouteInfo mRoute;

    Context mContext;
    private boolean mCreated;
    private boolean mAttachedToWindow;

    private int mDialogContentWidth;

    private View mCustomControlView;

    private Button mDisconnectButton;
    private Button mStopCastingButton;
    private ImageButton mPlaybackControlButton;
    private ImageButton mCloseButton;
    private MediaRouteExpandCollapseButton mGroupExpandCollapseButton;

    private FrameLayout mExpandableAreaLayout;
    private LinearLayout mDialogAreaLayout;
    FrameLayout mDefaultControlLayout;
    private FrameLayout mCustomControlLayout;
    private ImageView mArtView;
    private TextView mTitleView;
    private TextView mSubtitleView;
    private TextView mRouteNameTextView;

    private boolean mVolumeControlEnabled = true;
    // Layout for media controllers including play/pause button and the main volume slider.
    private LinearLayout mMediaMainControlLayout;
    private RelativeLayout mPlaybackControlLayout;
    private LinearLayout mVolumeControlLayout;
    private View mDividerView;

    OverlayListView mVolumeGroupList;
    VolumeGroupAdapter mVolumeGroupAdapter;
    private List<MediaRouter.RouteInfo> mGroupMemberRoutes;
    Set<MediaRouter.RouteInfo> mGroupMemberRoutesAdded;
    private Set<MediaRouter.RouteInfo> mGroupMemberRoutesRemoved;
    Set<MediaRouter.RouteInfo> mGroupMemberRoutesAnimatingWithBitmap;
    SeekBar mVolumeSlider;
    VolumeChangeListener mVolumeChangeListener;
    MediaRouter.RouteInfo mRouteInVolumeSliderTouched;
    private int mVolumeGroupListItemIconSize;
    private int mVolumeGroupListItemHeight;
    private int mVolumeGroupListMaxHeight;
    private final int mVolumeGroupListPaddingTop;
    Map<MediaRouter.RouteInfo, SeekBar> mVolumeSliderMap;

    MediaControllerCompat mMediaController;
    MediaControllerCallback mControllerCallback;
    PlaybackStateCompat mState;
    MediaDescriptionCompat mDescription;

    FetchArtTask mFetchArtTask;
    Bitmap mArtIconBitmap;
    Uri mArtIconUri;
    boolean mArtIconIsLoaded;
    Bitmap mArtIconLoadedBitmap;
    int mArtIconBackgroundColor;

    boolean mHasPendingUpdate;
    boolean mPendingUpdateAnimationNeeded;

    boolean mIsGroupExpanded;
    boolean mIsGroupListAnimating;
    boolean mIsGroupListAnimationPending;
    int mGroupListAnimationDurationMs;
    private int mGroupListFadeInDurationMs;
    private int mGroupListFadeOutDurationMs;

    private Interpolator mInterpolator;
    private Interpolator mLinearOutSlowInInterpolator;
    private Interpolator mFastOutSlowInInterpolator;
    private Interpolator mAccelerateDecelerateInterpolator;

    final AccessibilityManager mAccessibilityManager;

    Runnable mGroupListFadeInAnimation = new Runnable() {
        @Override
        public void run() {
            startGroupListFadeInAnimation();
        }
    };

    public MediaRouteControllerDialog(Context context) {
        this(context, 0);
    }

    public MediaRouteControllerDialog(Context context, int theme) {
        super(context = MediaRouterThemeHelper.createThemedDialogContext(context, theme, true),
                MediaRouterThemeHelper.createThemedDialogStyle(context));
        mContext = getContext();

        mControllerCallback = new MediaControllerCallback();
        mRouter = MediaRouter.getInstance(mContext);
        mCallback = new MediaRouterCallback();
        mRoute = mRouter.getSelectedRoute();
        setMediaSession(mRouter.getMediaSessionToken());
        mVolumeGroupListPaddingTop = mContext.getResources().getDimensionPixelSize(
                R.dimen.mr_controller_volume_group_list_padding_top);
        mAccessibilityManager =
                (AccessibilityManager) mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
        if (android.os.Build.VERSION.SDK_INT >= 21) {
            mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator(context,
                    R.interpolator.mr_linear_out_slow_in);
            mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(context,
                    R.interpolator.mr_fast_out_slow_in);
        }
        mAccelerateDecelerateInterpolator = new AccelerateDecelerateInterpolator();
    }

    /**
     * Gets the route that this dialog is controlling.
     */
    public MediaRouter.RouteInfo getRoute() {
        return mRoute;
    }

    /**
     * Provides the subclass an opportunity to create a view that will replace the default media
     * controls for the currently playing content.
     *
     * @param savedInstanceState The dialog's saved instance state.
     * @return The media control view, or null if none.
     */
    public View onCreateMediaControlView(Bundle savedInstanceState) {
        return null;
    }

    /**
     * Gets the media control view that was created by {@link #onCreateMediaControlView(Bundle)}.
     *
     * @return The media control view, or null if none.
     */
    public View getMediaControlView() {
        return mCustomControlView;
    }

    /**
     * Sets whether to enable the volume slider and volume control using the volume keys
     * when the route supports it.
     * <p>
     * The default value is true.
     * </p>
     */
    public void setVolumeControlEnabled(boolean enable) {
        if (mVolumeControlEnabled != enable) {
            mVolumeControlEnabled = enable;
            if (mCreated) {
                update(false);
            }
        }
    }

    /**
     * Returns whether to enable the volume slider and volume control using the volume keys
     * when the route supports it.
     */
    public boolean isVolumeControlEnabled() {
        return mVolumeControlEnabled;
    }

    /**
     * Set the session to use for metadata and transport controls. The dialog
     * will listen to changes on this session and update the UI automatically in
     * response to changes.
     *
     * @param sessionToken The token for the session to use.
     */
    private void setMediaSession(MediaSessionCompat.Token sessionToken) {
        if (mMediaController != null) {
            mMediaController.unregisterCallback(mControllerCallback);
            mMediaController = null;
        }
        if (sessionToken == null) {
            return;
        }
        if (!mAttachedToWindow) {
            return;
        }
        mMediaController = new MediaControllerCompat(mContext, sessionToken);
        mMediaController.registerCallback(mControllerCallback);
        MediaMetadataCompat metadata = mMediaController.getMetadata();
        mDescription = metadata == null ? null : metadata.getDescription();
        mState = mMediaController.getPlaybackState();
        updateArtIconIfNeeded();
        update(false);
    }

    /**
     * Gets the session to use for metadata and transport controls.
     *
     * @return The token for the session to use or null if none.
     */
    public MediaSessionCompat.Token getMediaSession() {
        return mMediaController == null ? null : mMediaController.getSessionToken();
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        getWindow().setBackgroundDrawableResource(android.R.color.transparent);
        setContentView(R.layout.mr_controller_material_dialog_b);

        // Remove the neutral button.
        findViewById(BUTTON_NEUTRAL_RES_ID).setVisibility(View.GONE);

        ClickListener listener = new ClickListener();

        mExpandableAreaLayout = findViewById(R.id.mr_expandable_area);
        mExpandableAreaLayout.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                dismiss();
            }
        });
        mDialogAreaLayout = findViewById(R.id.mr_dialog_area);
        mDialogAreaLayout.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // Eat unhandled touch events.
            }
        });
        int color = MediaRouterThemeHelper.getButtonTextColor(mContext);
        mDisconnectButton = findViewById(BUTTON_DISCONNECT_RES_ID);
        mDisconnectButton.setText(R.string.mr_controller_disconnect);
        mDisconnectButton.setTextColor(color);
        mDisconnectButton.setOnClickListener(listener);

        mStopCastingButton = findViewById(BUTTON_STOP_RES_ID);
        mStopCastingButton.setText(R.string.mr_controller_stop_casting);
        mStopCastingButton.setTextColor(color);
        mStopCastingButton.setOnClickListener(listener);

        mRouteNameTextView = findViewById(R.id.mr_name);
        mCloseButton = findViewById(R.id.mr_close);
        mCloseButton.setOnClickListener(listener);
        mCustomControlLayout = findViewById(R.id.mr_custom_control);
        mDefaultControlLayout = findViewById(R.id.mr_default_control);

        // Start the session activity when a content item (album art, title or subtitle) is clicked.
        View.OnClickListener onClickListener = new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (mMediaController != null) {
                    PendingIntent pi = mMediaController.getSessionActivity();
                    if (pi != null) {
                        try {
                            pi.send();
                            dismiss();
                        } catch (PendingIntent.CanceledException e) {
                            Log.e(TAG, pi + " was not sent, it had been canceled.");
                        }
                    }
                }
            }
        };
        mArtView = findViewById(R.id.mr_art);
        mArtView.setOnClickListener(onClickListener);
        findViewById(R.id.mr_control_title_container).setOnClickListener(onClickListener);

        mMediaMainControlLayout = findViewById(R.id.mr_media_main_control);
        mDividerView = findViewById(R.id.mr_control_divider);

        mPlaybackControlLayout = findViewById(R.id.mr_playback_control);
        mTitleView = findViewById(R.id.mr_control_title);
        mSubtitleView = findViewById(R.id.mr_control_subtitle);
        mPlaybackControlButton = findViewById(R.id.mr_control_playback_ctrl);
        mPlaybackControlButton.setOnClickListener(listener);

        mVolumeControlLayout = findViewById(R.id.mr_volume_control);
        mVolumeControlLayout.setVisibility(View.GONE);
        mVolumeSlider = findViewById(R.id.mr_volume_slider);
        mVolumeSlider.setTag(mRoute);
        mVolumeChangeListener = new VolumeChangeListener();
        mVolumeSlider.setOnSeekBarChangeListener(mVolumeChangeListener);

        mVolumeGroupList = findViewById(R.id.mr_volume_group_list);
        mGroupMemberRoutes = new ArrayList<MediaRouter.RouteInfo>();
        mVolumeGroupAdapter = new VolumeGroupAdapter(mVolumeGroupList.getContext(),
                mGroupMemberRoutes);
        mVolumeGroupList.setAdapter(mVolumeGroupAdapter);
        mGroupMemberRoutesAnimatingWithBitmap = new HashSet<>();

        MediaRouterThemeHelper.setMediaControlsBackgroundColor(mContext,
                mMediaMainControlLayout, mVolumeGroupList, mRoute.isGroup());
        MediaRouterThemeHelper.setVolumeSliderColor(mContext,
                (MediaRouteVolumeSlider) mVolumeSlider, mMediaMainControlLayout);
        mVolumeSliderMap = new HashMap<>();
        mVolumeSliderMap.put(mRoute, mVolumeSlider);

        mGroupExpandCollapseButton =
                findViewById(R.id.mr_group_expand_collapse);
        mGroupExpandCollapseButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mIsGroupExpanded = !mIsGroupExpanded;
                if (mIsGroupExpanded) {
                    mVolumeGroupList.setVisibility(View.VISIBLE);
                }
                loadInterpolator();
                updateLayoutHeight(true);
            }
        });
        loadInterpolator();
        mGroupListAnimationDurationMs = mContext.getResources().getInteger(
                R.integer.mr_controller_volume_group_list_animation_duration_ms);
        mGroupListFadeInDurationMs = mContext.getResources().getInteger(
                R.integer.mr_controller_volume_group_list_fade_in_duration_ms);
        mGroupListFadeOutDurationMs = mContext.getResources().getInteger(
                R.integer.mr_controller_volume_group_list_fade_out_duration_ms);

        mCustomControlView = onCreateMediaControlView(savedInstanceState);
        if (mCustomControlView != null) {
            mCustomControlLayout.addView(mCustomControlView);
            mCustomControlLayout.setVisibility(View.VISIBLE);
        }
        mCreated = true;
        updateLayout();
    }

    /**
     * Sets the width of the dialog. Also called when configuration changes.
     */
    void updateLayout() {
        int width = MediaRouteDialogHelper.getDialogWidth(mContext);
        getWindow().setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT);

        View decorView = getWindow().getDecorView();
        mDialogContentWidth = width - decorView.getPaddingLeft() - decorView.getPaddingRight();

        Resources res = mContext.getResources();
        mVolumeGroupListItemIconSize = res.getDimensionPixelSize(
                R.dimen.mr_controller_volume_group_list_item_icon_size);
        mVolumeGroupListItemHeight = res.getDimensionPixelSize(
                R.dimen.mr_controller_volume_group_list_item_height);
        mVolumeGroupListMaxHeight = res.getDimensionPixelSize(
                R.dimen.mr_controller_volume_group_list_max_height);

        // Fetch art icons again for layout changes to resize it accordingly
        mArtIconBitmap = null;
        mArtIconUri = null;
        updateArtIconIfNeeded();
        update(false);
    }

    @Override
    public void onAttachedToWindow() {
        super.onAttachedToWindow();
        mAttachedToWindow = true;

        mRouter.addCallback(MediaRouteSelector.EMPTY, mCallback,
                MediaRouter.CALLBACK_FLAG_UNFILTERED_EVENTS);
        setMediaSession(mRouter.getMediaSessionToken());
    }

    @Override
    public void onDetachedFromWindow() {
        mRouter.removeCallback(mCallback);
        setMediaSession(null);
        mAttachedToWindow = false;
        super.onDetachedFromWindow();
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
                || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
            mRoute.requestUpdateVolume(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ? -1 : 1);
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
                || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
            return true;
        }
        return super.onKeyUp(keyCode, event);
    }

    void update(boolean animate) {
        // Defer dialog updates if a user is adjusting a volume in the list
        if (mRouteInVolumeSliderTouched != null) {
            mHasPendingUpdate = true;
            mPendingUpdateAnimationNeeded |= animate;
            return;
        }
        mHasPendingUpdate = false;
        mPendingUpdateAnimationNeeded = false;
        if (!mRoute.isSelected() || mRoute.isDefaultOrBluetooth()) {
            dismiss();
            return;
        }
        if (!mCreated) {
            return;
        }

        mRouteNameTextView.setText(mRoute.getName());
        mDisconnectButton.setVisibility(mRoute.canDisconnect() ? View.VISIBLE : View.GONE);
        if (mCustomControlView == null && mArtIconIsLoaded) {
            if (isBitmapRecycled(mArtIconLoadedBitmap)) {
                Log.w(TAG, "Can't set artwork image with recycled bitmap: " + mArtIconLoadedBitmap);
            } else {
                mArtView.setImageBitmap(mArtIconLoadedBitmap);
                mArtView.setBackgroundColor(mArtIconBackgroundColor);
            }
            clearLoadedBitmap();
        }
        updateVolumeControlLayout();
        updatePlaybackControlLayout();
        updateLayoutHeight(animate);
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static boolean isBitmapRecycled(Bitmap bitmap) {
        return bitmap != null && bitmap.isRecycled();
    }

    private boolean canShowPlaybackControlLayout() {
        return mCustomControlView == null && (mDescription != null || mState != null);
    }

    /**
     * Returns the height of main media controller which includes playback control and master
     * volume control.
     */
    private int getMainControllerHeight(boolean showPlaybackControl) {
        int height = 0;
        if (showPlaybackControl || mVolumeControlLayout.getVisibility() == View.VISIBLE) {
            height += mMediaMainControlLayout.getPaddingTop()
                    + mMediaMainControlLayout.getPaddingBottom();
            if (showPlaybackControl) {
                height +=  mPlaybackControlLayout.getMeasuredHeight();
            }
            if (mVolumeControlLayout.getVisibility() == View.VISIBLE) {
                height += mVolumeControlLayout.getMeasuredHeight();
            }
            if (showPlaybackControl && mVolumeControlLayout.getVisibility() == View.VISIBLE) {
                height += mDividerView.getMeasuredHeight();
            }
        }
        return height;
    }

    private void updateMediaControlVisibility(boolean canShowPlaybackControlLayout) {
        // TODO: Update the top and bottom padding of the control layout according to the display
        // height.
        mDividerView.setVisibility((mVolumeControlLayout.getVisibility() == View.VISIBLE
                && canShowPlaybackControlLayout) ? View.VISIBLE : View.GONE);
        mMediaMainControlLayout.setVisibility((mVolumeControlLayout.getVisibility() == View.GONE
                && !canShowPlaybackControlLayout) ? View.GONE : View.VISIBLE);
    }

    void updateLayoutHeight(final boolean animate) {
        // We need to defer the update until the first layout has occurred, as we don't yet know the
        // overall visible display size in which the window this view is attached to has been
        // positioned in.
        mDefaultControlLayout.requestLayout();
        ViewTreeObserver observer = mDefaultControlLayout.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                mDefaultControlLayout.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                if (mIsGroupListAnimating) {
                    mIsGroupListAnimationPending = true;
                } else {
                    updateLayoutHeightInternal(animate);
                }
            }
        });
    }

    /**
     * Updates the height of views and hide artwork or metadata if space is limited.
     */
    void updateLayoutHeightInternal(boolean animate) {
        // Measure the size of widgets and get the height of main components.
        int oldHeight = getLayoutHeight(mMediaMainControlLayout);
        setLayoutHeight(mMediaMainControlLayout, ViewGroup.LayoutParams.MATCH_PARENT);
        updateMediaControlVisibility(canShowPlaybackControlLayout());
        View decorView = getWindow().getDecorView();
        decorView.measure(
                MeasureSpec.makeMeasureSpec(getWindow().getAttributes().width, MeasureSpec.EXACTLY),
                MeasureSpec.UNSPECIFIED);
        setLayoutHeight(mMediaMainControlLayout, oldHeight);
        int artViewHeight = 0;
        if (mCustomControlView == null && mArtView.getDrawable() instanceof BitmapDrawable) {
            Bitmap art = ((BitmapDrawable) mArtView.getDrawable()).getBitmap();
            if (art != null) {
                artViewHeight = getDesiredArtHeight(art.getWidth(), art.getHeight());
                mArtView.setScaleType(art.getWidth() >= art.getHeight()
                        ? ImageView.ScaleType.FIT_XY : ImageView.ScaleType.FIT_CENTER);
            }
        }
        int mainControllerHeight = getMainControllerHeight(canShowPlaybackControlLayout());
        int volumeGroupListCount = mGroupMemberRoutes.size();
        // Scale down volume group list items in landscape mode.
        int expandedGroupListHeight = mRoute.isGroup()
                ? mVolumeGroupListItemHeight * mRoute.getMemberRoutes().size() : 0;
        if (volumeGroupListCount > 0) {
            expandedGroupListHeight += mVolumeGroupListPaddingTop;
        }
        expandedGroupListHeight = Math.min(expandedGroupListHeight, mVolumeGroupListMaxHeight);
        int visibleGroupListHeight = mIsGroupExpanded ? expandedGroupListHeight : 0;

        int desiredControlLayoutHeight =
                Math.max(artViewHeight, visibleGroupListHeight) + mainControllerHeight;
        Rect visibleRect = new Rect();
        decorView.getWindowVisibleDisplayFrame(visibleRect);
        // Height of non-control views in decor view.
        // This includes title bar, button bar, and dialog's vertical padding which should be
        // always shown.
        int nonControlViewHeight = mDialogAreaLayout.getMeasuredHeight()
                - mDefaultControlLayout.getMeasuredHeight();
        // Maximum allowed height for controls to fit screen.
        int maximumControlViewHeight = visibleRect.height() - nonControlViewHeight;

        // Show artwork if it fits the screen.
        if (mCustomControlView == null && artViewHeight > 0
                && desiredControlLayoutHeight <= maximumControlViewHeight) {
            mArtView.setVisibility(View.VISIBLE);
            setLayoutHeight(mArtView, artViewHeight);
        } else {
            if (getLayoutHeight(mVolumeGroupList) + mMediaMainControlLayout.getMeasuredHeight()
                    >= mDefaultControlLayout.getMeasuredHeight()) {
                mArtView.setVisibility(View.GONE);
            }
            artViewHeight = 0;
            desiredControlLayoutHeight = visibleGroupListHeight + mainControllerHeight;
        }
        // Show the playback control if it fits the screen.
        if (canShowPlaybackControlLayout()
                && desiredControlLayoutHeight <= maximumControlViewHeight) {
            mPlaybackControlLayout.setVisibility(View.VISIBLE);
        } else {
            mPlaybackControlLayout.setVisibility(View.GONE);
        }
        updateMediaControlVisibility(mPlaybackControlLayout.getVisibility() == View.VISIBLE);
        mainControllerHeight = getMainControllerHeight(
                mPlaybackControlLayout.getVisibility() == View.VISIBLE);
        desiredControlLayoutHeight =
                Math.max(artViewHeight, visibleGroupListHeight) + mainControllerHeight;

        // Limit the volume group list height to fit the screen.
        if (desiredControlLayoutHeight > maximumControlViewHeight) {
            visibleGroupListHeight -= (desiredControlLayoutHeight - maximumControlViewHeight);
            desiredControlLayoutHeight = maximumControlViewHeight;
        }
        // Update the layouts with the computed heights.
        mMediaMainControlLayout.clearAnimation();
        mVolumeGroupList.clearAnimation();
        mDefaultControlLayout.clearAnimation();
        if (animate) {
            animateLayoutHeight(mMediaMainControlLayout, mainControllerHeight);
            animateLayoutHeight(mVolumeGroupList, visibleGroupListHeight);
            animateLayoutHeight(mDefaultControlLayout, desiredControlLayoutHeight);
        } else {
            setLayoutHeight(mMediaMainControlLayout, mainControllerHeight);
            setLayoutHeight(mVolumeGroupList, visibleGroupListHeight);
            setLayoutHeight(mDefaultControlLayout, desiredControlLayoutHeight);
        }
        // Maximize the window size with a transparent layout in advance for smooth animation.
        setLayoutHeight(mExpandableAreaLayout, visibleRect.height());
        rebuildVolumeGroupList(animate);
    }

    void updateVolumeGroupItemHeight(View item) {
        LinearLayout container = (LinearLayout) item.findViewById(R.id.volume_item_container);
        setLayoutHeight(container, mVolumeGroupListItemHeight);
        View icon = item.findViewById(R.id.mr_volume_item_icon);
        ViewGroup.LayoutParams lp = icon.getLayoutParams();
        lp.width = mVolumeGroupListItemIconSize;
        lp.height = mVolumeGroupListItemIconSize;
        icon.setLayoutParams(lp);
    }

    private void animateLayoutHeight(final View view, int targetHeight) {
        final int startValue = getLayoutHeight(view);
        final int endValue = targetHeight;
        Animation anim = new Animation() {
            @Override
            protected void applyTransformation(float interpolatedTime, Transformation t) {
                int height = startValue - (int) ((startValue - endValue) * interpolatedTime);
                setLayoutHeight(view, height);
            }
        };
        anim.setDuration(mGroupListAnimationDurationMs);
        if (android.os.Build.VERSION.SDK_INT >= 21) {
            anim.setInterpolator(mInterpolator);
        }
        view.startAnimation(anim);
    }

    void loadInterpolator() {
        if (android.os.Build.VERSION.SDK_INT >= 21) {
            mInterpolator = mIsGroupExpanded ? mLinearOutSlowInInterpolator
                    : mFastOutSlowInInterpolator;
        } else {
            mInterpolator = mAccelerateDecelerateInterpolator;
        }
    }

    private void updateVolumeControlLayout() {
        if (isVolumeControlAvailable(mRoute)) {
            if (mVolumeControlLayout.getVisibility() == View.GONE) {
                mVolumeControlLayout.setVisibility(View.VISIBLE);
                mVolumeSlider.setMax(mRoute.getVolumeMax());
                mVolumeSlider.setProgress(mRoute.getVolume());
                mGroupExpandCollapseButton.setVisibility(mRoute.isGroup()
                        ? View.VISIBLE : View.GONE);
            }
        } else {
            mVolumeControlLayout.setVisibility(View.GONE);
        }
    }

    private void rebuildVolumeGroupList(boolean animate) {
        List<MediaRouter.RouteInfo> routes = mRoute.getMemberRoutes();
        if (routes.isEmpty()) {
            mGroupMemberRoutes.clear();
            mVolumeGroupAdapter.notifyDataSetChanged();
        } else if (MediaRouteDialogHelper.listUnorderedEquals(mGroupMemberRoutes, routes)) {
            mVolumeGroupAdapter.notifyDataSetChanged();
        } else {
            HashMap<MediaRouter.RouteInfo, Rect> previousRouteBoundMap = animate
                    ? MediaRouteDialogHelper.getItemBoundMap(mVolumeGroupList, mVolumeGroupAdapter)
                    : null;
            HashMap<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap = animate
                    ? MediaRouteDialogHelper.getItemBitmapMap(mContext, mVolumeGroupList,
                            mVolumeGroupAdapter) : null;
            mGroupMemberRoutesAdded =
                    MediaRouteDialogHelper.getItemsAdded(mGroupMemberRoutes, routes);
            mGroupMemberRoutesRemoved = MediaRouteDialogHelper.getItemsRemoved(mGroupMemberRoutes,
                    routes);
            mGroupMemberRoutes.addAll(0, mGroupMemberRoutesAdded);
            mGroupMemberRoutes.removeAll(mGroupMemberRoutesRemoved);
            mVolumeGroupAdapter.notifyDataSetChanged();
            if (animate && mIsGroupExpanded
                    && mGroupMemberRoutesAdded.size() + mGroupMemberRoutesRemoved.size() > 0) {
                animateGroupListItems(previousRouteBoundMap, previousRouteBitmapMap);
            } else {
                mGroupMemberRoutesAdded = null;
                mGroupMemberRoutesRemoved = null;
            }
        }
    }

    private void animateGroupListItems(final Map<MediaRouter.RouteInfo, Rect> previousRouteBoundMap,
            final Map<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap) {
        mVolumeGroupList.setEnabled(false);
        mVolumeGroupList.requestLayout();
        mIsGroupListAnimating = true;
        ViewTreeObserver observer = mVolumeGroupList.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                mVolumeGroupList.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                animateGroupListItemsInternal(previousRouteBoundMap, previousRouteBitmapMap);
            }
        });
    }

    void animateGroupListItemsInternal(
            Map<MediaRouter.RouteInfo, Rect> previousRouteBoundMap,
            Map<MediaRouter.RouteInfo, BitmapDrawable> previousRouteBitmapMap) {
        if (mGroupMemberRoutesAdded == null || mGroupMemberRoutesRemoved == null) {
            return;
        }
        int groupSizeDelta = mGroupMemberRoutesAdded.size() - mGroupMemberRoutesRemoved.size();
        boolean listenerRegistered = false;
        Animation.AnimationListener listener = new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
                mVolumeGroupList.startAnimationAll();
                mVolumeGroupList.postDelayed(mGroupListFadeInAnimation,
                        mGroupListAnimationDurationMs);
            }

            @Override
            public void onAnimationEnd(Animation animation) { }

            @Override
            public void onAnimationRepeat(Animation animation) { }
        };

        // Animate visible items from previous positions to current positions except routes added
        // just before. Added routes will remain hidden until translate animation finishes.
        int first = mVolumeGroupList.getFirstVisiblePosition();
        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
            View view = mVolumeGroupList.getChildAt(i);
            int position = first + i;
            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
            Rect previousBounds = previousRouteBoundMap.get(route);
            int currentTop = view.getTop();
            int previousTop = previousBounds != null ? previousBounds.top
                    : (currentTop + mVolumeGroupListItemHeight * groupSizeDelta);
            AnimationSet animSet = new AnimationSet(true);
            if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.contains(route)) {
                previousTop = currentTop;
                Animation alphaAnim = new AlphaAnimation(0.0f, 0.0f);
                alphaAnim.setDuration(mGroupListFadeInDurationMs);
                animSet.addAnimation(alphaAnim);
            }
            Animation translationAnim = new TranslateAnimation(0, 0, previousTop - currentTop, 0);
            translationAnim.setDuration(mGroupListAnimationDurationMs);
            animSet.addAnimation(translationAnim);
            animSet.setFillAfter(true);
            animSet.setFillEnabled(true);
            animSet.setInterpolator(mInterpolator);
            if (!listenerRegistered) {
                listenerRegistered = true;
                animSet.setAnimationListener(listener);
            }
            view.clearAnimation();
            view.startAnimation(animSet);
            previousRouteBoundMap.remove(route);
            previousRouteBitmapMap.remove(route);
        }

        // If a member route doesn't exist any longer, it can be either removed or moved out of the
        // ListView layout boundary. In this case, use the previously captured bitmaps for
        // animation.
        for (Map.Entry<MediaRouter.RouteInfo, BitmapDrawable> item
                : previousRouteBitmapMap.entrySet()) {
            final MediaRouter.RouteInfo route = item.getKey();
            final BitmapDrawable bitmap = item.getValue();
            final Rect bounds = previousRouteBoundMap.get(route);
            OverlayListView.OverlayObject object = null;
            if (mGroupMemberRoutesRemoved.contains(route)) {
                object = new OverlayListView.OverlayObject(bitmap, bounds).setAlphaAnimation(1.0f, 0.0f)
                        .setDuration(mGroupListFadeOutDurationMs)
                        .setInterpolator(mInterpolator);
            } else {
                int deltaY = groupSizeDelta * mVolumeGroupListItemHeight;
                object = new OverlayListView.OverlayObject(bitmap, bounds).setTranslateYAnimation(deltaY)
                        .setDuration(mGroupListAnimationDurationMs)
                        .setInterpolator(mInterpolator)
                        .setAnimationEndListener(new OverlayListView.OverlayObject.OnAnimationEndListener() {
                            @Override
                            public void onAnimationEnd() {
                                mGroupMemberRoutesAnimatingWithBitmap.remove(route);
                                mVolumeGroupAdapter.notifyDataSetChanged();
                            }
                        });
                mGroupMemberRoutesAnimatingWithBitmap.add(route);
            }
            mVolumeGroupList.addOverlayObject(object);
        }
    }

    void startGroupListFadeInAnimation() {
        clearGroupListAnimation(true);
        mVolumeGroupList.requestLayout();
        ViewTreeObserver observer = mVolumeGroupList.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                mVolumeGroupList.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                startGroupListFadeInAnimationInternal();
            }
        });
    }

    void startGroupListFadeInAnimationInternal() {
        if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.size() != 0) {
            fadeInAddedRoutes();
        } else {
            finishAnimation(true);
        }
    }

    void finishAnimation(boolean animate) {
        mGroupMemberRoutesAdded = null;
        mGroupMemberRoutesRemoved = null;
        mIsGroupListAnimating = false;
        if (mIsGroupListAnimationPending) {
            mIsGroupListAnimationPending = false;
            updateLayoutHeight(animate);
        }
        mVolumeGroupList.setEnabled(true);
    }

    private void fadeInAddedRoutes() {
        Animation.AnimationListener listener = new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) { }

            @Override
            public void onAnimationEnd(Animation animation) {
                finishAnimation(true);
            }

            @Override
            public void onAnimationRepeat(Animation animation) { }
        };
        boolean listenerRegistered = false;
        int first = mVolumeGroupList.getFirstVisiblePosition();
        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
            View view = mVolumeGroupList.getChildAt(i);
            int position = first + i;
            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
            if (mGroupMemberRoutesAdded.contains(route)) {
                Animation alphaAnim = new AlphaAnimation(0.0f, 1.0f);
                alphaAnim.setDuration(mGroupListFadeInDurationMs);
                alphaAnim.setFillEnabled(true);
                alphaAnim.setFillAfter(true);
                if (!listenerRegistered) {
                    listenerRegistered = true;
                    alphaAnim.setAnimationListener(listener);
                }
                view.clearAnimation();
                view.startAnimation(alphaAnim);
            }
        }
    }

    void clearGroupListAnimation(boolean exceptAddedRoutes) {
        int first = mVolumeGroupList.getFirstVisiblePosition();
        for (int i = 0; i < mVolumeGroupList.getChildCount(); ++i) {
            View view = mVolumeGroupList.getChildAt(i);
            int position = first + i;
            MediaRouter.RouteInfo route = mVolumeGroupAdapter.getItem(position);
            if (exceptAddedRoutes && mGroupMemberRoutesAdded != null
                    && mGroupMemberRoutesAdded.contains(route)) {
                continue;
            }
            LinearLayout container = (LinearLayout) view.findViewById(R.id.volume_item_container);
            container.setVisibility(View.VISIBLE);
            AnimationSet animSet = new AnimationSet(true);
            Animation alphaAnim = new AlphaAnimation(1.0f, 1.0f);
            alphaAnim.setDuration(0);
            animSet.addAnimation(alphaAnim);
            Animation translationAnim = new TranslateAnimation(0, 0, 0, 0);
            translationAnim.setDuration(0);
            animSet.setFillAfter(true);
            animSet.setFillEnabled(true);
            view.clearAnimation();
            view.startAnimation(animSet);
        }
        mVolumeGroupList.stopAnimationAll();
        if (!exceptAddedRoutes) {
            finishAnimation(false);
        }
    }

    private void updatePlaybackControlLayout() {
        if (canShowPlaybackControlLayout()) {
            CharSequence title = mDescription == null ? null : mDescription.getTitle();
            boolean hasTitle = !TextUtils.isEmpty(title);

            CharSequence subtitle = mDescription == null ? null : mDescription.getSubtitle();
            boolean hasSubtitle = !TextUtils.isEmpty(subtitle);

            boolean showTitle = false;
            boolean showSubtitle = false;
            if (mRoute.getPresentationDisplayId()
                    != MediaRouter.RouteInfo.PRESENTATION_DISPLAY_ID_NONE) {
                // The user is currently casting screen.
                mTitleView.setText(R.string.mr_controller_casting_screen);
                showTitle = true;
            } else if (mState == null || mState.getState() == PlaybackStateCompat.STATE_NONE) {
                // Show "No media selected" as we don't yet know the playback state.
                mTitleView.setText(R.string.mr_controller_no_media_selected);
                showTitle = true;
            } else if (!hasTitle && !hasSubtitle) {
                mTitleView.setText(R.string.mr_controller_no_info_available);
                showTitle = true;
            } else {
                if (hasTitle) {
                    mTitleView.setText(title);
                    showTitle = true;
                }
                if (hasSubtitle) {
                    mSubtitleView.setText(subtitle);
                    showSubtitle = true;
                }
            }
            mTitleView.setVisibility(showTitle ? View.VISIBLE : View.GONE);
            mSubtitleView.setVisibility(showSubtitle ? View.VISIBLE : View.GONE);

            if (mState != null) {
                boolean isPlaying = mState.getState() == PlaybackStateCompat.STATE_BUFFERING
                        || mState.getState() == PlaybackStateCompat.STATE_PLAYING;
                Context playbackControlButtonContext = mPlaybackControlButton.getContext();
                boolean visible = true;
                int iconDrawableAttr = 0;
                int iconDescResId = 0;
                if (isPlaying && isPauseActionSupported()) {
                    iconDrawableAttr = R.attr.mediaRoutePauseDrawable;
                    iconDescResId = R.string.mr_controller_pause;
                } else if (isPlaying && isStopActionSupported()) {
                    iconDrawableAttr = R.attr.mediaRouteStopDrawable;
                    iconDescResId = R.string.mr_controller_stop;
                } else if (!isPlaying && isPlayActionSupported()) {
                    iconDrawableAttr = R.attr.mediaRoutePlayDrawable;
                    iconDescResId = R.string.mr_controller_play;
                } else {
                    visible = false;
                }
                mPlaybackControlButton.setVisibility(visible ? View.VISIBLE : View.GONE);
                if (visible) {
                    mPlaybackControlButton.setImageResource(
                            MediaRouterThemeHelper.getThemeResource(
                                    playbackControlButtonContext, iconDrawableAttr));
                    mPlaybackControlButton.setContentDescription(
                            playbackControlButtonContext.getResources()
                                    .getText(iconDescResId));
                }
            }
        }
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean isPlayActionSupported() {
        return (mState.getActions() & (ACTION_PLAY | ACTION_PLAY_PAUSE)) != 0;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean isPauseActionSupported() {
        return (mState.getActions() & (ACTION_PAUSE | ACTION_PLAY_PAUSE)) != 0;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean isStopActionSupported() {
        return (mState.getActions() & ACTION_STOP) != 0;
    }

    boolean isVolumeControlAvailable(MediaRouter.RouteInfo route) {
        return mVolumeControlEnabled && route.getVolumeHandling()
                == MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE;
    }

    private static int getLayoutHeight(View view) {
        return view.getLayoutParams().height;
    }

    static void setLayoutHeight(View view, int height) {
        ViewGroup.LayoutParams lp = view.getLayoutParams();
        lp.height = height;
        view.setLayoutParams(lp);
    }

    private static boolean uriEquals(Uri uri1, Uri uri2) {
        if (uri1 != null && uri1.equals(uri2)) {
            return true;
        } else if (uri1 == null && uri2 == null) {
            return true;
        }
        return false;
    }

    /**
     * Returns desired art height to fit into controller dialog.
     */
    int getDesiredArtHeight(int originalWidth, int originalHeight) {
        if (originalWidth >= originalHeight) {
            // For landscape art, fit width to dialog width.
            return (int) ((float) mDialogContentWidth * originalHeight / originalWidth + 0.5f);
        }
        // For portrait art, fit height to 16:9 ratio case's height.
        return (int) ((float) mDialogContentWidth * 9 / 16 + 0.5f);
    }

    void updateArtIconIfNeeded() {
        if (mCustomControlView != null || !isIconChanged()) {
            return;
        }
        if (mFetchArtTask != null) {
            mFetchArtTask.cancel(true);
        }
        mFetchArtTask = new FetchArtTask();
        mFetchArtTask.execute();
    }

    /**
     * Clear the bitmap loaded by FetchArtTask. Will be called after the loaded bitmaps are applied
     * to artwork, or no longer valid.
     */
    void clearLoadedBitmap() {
        mArtIconIsLoaded = false;
        mArtIconLoadedBitmap = null;
        mArtIconBackgroundColor = 0;
    }

    /**
     * Returns whether a new art image is different from an original art image. Compares
     * Bitmap objects first, and then compares URIs only if bitmap is unchanged with
     * a null value.
     */
    private boolean isIconChanged() {
        Bitmap newBitmap = mDescription == null ? null : mDescription.getIconBitmap();
        Uri newUri = mDescription == null ? null : mDescription.getIconUri();
        Bitmap oldBitmap = mFetchArtTask == null ? mArtIconBitmap : mFetchArtTask.getIconBitmap();
        Uri oldUri = mFetchArtTask == null ? mArtIconUri : mFetchArtTask.getIconUri();
        if (oldBitmap != newBitmap) {
            return true;
        } else if (oldBitmap == null && !uriEquals(oldUri, newUri)) {
            return true;
        }
        return false;
    }

    private final class MediaRouterCallback extends MediaRouter.Callback {
        MediaRouterCallback() {
        }

        @Override
        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo route) {
            update(false);
        }

        @Override
        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
            update(true);
        }

        @Override
        public void onRouteVolumeChanged(MediaRouter router, MediaRouter.RouteInfo route) {
            SeekBar volumeSlider = mVolumeSliderMap.get(route);
            int volume = route.getVolume();
            if (DEBUG) {
                Log.d(TAG, "onRouteVolumeChanged(), route.getVolume:" + volume);
            }
            if (volumeSlider != null && mRouteInVolumeSliderTouched != route) {
                volumeSlider.setProgress(volume);
            }
        }
    }

    private final class MediaControllerCallback extends MediaControllerCompat.Callback {
        MediaControllerCallback() {
        }

        @Override
        public void onSessionDestroyed() {
            if (mMediaController != null) {
                mMediaController.unregisterCallback(mControllerCallback);
                mMediaController = null;
            }
        }

        @Override
        public void onPlaybackStateChanged(PlaybackStateCompat state) {
            mState = state;
            update(false);
        }

        @Override
        public void onMetadataChanged(MediaMetadataCompat metadata) {
            mDescription = metadata == null ? null : metadata.getDescription();
            updateArtIconIfNeeded();
            update(false);
        }
    }

    private final class ClickListener implements View.OnClickListener {
        ClickListener() {
        }

        @Override
        public void onClick(View v) {
            int id = v.getId();
            if (id == BUTTON_STOP_RES_ID || id == BUTTON_DISCONNECT_RES_ID) {
                if (mRoute.isSelected()) {
                    mRouter.unselect(id == BUTTON_STOP_RES_ID ?
                            MediaRouter.UNSELECT_REASON_STOPPED :
                            MediaRouter.UNSELECT_REASON_DISCONNECTED);
                }
                dismiss();
            } else if (id == R.id.mr_control_playback_ctrl) {
                if (mMediaController != null && mState != null) {
                    boolean isPlaying = mState.getState() == PlaybackStateCompat.STATE_PLAYING;
                    int actionDescResId = 0;
                    if (isPlaying && isPauseActionSupported()) {
                        mMediaController.getTransportControls().pause();
                        actionDescResId = R.string.mr_controller_pause;
                    } else if (isPlaying && isStopActionSupported()) {
                        mMediaController.getTransportControls().stop();
                        actionDescResId = R.string.mr_controller_stop;
                    } else if (!isPlaying && isPlayActionSupported()){
                        mMediaController.getTransportControls().play();
                        actionDescResId = R.string.mr_controller_play;
                    }
                    // Announce the action for accessibility.
                    if (mAccessibilityManager != null && mAccessibilityManager.isEnabled()
                            && actionDescResId != 0) {
                        AccessibilityEvent event = AccessibilityEvent.obtain(
                                AccessibilityEventCompat.TYPE_ANNOUNCEMENT);
                        event.setPackageName(mContext.getPackageName());
                        event.setClassName(getClass().getName());
                        event.getText().add(mContext.getString(actionDescResId));
                        mAccessibilityManager.sendAccessibilityEvent(event);
                    }
                }
            } else if (id == R.id.mr_close) {
                dismiss();
            }
        }
    }

    private class VolumeChangeListener implements SeekBar.OnSeekBarChangeListener {
        private final Runnable mStopTrackingTouch = new Runnable() {
            @Override
            public void run() {
                if (mRouteInVolumeSliderTouched != null) {
                    mRouteInVolumeSliderTouched = null;
                    if (mHasPendingUpdate) {
                        update(mPendingUpdateAnimationNeeded);
                    }
                }
            }
        };

        VolumeChangeListener() {
        }

        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
            if (mRouteInVolumeSliderTouched != null) {
                mVolumeSlider.removeCallbacks(mStopTrackingTouch);
            }
            mRouteInVolumeSliderTouched = (MediaRouter.RouteInfo) seekBar.getTag();
        }

        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
            // Defer resetting mVolumeSliderTouched to allow the media route provider
            // a little time to settle into its new state and publish the final
            // volume update.
            mVolumeSlider.postDelayed(mStopTrackingTouch, VOLUME_UPDATE_DELAY_MILLIS);
        }

        @Override
        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
            if (fromUser) {
                MediaRouter.RouteInfo route = (MediaRouter.RouteInfo) seekBar.getTag();
                if (DEBUG) {
                    Log.d(TAG, "onProgressChanged(): calling "
                            + "MediaRouter.RouteInfo.requestSetVolume(" + progress + ")");
                }
                route.requestSetVolume(progress);
            }
        }
    }

    private class VolumeGroupAdapter extends ArrayAdapter<MediaRouter.RouteInfo> {
        final float mDisabledAlpha;

        public VolumeGroupAdapter(Context context, List<MediaRouter.RouteInfo> objects) {
            super(context, 0, objects);
            mDisabledAlpha = MediaRouterThemeHelper.getDisabledAlpha(context);
        }

        @Override
        public boolean isEnabled(int position) {
            return false;
        }

        @Override
        public View getView(final int position, View convertView, ViewGroup parent) {
            View v = convertView;
            if (v == null) {
                v = LayoutInflater.from(parent.getContext()).inflate(
                        R.layout.mr_controller_volume_item, parent, false);
            } else {
                updateVolumeGroupItemHeight(v);
            }

            MediaRouter.RouteInfo route = getItem(position);
            if (route != null) {
                boolean isEnabled = route.isEnabled();

                TextView routeName = (TextView) v.findViewById(R.id.mr_name);
                routeName.setEnabled(isEnabled);
                routeName.setText(route.getName());

                MediaRouteVolumeSlider volumeSlider =
                        (MediaRouteVolumeSlider) v.findViewById(R.id.mr_volume_slider);
                MediaRouterThemeHelper.setVolumeSliderColor(
                        parent.getContext(), volumeSlider, mVolumeGroupList);
                volumeSlider.setTag(route);
                mVolumeSliderMap.put(route, volumeSlider);
                volumeSlider.setHideThumb(!isEnabled);
                volumeSlider.setEnabled(isEnabled);
                if (isEnabled) {
                    if (isVolumeControlAvailable(route)) {
                        volumeSlider.setMax(route.getVolumeMax());
                        volumeSlider.setProgress(route.getVolume());
                        volumeSlider.setOnSeekBarChangeListener(mVolumeChangeListener);
                    } else {
                        volumeSlider.setMax(100);
                        volumeSlider.setProgress(100);
                        volumeSlider.setEnabled(false);
                    }
                }

                ImageView volumeItemIcon =
                        (ImageView) v.findViewById(R.id.mr_volume_item_icon);
                volumeItemIcon.setAlpha(isEnabled ? 0xFF : (int) (0xFF * mDisabledAlpha));

                // If overlay bitmap exists, real view should remain hidden until
                // the animation ends.
                LinearLayout container = (LinearLayout) v.findViewById(R.id.volume_item_container);
                container.setVisibility(mGroupMemberRoutesAnimatingWithBitmap.contains(route)
                        ? View.INVISIBLE : View.VISIBLE);

                // Routes which are being added will be invisible until animation ends.
                if (mGroupMemberRoutesAdded != null && mGroupMemberRoutesAdded.contains(route)) {
                    Animation alphaAnim = new AlphaAnimation(0.0f, 0.0f);
                    alphaAnim.setDuration(0);
                    alphaAnim.setFillEnabled(true);
                    alphaAnim.setFillAfter(true);
                    v.clearAnimation();
                    v.startAnimation(alphaAnim);
                }
            }
            return v;
        }
    }

    private class FetchArtTask extends AsyncTask<Void, Void, Bitmap> {
        // Show animation only when fetching takes a long time.
        private static final long SHOW_ANIM_TIME_THRESHOLD_MILLIS = 120L;

        private final Bitmap mIconBitmap;
        private final Uri mIconUri;
        private int mBackgroundColor;
        private long mStartTimeMillis;

        FetchArtTask() {
            Bitmap bitmap = mDescription == null ? null : mDescription.getIconBitmap();
            if (isBitmapRecycled(bitmap)) {
                Log.w(TAG, "Can't fetch the given art bitmap because it's already recycled.");
                bitmap = null;
            }
            mIconBitmap = bitmap;
            mIconUri = mDescription == null ? null : mDescription.getIconUri();
        }

        public Bitmap getIconBitmap() {
            return mIconBitmap;
        }

        public Uri getIconUri() {
            return mIconUri;
        }

        @Override
        protected void onPreExecute() {
            mStartTimeMillis = SystemClock.uptimeMillis();
            clearLoadedBitmap();
        }

        @Override
        protected Bitmap doInBackground(Void... arg) {
            Bitmap art = null;
            if (mIconBitmap != null) {
                art = mIconBitmap;
            } else if (mIconUri != null) {
                InputStream stream = null;
                try {
                    if ((stream = openInputStreamByScheme(mIconUri)) == null) {
                        Log.w(TAG, "Unable to open: " + mIconUri);
                        return null;
                    }
                    // Query art size.
                    BitmapFactory.Options options = new BitmapFactory.Options();
                    options.inJustDecodeBounds = true;
                    BitmapFactory.decodeStream(stream, null, options);
                    if (options.outWidth == 0 || options.outHeight == 0) {
                        return null;
                    }
                    // Rewind the stream in order to restart art decoding.
                    try {
                        stream.reset();
                    } catch (IOException e) {
                        // Failed to rewind the stream, try to reopen it.
                        stream.close();
                        if ((stream = openInputStreamByScheme(mIconUri)) == null) {
                            Log.w(TAG, "Unable to open: " + mIconUri);
                            return null;
                        }
                    }
                    // Calculate required size to decode the art and possibly resize it.
                    options.inJustDecodeBounds = false;
                    int reqHeight = getDesiredArtHeight(options.outWidth, options.outHeight);
                    int ratio = options.outHeight / reqHeight;
                    options.inSampleSize = Math.max(1, Integer.highestOneBit(ratio));
                    if (isCancelled()) {
                        return null;
                    }
                    art = BitmapFactory.decodeStream(stream, null, options);
                } catch (IOException e){
                    Log.w(TAG, "Unable to open: " + mIconUri, e);
                } finally {
                    if (stream != null) {
                        try {
                            stream.close();
                        } catch (IOException e) {
                        }
                    }
                }
            }
            if (isBitmapRecycled(art)) {
                Log.w(TAG, "Can't use recycled bitmap: " + art);
                return null;
            }
            if (art != null && art.getWidth() < art.getHeight()) {
                // Portrait art requires dominant color as background color.
                Palette palette = new Palette.Builder(art).maximumColorCount(1).generate();
                mBackgroundColor = palette.getSwatches().isEmpty()
                        ? 0 : palette.getSwatches().get(0).getRgb();
            }
            return art;
        }

        @Override
        protected void onPostExecute(Bitmap art) {
            mFetchArtTask = null;
            if (!ObjectsCompat.equals(mArtIconBitmap, mIconBitmap)
                    || !ObjectsCompat.equals(mArtIconUri, mIconUri)) {
                mArtIconBitmap = mIconBitmap;
                mArtIconLoadedBitmap = art;
                mArtIconUri = mIconUri;
                mArtIconBackgroundColor = mBackgroundColor;
                mArtIconIsLoaded = true;
                long elapsedTimeMillis = SystemClock.uptimeMillis() - mStartTimeMillis;
                // Loaded bitmap will be applied on the next update
                update(elapsedTimeMillis > SHOW_ANIM_TIME_THRESHOLD_MILLIS);
            }
        }

        private InputStream openInputStreamByScheme(Uri uri) throws IOException {
            String scheme = uri.getScheme().toLowerCase();
            InputStream stream = null;
            if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)
                    || ContentResolver.SCHEME_CONTENT.equals(scheme)
                    || ContentResolver.SCHEME_FILE.equals(scheme)) {
                stream = mContext.getContentResolver().openInputStream(uri);
            } else {
                URL url = new URL(uri.toString());
                URLConnection conn = url.openConnection();
                conn.setConnectTimeout(CONNECTION_TIMEOUT_MILLIS);
                conn.setReadTimeout(CONNECTION_TIMEOUT_MILLIS);
                stream = conn.getInputStream();
            }
            return (stream == null) ? null : new BufferedInputStream(stream);
        }
    }
}