1 /* 2 * Copyright 2018 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; 18 19 import android.annotation.Nullable; 20 import android.content.Context; 21 import android.content.res.ColorStateList; 22 import android.graphics.Bitmap; 23 import android.graphics.Rect; 24 import android.content.res.Resources; 25 import android.content.res.TypedArray; 26 import android.os.Bundle; 27 import android.util.Log; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.widget.ImageView; 32 import android.widget.SeekBar; 33 import android.widget.TextView; 34 35 import androidx.annotation.NonNull; 36 import androidx.constraintlayout.widget.ConstraintLayout; 37 import androidx.fragment.app.Fragment; 38 import androidx.recyclerview.widget.DefaultItemAnimator; 39 import androidx.recyclerview.widget.LinearLayoutManager; 40 import androidx.recyclerview.widget.RecyclerView; 41 42 import com.android.car.apps.common.BackgroundImageView; 43 import com.android.car.apps.common.util.ViewUtils; 44 import com.android.car.media.common.MediaAppSelectorWidget; 45 import com.android.car.media.common.MediaItemMetadata; 46 import com.android.car.media.common.MetadataController; 47 import com.android.car.media.common.PlaybackControlsActionBar; 48 import com.android.car.media.common.playback.PlaybackViewModel; 49 50 import java.util.ArrayList; 51 import java.util.Collections; 52 import java.util.List; 53 import java.util.Objects; 54 import java.util.concurrent.CompletableFuture; 55 56 /** 57 * A {@link Fragment} that implements both the playback and the content forward browsing experience. 58 * It observes a {@link PlaybackViewModel} and updates its information depending on the currently 59 * playing media source through the {@link android.media.session.MediaSession} API. 60 */ 61 public class PlaybackFragment extends Fragment { 62 private static final String TAG = "PlaybackFragment"; 63 64 private CompletableFuture<Bitmap> mFutureAlbumBackground; 65 private BackgroundImageView mAlbumBackground; 66 private View mBackgroundScrim; 67 private View mControlBarScrim; 68 private PlaybackControlsActionBar mPlaybackControls; 69 private QueueItemsAdapter mQueueAdapter; 70 private RecyclerView mQueue; 71 private ConstraintLayout mMetadataContainer; 72 private SeekBar mSeekBar; 73 private View mQueueButton; 74 private ViewGroup mNavIconContainer; 75 private List<View> mViewsToHideForCustomActions; 76 77 private DefaultItemAnimator mItemAnimator; 78 79 private MetadataController mMetadataController; 80 81 private PlaybackFragmentListener mListener; 82 83 private PlaybackViewModel.PlaybackController mController; 84 private Long mActiveQueueItemId; 85 86 private boolean mHasQueue; 87 private boolean mQueueIsVisible; 88 private boolean mShowTimeForActiveQueueItem; 89 private boolean mShowIconForActiveQueueItem; 90 private boolean mShowThumbnailForQueueItem; 91 92 private int mFadeDuration; 93 private float mPlaybackQueueBackgroundAlpha; 94 95 /** 96 * PlaybackFragment listener 97 */ 98 public interface PlaybackFragmentListener { 99 /** 100 * Invoked when the user clicks on the collapse button 101 */ onCollapse()102 void onCollapse(); 103 } 104 105 public class QueueViewHolder extends RecyclerView.ViewHolder { 106 107 private final View mView; 108 private final ViewGroup mThumbnailContainer; 109 private final ImageView mThumbnail; 110 private final View mSpacer; 111 private final TextView mTitle; 112 private final TextView mCurrentTime; 113 private final TextView mMaxTime; 114 private final TextView mTimeSeparator; 115 private final ImageView mActiveIcon; 116 QueueViewHolder(View itemView)117 QueueViewHolder(View itemView) { 118 super(itemView); 119 mView = itemView; 120 mThumbnailContainer = itemView.findViewById(R.id.thumbnail_container); 121 mThumbnail = itemView.findViewById(R.id.thumbnail); 122 mSpacer = itemView.findViewById(R.id.spacer); 123 mTitle = itemView.findViewById(R.id.title); 124 mCurrentTime = itemView.findViewById(R.id.current_time); 125 mMaxTime = itemView.findViewById(R.id.max_time); 126 mTimeSeparator = itemView.findViewById(R.id.separator); 127 mActiveIcon = itemView.findViewById(R.id.now_playing_icon); 128 } 129 bind(MediaItemMetadata item)130 boolean bind(MediaItemMetadata item) { 131 mView.setOnClickListener(v -> onQueueItemClicked(item)); 132 133 ViewUtils.setVisible(mThumbnailContainer, mShowThumbnailForQueueItem); 134 if (mShowThumbnailForQueueItem) { 135 MediaItemMetadata.updateImageView(mThumbnail.getContext(), item, mThumbnail, 0, 136 true); 137 } 138 139 ViewUtils.setVisible(mSpacer, !mShowThumbnailForQueueItem); 140 141 mTitle.setText(item.getTitle()); 142 143 boolean active = mActiveQueueItemId != null && Objects.equals(mActiveQueueItemId, 144 item.getQueueId()); 145 if (active) { 146 mCurrentTime.setText(mQueueAdapter.getCurrentTime()); 147 mMaxTime.setText(mQueueAdapter.getMaxTime()); 148 } 149 boolean shouldShowTime = 150 mShowTimeForActiveQueueItem && active && mQueueAdapter.getTimeVisible(); 151 ViewUtils.setVisible(mCurrentTime, shouldShowTime); 152 ViewUtils.setVisible(mMaxTime, shouldShowTime); 153 ViewUtils.setVisible(mTimeSeparator, shouldShowTime); 154 155 boolean shouldShowIcon = mShowIconForActiveQueueItem && active; 156 ViewUtils.setVisible(mActiveIcon, shouldShowIcon); 157 158 return active; 159 } 160 } 161 162 163 private class QueueItemsAdapter extends RecyclerView.Adapter<QueueViewHolder> { 164 165 private List<MediaItemMetadata> mQueueItems; 166 private String mCurrentTimeText; 167 private String mMaxTimeText; 168 private Integer mActiveItemPos; 169 private boolean mTimeVisible; 170 setItems(@ullable List<MediaItemMetadata> items)171 void setItems(@Nullable List<MediaItemMetadata> items) { 172 mQueueItems = new ArrayList<>(items != null ? items : Collections.emptyList()); 173 notifyDataSetChanged(); 174 } 175 setCurrentTime(String currentTime)176 void setCurrentTime(String currentTime) { 177 mCurrentTimeText = currentTime; 178 if (mActiveItemPos != null) { 179 notifyItemChanged(mActiveItemPos.intValue()); 180 } 181 } 182 setMaxTime(String maxTime)183 void setMaxTime(String maxTime) { 184 mMaxTimeText = maxTime; 185 if (mActiveItemPos != null) { 186 notifyItemChanged(mActiveItemPos.intValue()); 187 } 188 } 189 setTimeVisible(boolean visible)190 void setTimeVisible(boolean visible) { 191 mTimeVisible = visible; 192 if (mActiveItemPos != null) { 193 notifyItemChanged(mActiveItemPos.intValue()); 194 } 195 } 196 getCurrentTime()197 String getCurrentTime() { 198 return mCurrentTimeText; 199 } 200 getMaxTime()201 String getMaxTime() { 202 return mMaxTimeText; 203 } 204 getTimeVisible()205 boolean getTimeVisible() { 206 return mTimeVisible; 207 } 208 209 @Override onCreateViewHolder(ViewGroup parent, int viewType)210 public QueueViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 211 LayoutInflater inflater = LayoutInflater.from(parent.getContext()); 212 return new QueueViewHolder(inflater.inflate(R.layout.queue_list_item, parent, false)); 213 } 214 215 @Override onBindViewHolder(QueueViewHolder holder, int position)216 public void onBindViewHolder(QueueViewHolder holder, int position) { 217 int size = mQueueItems.size(); 218 if (0 <= position && position < size) { 219 boolean active = holder.bind(mQueueItems.get(position)); 220 if (active) { 221 mActiveItemPos = position; 222 } 223 } else { 224 Log.e(TAG, "onBindViewHolder invalid position " + position + " of " + size); 225 } 226 } 227 228 @Override getItemCount()229 public int getItemCount() { 230 return mQueueItems.size(); 231 } 232 refresh()233 void refresh() { 234 // TODO: Perform a diff between current and new content and trigger the proper 235 // RecyclerView updates. 236 this.notifyDataSetChanged(); 237 } 238 239 @Override getItemId(int position)240 public long getItemId(int position) { 241 return mQueueItems.get(position).getQueueId(); 242 } 243 } 244 245 private class QueueTopItemDecoration extends RecyclerView.ItemDecoration { 246 int mHeight; 247 int mDecorationPosition; 248 QueueTopItemDecoration(int height, int decorationPosition)249 QueueTopItemDecoration(int height, int decorationPosition) { 250 mHeight = height; 251 mDecorationPosition = decorationPosition; 252 } 253 254 @Override getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)255 public void getItemOffsets(Rect outRect, View view, RecyclerView parent, 256 RecyclerView.State state) { 257 super.getItemOffsets(outRect, view, parent, state); 258 if (parent.getChildAdapterPosition(view) == mDecorationPosition) { 259 outRect.top = mHeight; 260 } 261 } 262 } 263 264 @Override onCreateView(@onNull LayoutInflater inflater, final ViewGroup container, Bundle savedInstanceState)265 public View onCreateView(@NonNull LayoutInflater inflater, final ViewGroup container, 266 Bundle savedInstanceState) { 267 View view = inflater.inflate(R.layout.fragment_playback, container, false); 268 mAlbumBackground = view.findViewById(R.id.playback_background); 269 mQueue = view.findViewById(R.id.queue_list); 270 mMetadataContainer = view.findViewById(R.id.metadata_container); 271 mSeekBar = view.findViewById(R.id.seek_bar); 272 mQueueButton = view.findViewById(R.id.queue_button); 273 mQueueButton.setOnClickListener(button -> toggleQueueVisibility()); 274 mNavIconContainer = view.findViewById(R.id.nav_icon_container); 275 mNavIconContainer.setOnClickListener(nav -> onCollapse()); 276 mBackgroundScrim = view.findViewById(R.id.background_scrim); 277 ViewUtils.setVisible(mBackgroundScrim, false); 278 mControlBarScrim = view.findViewById(R.id.control_bar_scrim); 279 ViewUtils.setVisible(mControlBarScrim, false); 280 mControlBarScrim.setOnClickListener(scrim -> mPlaybackControls.close()); 281 mControlBarScrim.setClickable(false); 282 283 Resources res = getResources(); 284 mShowTimeForActiveQueueItem = res.getBoolean( 285 R.bool.show_time_for_now_playing_queue_list_item); 286 mShowIconForActiveQueueItem = res.getBoolean( 287 R.bool.show_icon_for_now_playing_queue_list_item); 288 mShowThumbnailForQueueItem = getContext().getResources().getBoolean( 289 R.bool.show_thumbnail_for_queue_list_item); 290 291 boolean useMediaSourceColor = res.getBoolean( 292 R.bool.use_media_source_color_for_progress_bar); 293 int defaultColor = res.getColor(R.color.progress_bar_highlight, null); 294 if (useMediaSourceColor) { 295 getPlaybackViewModel().getMediaSourceColors().observe(getViewLifecycleOwner(), 296 sourceColors -> { 297 int color = sourceColors != null ? sourceColors.getAccentColor(defaultColor) 298 : defaultColor; 299 mSeekBar.setThumbTintList(ColorStateList.valueOf(color)); 300 mSeekBar.setProgressTintList(ColorStateList.valueOf(color)); 301 }); 302 } else { 303 mSeekBar.setThumbTintList(ColorStateList.valueOf(defaultColor)); 304 mSeekBar.setProgressTintList(ColorStateList.valueOf(defaultColor)); 305 } 306 307 MediaAppSelectorWidget appIcon = view.findViewById(R.id.app_icon_container); 308 appIcon.setFragmentActivity(getActivity()); 309 310 getPlaybackViewModel().getPlaybackController().observe(getViewLifecycleOwner(), 311 controller -> mController = controller); 312 initPlaybackControls(view.findViewById(R.id.playback_controls)); 313 initMetadataController(view); 314 initQueue(); 315 316 TypedArray hideViewIds = 317 res.obtainTypedArray(R.array.playback_views_to_hide_when_showing_custom_actions); 318 mViewsToHideForCustomActions = new ArrayList<>(hideViewIds.length()); 319 for (int i = 0; i < hideViewIds.length(); i++) { 320 int viewId = hideViewIds.getResourceId(i, 0); 321 if (viewId != 0) { 322 View viewToHide = view.findViewById(viewId); 323 if (viewToHide != null) { 324 mViewsToHideForCustomActions.add(viewToHide); 325 } 326 } 327 } 328 hideViewIds.recycle(); 329 330 int albumBgSizePx = getResources().getInteger( 331 com.android.car.apps.common.R.integer.background_bitmap_target_size_px); 332 333 getPlaybackViewModel().getMetadata().observe(getViewLifecycleOwner(), 334 metadata -> { 335 if (mFutureAlbumBackground != null && !mFutureAlbumBackground.isDone()) { 336 mFutureAlbumBackground.cancel(true); 337 } 338 if (metadata == null) { 339 setBackgroundImage(null); 340 mFutureAlbumBackground = null; 341 } else { 342 mFutureAlbumBackground = metadata.getAlbumArt( 343 getContext(), albumBgSizePx, albumBgSizePx, false); 344 mFutureAlbumBackground.whenComplete((result, throwable) -> { 345 if (throwable != null) { 346 setBackgroundImage(null); 347 } else { 348 setBackgroundImage(result); 349 } 350 }); 351 } 352 }); 353 354 return view; 355 } 356 357 @Override onAttach(Context context)358 public void onAttach(Context context) { 359 super.onAttach(context); 360 } 361 362 @Override onDetach()363 public void onDetach() { 364 super.onDetach(); 365 } 366 setBackgroundImage(Bitmap bitmap)367 private void setBackgroundImage(Bitmap bitmap) { 368 mAlbumBackground.setBackgroundImage(bitmap, bitmap != null); 369 } 370 initPlaybackControls(PlaybackControlsActionBar playbackControls)371 private void initPlaybackControls(PlaybackControlsActionBar playbackControls) { 372 mPlaybackControls = playbackControls; 373 mPlaybackControls.setModel(getPlaybackViewModel(), getViewLifecycleOwner()); 374 mPlaybackControls.registerExpandCollapseCallback((expanding) -> { 375 mControlBarScrim.setClickable(expanding); 376 377 Resources res = getContext().getResources(); 378 int millis = expanding ? res.getInteger(R.integer.control_bar_expand_anim_duration) : 379 res.getInteger(R.integer.control_bar_collapse_anim_duration); 380 381 if (expanding) { 382 ViewUtils.showViewAnimated(mControlBarScrim, millis); 383 } else { 384 ViewUtils.hideViewAnimated(mControlBarScrim, millis); 385 } 386 387 if (!mQueueIsVisible) { 388 for (View view : mViewsToHideForCustomActions) { 389 if (expanding) { 390 ViewUtils.hideViewAnimated(view, millis); 391 } else { 392 ViewUtils.showViewAnimated(view, millis); 393 } 394 } 395 } 396 }); 397 } 398 initQueue()399 private void initQueue() { 400 mFadeDuration = getResources().getInteger( 401 R.integer.fragment_playback_queue_fade_duration_ms); 402 mPlaybackQueueBackgroundAlpha = getResources().getFloat( 403 R.dimen.playback_queue_background_alpha); 404 405 int decorationHeight = getResources().getDimensionPixelSize( 406 R.dimen.playback_queue_list_padding_top); 407 // Put the decoration above the first item. 408 int decorationPosition = 0; 409 mQueue.addItemDecoration(new QueueTopItemDecoration(decorationHeight, decorationPosition)); 410 411 mQueue.setVerticalFadingEdgeEnabled( 412 getResources().getBoolean(R.bool.queue_fading_edge_length_enabled)); 413 mQueueAdapter = new QueueItemsAdapter(); 414 415 getPlaybackViewModel().getPlaybackStateWrapper().observe(getViewLifecycleOwner(), 416 state -> { 417 Long itemId = (state != null) ? state.getActiveQueueItemId() : null; 418 if (!Objects.equals(mActiveQueueItemId, itemId)) { 419 mActiveQueueItemId = itemId; 420 mQueueAdapter.refresh(); 421 } 422 }); 423 mQueue.setAdapter(mQueueAdapter); 424 mQueue.setLayoutManager(new LinearLayoutManager(getContext())); 425 426 // Disable item changed animation. 427 mItemAnimator = new DefaultItemAnimator(); 428 mItemAnimator.setSupportsChangeAnimations(false); 429 mQueue.setItemAnimator(mItemAnimator); 430 431 getPlaybackViewModel().getQueue().observe(this, this::setQueue); 432 433 getPlaybackViewModel().hasQueue().observe(getViewLifecycleOwner(), hasQueue -> { 434 boolean enableQueue = (hasQueue != null) && hasQueue; 435 setHasQueue(enableQueue); 436 if (mQueueIsVisible && !enableQueue) { 437 toggleQueueVisibility(); 438 } 439 }); 440 getPlaybackViewModel().getProgress().observe(getViewLifecycleOwner(), 441 playbackProgress -> 442 { 443 mQueueAdapter.setCurrentTime(playbackProgress.getCurrentTimeText().toString()); 444 mQueueAdapter.setMaxTime(playbackProgress.getMaxTimeText().toString()); 445 mQueueAdapter.setTimeVisible(playbackProgress.hasTime()); 446 }); 447 } 448 setQueue(List<MediaItemMetadata> queueItems)449 private void setQueue(List<MediaItemMetadata> queueItems) { 450 mQueueAdapter.setItems(queueItems); 451 mQueueAdapter.refresh(); 452 } 453 initMetadataController(View view)454 private void initMetadataController(View view) { 455 ImageView albumArt = view.findViewById(R.id.album_art); 456 TextView title = view.findViewById(R.id.title); 457 TextView artist = view.findViewById(R.id.artist); 458 TextView albumTitle = view.findViewById(R.id.album_title); 459 TextView outerSeparator = view.findViewById(R.id.outer_separator); 460 TextView curTime = view.findViewById(R.id.current_time); 461 TextView innerSeparator = view.findViewById(R.id.inner_separator); 462 TextView maxTime = view.findViewById(R.id.max_time); 463 SeekBar seekbar = view.findViewById(R.id.seek_bar); 464 465 mMetadataController = new MetadataController(getViewLifecycleOwner(), 466 getPlaybackViewModel(), title, artist, albumTitle, outerSeparator, 467 curTime, innerSeparator, maxTime, seekbar, albumArt, 468 getResources().getDimensionPixelSize(R.dimen.playback_album_art_size)); 469 } 470 471 /** 472 * Hides or shows the playback queue. 473 */ toggleQueueVisibility()474 private void toggleQueueVisibility() { 475 mQueueIsVisible = !mQueueIsVisible; 476 mQueueButton.setActivated(mQueueIsVisible); 477 mQueueButton.setSelected(mQueueIsVisible); 478 if (mQueueIsVisible) { 479 ViewUtils.hideViewAnimated(mMetadataContainer, mFadeDuration); 480 ViewUtils.hideViewAnimated(mSeekBar, mFadeDuration); 481 ViewUtils.showViewAnimated(mQueue, mFadeDuration); 482 ViewUtils.showViewAnimated(mBackgroundScrim, mFadeDuration); 483 } else { 484 ViewUtils.hideViewAnimated(mQueue, mFadeDuration); 485 ViewUtils.showViewAnimated(mMetadataContainer, mFadeDuration); 486 ViewUtils.showViewAnimated(mSeekBar, mFadeDuration); 487 ViewUtils.hideViewAnimated(mBackgroundScrim, mFadeDuration); 488 } 489 } 490 491 /** Sets whether the source has a queue. */ setHasQueue(boolean hasQueue)492 private void setHasQueue(boolean hasQueue) { 493 mHasQueue = hasQueue; 494 updateQueueVisibility(); 495 } 496 updateQueueVisibility()497 private void updateQueueVisibility() { 498 mQueueButton.setVisibility(mHasQueue ? View.VISIBLE : View.GONE); 499 } 500 onQueueItemClicked(MediaItemMetadata item)501 private void onQueueItemClicked(MediaItemMetadata item) { 502 if (mController != null) { 503 mController.skipToQueueItem(item.getQueueId()); 504 } 505 boolean switchToPlayback = getResources().getBoolean( 506 R.bool.switch_to_playback_view_when_playable_item_is_clicked); 507 if (switchToPlayback) { 508 toggleQueueVisibility(); 509 } 510 } 511 512 /** 513 * Collapses the playback controls. 514 */ closeOverflowMenu()515 public void closeOverflowMenu() { 516 mPlaybackControls.close(); 517 } 518 getPlaybackViewModel()519 private PlaybackViewModel getPlaybackViewModel() { 520 return PlaybackViewModel.get(getActivity().getApplication()); 521 } 522 523 /** 524 * Sets a listener of this PlaybackFragment events. In order to avoid memory leaks, consumers 525 * must reset this reference by setting the listener to null. 526 */ setListener(PlaybackFragmentListener listener)527 public void setListener(PlaybackFragmentListener listener) { 528 mListener = listener; 529 } 530 onCollapse()531 private void onCollapse() { 532 if (mListener != null) { 533 mListener.onCollapse(); 534 } 535 } 536 } 537