1 /*
2  * Copyright 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.media.common;
18 
19 import android.content.Context;
20 import android.content.res.ColorStateList;
21 import android.graphics.PorterDuff;
22 import android.graphics.drawable.Drawable;
23 import android.graphics.drawable.VectorDrawable;
24 import android.util.Log;
25 import android.view.LayoutInflater;
26 import android.view.View;
27 import android.widget.ImageButton;
28 import android.widget.ProgressBar;
29 
30 import androidx.annotation.ColorRes;
31 import androidx.annotation.DrawableRes;
32 import androidx.annotation.LayoutRes;
33 import androidx.annotation.NonNull;
34 import androidx.annotation.Nullable;
35 import androidx.lifecycle.LifecycleOwner;
36 
37 import com.android.car.apps.common.CarControlBar;
38 import com.android.car.apps.common.CommonFlags;
39 import com.android.car.apps.common.ControlBar;
40 import com.android.car.media.common.playback.PlaybackViewModel;
41 import com.android.car.media.common.source.MediaSourceColors;
42 
43 import java.util.ArrayList;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.stream.Collectors;
48 
49 /**
50  * This class manages the media control buttons that are added to a CarControlBar.
51  *
52  * It expects the icons to be {@link VectorDrawable}s (because they look better), and will flag
53  * non compliant ones if {@link MediaItemMetadata#flagInvalidMediaArt} returns true.
54  */
55 public class MediaButtonController {
56 
57     private static final String TAG = "MediaButton";
58 
59     private final Map<String, ImageButton> mImageButtons = new HashMap<>();
60 
61     private Context mContext;
62     private PlayPauseStopImageView mPlayPauseStopImageView;
63     private View mPlayPauseStopImageContainer;
64     private ProgressBar mCircularProgressBar;
65     private ImageButton mSkipPrevButton;
66     private ImageButton mSkipNextButton;
67     private ColorStateList mIconsColor;
68     private boolean mSkipNextAdded;
69     private boolean mSkipPrevAdded;
70     private boolean mShowCircularProgressBar;
71 
72     private PlaybackViewModel mModel;
73     private PlaybackViewModel.PlaybackController mController;
74 
75     private CarControlBar mControlBar;
76 
MediaButtonController(Context context, CarControlBar controlBar, @ColorRes int iconColorsId, @LayoutRes int playPauseContainerId, @DrawableRes int skipPrevButtonId, @DrawableRes int skipNextButtonId)77     public MediaButtonController(Context context, CarControlBar controlBar,
78             @ColorRes int iconColorsId, @LayoutRes int playPauseContainerId,
79             @DrawableRes int skipPrevButtonId, @DrawableRes int skipNextButtonId) {
80         mContext = context;
81         mControlBar = controlBar;
82         mPlayPauseStopImageContainer =
83                 LayoutInflater.from(context).inflate(playPauseContainerId, null);
84         mPlayPauseStopImageContainer.setOnClickListener(this::onPlayPauseStopClicked);
85         mPlayPauseStopImageView = mPlayPauseStopImageContainer.findViewById(R.id.play_pause_stop);
86         mPlayPauseStopImageView.setVisibility(View.INVISIBLE);
87         mCircularProgressBar = mPlayPauseStopImageContainer.findViewById(
88                 R.id.circular_progress_bar);
89         mPlayPauseStopImageView.setAction(PlayPauseStopImageView.ACTION_DISABLED);
90         mPlayPauseStopImageView.setOnClickListener(this::onPlayPauseStopClicked);
91         // In non-touch mode, a browse list will request focus explicitly and its first element
92         // will get focused instead of this button
93         mPlayPauseStopImageView.setFocusedByDefault(true);
94 
95         mShowCircularProgressBar = context.getResources().getBoolean(
96                 R.bool.show_circular_progress_bar);
97         mIconsColor = context.getResources().getColorStateList(iconColorsId, null);
98 
99         mSkipPrevButton = createIconButton(context.getDrawable(skipPrevButtonId));
100         mSkipPrevButton.setId(R.id.skip_prev);
101         mSkipPrevButton.setVisibility(View.VISIBLE);
102         mSkipPrevButton.setOnClickListener(this::onPrevClicked);
103 
104         mSkipNextButton = createIconButton(context.getDrawable(skipNextButtonId));
105         mSkipNextButton.setId(R.id.skip_next);
106         mSkipNextButton.setVisibility(View.VISIBLE);
107         mSkipNextButton.setOnClickListener(this::onNextClicked);
108 
109         resetInitialViews();
110     }
111 
112     /**
113      * Set the model that the available media buttons will be provided from
114      */
setModel(@onNull PlaybackViewModel model, @NonNull LifecycleOwner owner)115     public void setModel(@NonNull PlaybackViewModel model, @NonNull LifecycleOwner owner) {
116         if (mModel != null) {
117             Log.w(TAG, "PlaybackViewModel set more than once. Ignoring subsequent call.");
118         }
119         mModel = model;
120 
121         model.getPlaybackController().observe(owner, controller -> {
122             if (mController != controller) {
123                 mController = controller;
124                 resetInitialViews();
125             }
126         });
127         mPlayPauseStopImageView.setVisibility(View.VISIBLE);
128         boolean useMediaSourceColor =
129                 mContext.getResources().getBoolean(R.bool.use_media_source_color_for_fab_spinner);
130         if (useMediaSourceColor) {
131             model.getMediaSourceColors().observe(owner, this::updateSpinerColors);
132         }
133         model.getPlaybackStateWrapper().observe(owner, this::onPlaybackStateChanged);
134     }
135 
resetInitialViews()136     private void resetInitialViews() {
137         mControlBar.setViews(new View[0]);
138         mControlBar.setView(mPlayPauseStopImageContainer, ControlBar.SLOT_MAIN);
139         mControlBar.setView(null, ControlBar.SLOT_LEFT);
140         mControlBar.setView(null, ControlBar.SLOT_RIGHT);
141         mSkipNextAdded = false;
142         mSkipPrevAdded = false;
143         mImageButtons.clear();
144     }
145 
createIconButton(Drawable icon)146     private ImageButton createIconButton(Drawable icon) {
147         ImageButton button = mControlBar.createIconButton(icon);
148         boolean flagInvalidArt = CommonFlags.getInstance(mContext).shouldFlagImproperImageRefs();
149         if (flagInvalidArt && !(icon instanceof VectorDrawable)) {
150             button.setImageTintList(
151                     ColorStateList.valueOf(MediaItemMetadata.INVALID_MEDIA_ART_TINT_COLOR));
152         } else {
153             button.setImageTintList(mIconsColor);
154         }
155         button.setImageTintMode(PorterDuff.Mode.SRC_ATOP);
156         return button;
157     }
158 
onPlaybackStateChanged(@ullable PlaybackViewModel.PlaybackStateWrapper state)159     private void onPlaybackStateChanged(@Nullable PlaybackViewModel.PlaybackStateWrapper state) {
160 
161         boolean hasState = (state != null);
162         mPlayPauseStopImageView.setAction(convertMainAction(state));
163         boolean isLoading = hasState && state.isLoading();
164         mCircularProgressBar.setVisibility(
165                 isLoading || mShowCircularProgressBar ? View.VISIBLE : View.INVISIBLE);
166         mCircularProgressBar.setIndeterminate(isLoading);
167 
168         // If prev/next is reserved, but not enabled, the icon is displayed as disabled (inactive
169         // or grayed out). For example some apps only allow a certain number of skips in a given
170         // time.
171 
172         boolean skipPreviousReserved = hasState && state.iSkipPreviousReserved();
173         boolean skipPreviousEnabled = hasState && state.isSkipPreviousEnabled();
174 
175         if (skipPreviousReserved || skipPreviousEnabled) {
176             if (!mSkipPrevAdded) {
177                 mControlBar.setView(mSkipPrevButton, ControlBar.SLOT_LEFT);
178                 mSkipPrevAdded = true;
179             }
180         } else {
181             mControlBar.setView(null, ControlBar.SLOT_LEFT);
182             mSkipPrevAdded = false;
183         }
184         mSkipPrevButton.setEnabled(skipPreviousEnabled);
185 
186         boolean skipNextReserved = hasState && state.isSkipNextReserved();
187         boolean skipNextEnabled = hasState && state.isSkipNextEnabled();
188 
189         if (skipNextReserved || skipNextEnabled) {
190             if (!mSkipNextAdded) {
191                 mControlBar.setView(mSkipNextButton, ControlBar.SLOT_RIGHT);
192                 mSkipNextAdded = true;
193             }
194         } else {
195             mControlBar.setView(null, ControlBar.SLOT_RIGHT);
196             mSkipNextAdded = false;
197         }
198         mSkipNextButton.setEnabled(skipNextEnabled);
199 
200         updateCustomActions(state);
201     }
202 
203     @PlayPauseStopImageView.Action
convertMainAction(@ullable PlaybackViewModel.PlaybackStateWrapper state)204     private int convertMainAction(@Nullable PlaybackViewModel.PlaybackStateWrapper state) {
205         @PlaybackViewModel.Action int action =
206                 (state != null) ? state.getMainAction() : PlaybackViewModel.ACTION_DISABLED;
207         switch (action) {
208             case PlaybackViewModel.ACTION_DISABLED:
209                 return PlayPauseStopImageView.ACTION_DISABLED;
210             case PlaybackViewModel.ACTION_PLAY:
211                 return PlayPauseStopImageView.ACTION_PLAY;
212             case PlaybackViewModel.ACTION_PAUSE:
213                 return PlayPauseStopImageView.ACTION_PAUSE;
214             case PlaybackViewModel.ACTION_STOP:
215                 return PlayPauseStopImageView.ACTION_STOP;
216         }
217         Log.w(TAG, "Unknown action: " + action);
218         return PlayPauseStopImageView.ACTION_DISABLED;
219     }
220 
updateSpinerColors(MediaSourceColors colors)221     private void updateSpinerColors(MediaSourceColors colors) {
222         int color = getMediaSourceColor(colors);
223         mCircularProgressBar.setIndeterminateTintList(ColorStateList.valueOf(color));
224     }
225 
getMediaSourceColor(@ullable MediaSourceColors colors)226     private int getMediaSourceColor(@Nullable MediaSourceColors colors) {
227         int defaultColor = mContext.getResources().getColor(R.color.media_source_default_color,
228                 null);
229         return colors != null ? colors.getAccentColor(defaultColor) : defaultColor;
230     }
231 
updateCustomActions(@ullable PlaybackViewModel.PlaybackStateWrapper state)232     private void updateCustomActions(@Nullable PlaybackViewModel.PlaybackStateWrapper state) {
233         int focusedViewIndex = mControlBar.getFocusedViewIndex();
234 
235         List<ImageButton> imageButtons = new ArrayList<>();
236         if (state != null) {
237             imageButtons.addAll(state.getCustomActions()
238                     .stream()
239                     .map(rawAction -> rawAction.fetchDrawable(mContext))
240                     .map(action -> getOrCreateIconButton(action))
241                     .collect(Collectors.toList()));
242         }
243         if (!mSkipPrevAdded && !imageButtons.isEmpty()) {
244             mControlBar.setView(imageButtons.remove(0), CarControlBar.SLOT_LEFT);
245         }
246         if (!mSkipNextAdded && !imageButtons.isEmpty()) {
247             mControlBar.setView(imageButtons.remove(0), CarControlBar.SLOT_RIGHT);
248         }
249         mControlBar.setViews(imageButtons.toArray(new ImageButton[0]));
250 
251         mControlBar.setFocusAtViewIndex(focusedViewIndex);
252     }
253 
getOrCreateIconButton(CustomPlaybackAction action)254     private ImageButton getOrCreateIconButton(CustomPlaybackAction action) {
255         // Reuse the ImageButton with the same action identifier if it exists, because if the
256         // ImageButton is focused, replacing it with a new one will make it lose focus.
257         ImageButton button = mImageButtons.get(action.mAction);
258         if (button != null) {
259             button.setImageDrawable(action.mIcon);
260         } else {
261             button = createIconButton(action.mIcon);
262             mImageButtons.put(action.mAction, button);
263         }
264         button.setOnClickListener(view ->
265                 mController.doCustomAction(action.mAction, action.mExtras));
266         return button;
267     }
268 
onPlayPauseStopClicked(View view)269     private void onPlayPauseStopClicked(View view) {
270         if (mController == null) {
271             return;
272         }
273         switch (mPlayPauseStopImageView.getAction()) {
274             case PlayPauseStopImageView.ACTION_PLAY:
275                 mController.play();
276                 break;
277             case PlayPauseStopImageView.ACTION_PAUSE:
278                 mController.pause();
279                 break;
280             case PlayPauseStopImageView.ACTION_STOP:
281                 mController.stop();
282                 break;
283             default:
284                 Log.i(TAG, "Play/Pause/Stop clicked on invalid state");
285                 break;
286         }
287     }
288 
onNextClicked(View view)289     private void onNextClicked(View view) {
290         PlaybackViewModel.PlaybackStateWrapper state = getPlaybackState();
291         if ((mController != null) && (state != null) && (state.isSkipNextEnabled())) {
292             mController.skipToNext();
293         }
294     }
295 
onPrevClicked(View view)296     private void onPrevClicked(View view) {
297         PlaybackViewModel.PlaybackStateWrapper state = getPlaybackState();
298         if ((mController != null) && (state != null) && (state.isSkipPreviousEnabled())) {
299             mController.skipToPrevious();
300         }
301     }
302 
getPlaybackState()303     private PlaybackViewModel.PlaybackStateWrapper getPlaybackState() {
304         if (mModel != null) {
305             return mModel.getPlaybackStateWrapper().getValue();
306         }
307         return null;
308     }
309 
310 }
311