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.browse; 18 19 import android.content.Context; 20 import android.media.browse.MediaBrowser; 21 import android.os.Bundle; 22 import android.support.annotation.NonNull; 23 import android.support.annotation.Nullable; 24 import android.support.v7.util.DiffUtil; 25 import android.support.v7.widget.GridLayoutManager; 26 import android.support.v7.widget.RecyclerView; 27 import android.util.Log; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.ViewGroup; 31 32 import com.android.car.media.common.MediaItemMetadata; 33 import com.android.car.media.common.MediaSource; 34 35 import java.util.ArrayList; 36 import java.util.Collection; 37 import java.util.LinkedHashMap; 38 import java.util.List; 39 import java.util.Objects; 40 import java.util.function.Consumer; 41 import java.util.stream.Collectors; 42 43 import androidx.car.widget.PagedListView; 44 45 /** 46 * A {@link RecyclerView.Adapter} that can be used to display a single level of a 47 * {@link android.service.media.MediaBrowserService} media tree into a 48 * {@link androidx.car.widget.PagedListView} or any other {@link RecyclerView}. 49 * 50 * <p>This adapter assumes that the attached {@link RecyclerView} uses a {@link GridLayoutManager}, 51 * as it can use both grid and list elements to produce the desired representation. 52 * 53 * <p> The actual strategy to group and expand media items has to be supplied by providing an 54 * instance of {@link ContentForwardStrategy}. 55 * 56 * <p> The adapter will only start updating once {@link #start()} is invoked. At this point, the 57 * provided {@link MediaBrowser} must be already in connected state. 58 * 59 * <p>Resources and asynchronous data loading must be released by callign {@link #stop()}. 60 * 61 * <p>No views will be actually updated until {@link #update()} is invoked (normally as a result of 62 * the {@link Observer#onDirty()} event. This way, the consumer of this adapter has the opportunity 63 * to decide whether updates should be displayd immediately, or if they should be delayed to 64 * prevent flickering. 65 * 66 * <p>Consumers of this adapter should use {@link #registerObserver(Observer)} to receive updates. 67 */ 68 public class BrowseAdapter extends RecyclerView.Adapter<BrowseViewHolder> implements 69 PagedListView.DividerVisibilityManager { 70 private static final String TAG = "BrowseAdapter"; 71 @NonNull 72 private final Context mContext; 73 private final MediaSource mMediaSource; 74 private final ContentForwardStrategy mCFBStrategy; 75 private MediaItemMetadata mParentMediaItem; 76 private LinkedHashMap<String, MediaItemState> mItemStates = new LinkedHashMap<>(); 77 private List<BrowseViewData> mViewData = new ArrayList<>(); 78 private String mParentMediaItemId; 79 private List<Observer> mObservers = new ArrayList<>(); 80 private List<MediaItemMetadata> mQueue; 81 private CharSequence mQueueTitle; 82 private int mMaxSpanSize = 1; 83 private State mState = State.IDLE; 84 85 /** 86 * Possible states of the adapter 87 */ 88 public enum State { 89 /** Loading of this item hasn't started yet */ 90 IDLE, 91 /** There is pending information before this item can be displayed */ 92 LOADING, 93 /** It was not possible to load metadata for this item */ 94 ERROR, 95 /** Metadata for this items has been correctly loaded */ 96 LOADED 97 } 98 99 /** 100 * An {@link BrowseAdapter} observer. 101 */ 102 public static abstract class Observer { 103 /** 104 * Callback invoked anytime there is more information to be displayed, or if there is a 105 * change in the overall state of the adapter. 106 */ onDirty()107 protected void onDirty() {}; 108 109 /** 110 * Callback invoked when a user clicks on a playable item. 111 */ onPlayableItemClicked(MediaItemMetadata item)112 protected void onPlayableItemClicked(MediaItemMetadata item) {}; 113 114 /** 115 * Callback invoked when a user clicks on a browsable item. 116 */ onBrowseableItemClicked(MediaItemMetadata item)117 protected void onBrowseableItemClicked(MediaItemMetadata item) {}; 118 119 /** 120 * Callback invoked when a user clicks on a the "more items" button on a section. 121 */ onMoreButtonClicked(MediaItemMetadata item)122 protected void onMoreButtonClicked(MediaItemMetadata item) {}; 123 124 /** 125 * Callback invoked when the user clicks on the title of the queue. 126 */ onQueueTitleClicked()127 protected void onQueueTitleClicked() {}; 128 129 /** 130 * Callback invoked when the user clicks on a queue item. 131 */ onQueueItemClicked(MediaItemMetadata item)132 protected void onQueueItemClicked(MediaItemMetadata item) {}; 133 } 134 135 private MediaSource.ItemsSubscription mSubscriptionCallback = 136 (mediaSource, parentId, items) -> { 137 if (items != null) { 138 onItemsLoaded(parentId, items); 139 } else { 140 onLoadingError(parentId); 141 } 142 }; 143 144 145 /** 146 * Represents the loading state of children of a single {@link MediaItemMetadata} in the 147 * {@link BrowseAdapter} 148 */ 149 private class MediaItemState { 150 /** 151 * {@link com.android.car.media.common.MediaItemMetadata} whose children are being loaded 152 */ 153 final MediaItemMetadata mItem; 154 /** Current loading state for this item */ 155 State mState = State.LOADING; 156 /** Playable children of the given item */ 157 List<MediaItemMetadata> mPlayableChildren = new ArrayList<>(); 158 /** Browsable children of the given item */ 159 List<MediaItemMetadata> mBrowsableChildren = new ArrayList<>(); 160 /** Whether we are subscribed to updates for this item or not */ 161 boolean mIsSubscribed; 162 MediaItemState(MediaItemMetadata item)163 MediaItemState(MediaItemMetadata item) { 164 mItem = item; 165 } 166 setChildren(List<MediaItemMetadata> children)167 void setChildren(List<MediaItemMetadata> children) { 168 mPlayableChildren.clear(); 169 mBrowsableChildren.clear(); 170 for (MediaItemMetadata child : children) { 171 if (child.isBrowsable()) { 172 // Browsable items could also be playable 173 mBrowsableChildren.add(child); 174 } else if (child.isPlayable()) { 175 mPlayableChildren.add(child); 176 } 177 } 178 } 179 } 180 181 /** 182 * Creates a {@link BrowseAdapter} that displays the children of the given media tree node. 183 * 184 * @param mediaSource the {@link MediaSource} to get data from. 185 * @param parentItem the node to display children of, or NULL if the 186 * @param strategy a {@link ContentForwardStrategy} that would determine which items would be 187 * expanded and how. 188 */ BrowseAdapter(Context context, @NonNull MediaSource mediaSource, @Nullable MediaItemMetadata parentItem, @NonNull ContentForwardStrategy strategy)189 public BrowseAdapter(Context context, @NonNull MediaSource mediaSource, 190 @Nullable MediaItemMetadata parentItem, @NonNull ContentForwardStrategy strategy) { 191 mContext = context; 192 mMediaSource = mediaSource; 193 mParentMediaItem = parentItem; 194 mCFBStrategy = strategy; 195 } 196 197 /** 198 * Initiates or resumes the data loading process and subscribes to updates. The client can use 199 * {@link #registerObserver(Observer)} to receive updates on the progress. 200 */ start()201 public void start() { 202 mParentMediaItemId = mParentMediaItem != null ? mParentMediaItem.getId() : 203 mMediaSource.getRoot(); 204 mMediaSource.subscribeChildren(mParentMediaItemId, mSubscriptionCallback); 205 for (MediaItemState itemState : mItemStates.values()) { 206 subscribe(itemState); 207 } 208 } 209 210 /** 211 * Stops the data loading and releases any subscriptions. 212 */ stop()213 public void stop() { 214 if (mParentMediaItemId == null) { 215 // Not started 216 return; 217 } 218 mMediaSource.unsubscribeChildren(mParentMediaItemId, mSubscriptionCallback); 219 for (MediaItemState itemState : mItemStates.values()) { 220 unsubscribe(itemState); 221 } 222 mParentMediaItemId = null; 223 } 224 225 /** 226 * Replaces the media item whose children are being displayed in this adapter. The content of 227 * the adapter will be replaced once the children of the new item are loaded. 228 * 229 * @param parentItem new media item to expand. 230 */ setParentMediaItemId(@ullable MediaItemMetadata parentItem)231 public void setParentMediaItemId(@Nullable MediaItemMetadata parentItem) { 232 String newParentMediaItemId = parentItem != null ? parentItem.getId() : 233 mMediaSource.getRoot(); 234 if (Objects.equals(newParentMediaItemId, mParentMediaItemId)) { 235 return; 236 } 237 stop(); 238 mParentMediaItem = parentItem; 239 mParentMediaItemId = newParentMediaItemId; 240 mMediaSource.subscribeChildren(mParentMediaItemId, mSubscriptionCallback); 241 } 242 243 /** 244 * Sets media queue items into this adapter. 245 */ setQueue(List<MediaItemMetadata> items, CharSequence queueTitle)246 public void setQueue(List<MediaItemMetadata> items, CharSequence queueTitle) { 247 mQueue = items; 248 mQueueTitle = queueTitle; 249 notify(Observer::onDirty); 250 } 251 252 /** 253 * Registers an {@link Observer} 254 */ registerObserver(Observer observer)255 public void registerObserver(Observer observer) { 256 mObservers.add(observer); 257 } 258 259 /** 260 * Unregisters an {@link Observer} 261 */ unregisterObserver(Observer observer)262 public void unregisterObserver(Observer observer) { 263 mObservers.remove(observer); 264 } 265 266 /** 267 * @return the global loading state. Consumers can use this state to determine if more 268 * information is still pending to arrive or not. This method will report 269 * {@link State#ERROR} only if the list of immediate children fails to load. 270 */ getState()271 public State getState() { 272 return mState; 273 } 274 275 /** 276 * Sets the number of columns that items can take. This method only needs to be used if the 277 * attached {@link RecyclerView} is NOT using a {@link GridLayoutManager}. This class will 278 * automatically determine this value on {@link #onAttachedToRecyclerView(RecyclerView)} 279 * otherwise. 280 */ setMaxSpanSize(int maxSpanSize)281 public void setMaxSpanSize(int maxSpanSize) { 282 mMaxSpanSize = maxSpanSize; 283 } 284 285 /** 286 * @return a {@link GridLayoutManager.SpanSizeLookup} that can be used to obtain the span size 287 * of each item in this adapter. This method is only needed if the {@link RecyclerView} is NOT 288 * using a {@link GridLayoutManager}. This class will automatically use it on\ 289 * {@link #onAttachedToRecyclerView(RecyclerView)} otherwise. 290 */ getSpanSizeLookup()291 public GridLayoutManager.SpanSizeLookup getSpanSizeLookup() { 292 return new GridLayoutManager.SpanSizeLookup() { 293 @Override 294 public int getSpanSize(int position) { 295 BrowseItemViewType viewType = mViewData.get(position).mViewType; 296 return viewType.getSpanSize(mMaxSpanSize); 297 } 298 }; 299 } 300 301 /** 302 * Updates the {@link RecyclerView} with newly loaded information. This normally should be 303 * invoked as a result of a {@link Observer#onDirty()} callback. 304 * 305 * This method is idempotent and can be used at any time (even delayed if needed). Additions, 306 * removals and insertions would be notified to the {@link RecyclerView} so it can be 307 * animated appropriately. 308 */ 309 public void update() { 310 List<BrowseViewData> newItems = generateViewData(mItemStates.values()); 311 List<BrowseViewData> oldItems = mViewData; 312 mViewData = newItems; 313 DiffUtil.DiffResult result = DiffUtil.calculateDiff(createDiffUtil(oldItems, newItems)); 314 result.dispatchUpdatesTo(this); 315 } 316 317 private void subscribe(MediaItemState state) { 318 if (!state.mIsSubscribed && state.mItem.isBrowsable()) { 319 mMediaSource.subscribeChildren(state.mItem.getId(), mSubscriptionCallback); 320 state.mIsSubscribed = true; 321 } else { 322 state.mState = State.LOADED; 323 } 324 } 325 326 private void unsubscribe(MediaItemState state) { 327 if (state.mIsSubscribed) { 328 mMediaSource.unsubscribeChildren(state.mItem.getId(), mSubscriptionCallback); 329 state.mIsSubscribed = false; 330 } 331 } 332 333 @NonNull 334 @Override 335 public BrowseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { 336 int layoutId = BrowseItemViewType.values()[viewType].getLayoutId(); 337 View view = LayoutInflater.from(mContext).inflate(layoutId, parent, false); 338 return new BrowseViewHolder(view); 339 } 340 341 @Override 342 public void onBindViewHolder(@NonNull BrowseViewHolder holder, int position) { 343 BrowseViewData viewData = mViewData.get(position); 344 holder.bind(mContext, viewData); 345 } 346 347 @Override 348 public int getItemCount() { 349 return mViewData.size(); 350 } 351 352 @Override 353 public int getItemViewType(int position) { 354 return mViewData.get(position).mViewType.ordinal(); 355 } 356 357 private void onItemsLoaded(String parentId, List<MediaItemMetadata> children) { 358 if (parentId.equals(mParentMediaItemId)) { 359 // Direct children from the requested media item id. Update subscription list. 360 LinkedHashMap<String, MediaItemState> newItemStates = new LinkedHashMap<>(); 361 List<MediaItemState> itemsToSubscribe = new ArrayList<>(); 362 for (MediaItemMetadata item : children) { 363 MediaItemState itemState = mItemStates.get(item.getId()); 364 if (itemState != null) { 365 // Reuse existing section. 366 newItemStates.put(item.getId(), itemState); 367 mItemStates.remove(item.getId()); 368 } else { 369 // New section, subscribe to it. 370 itemState = new MediaItemState(item); 371 newItemStates.put(item.getId(), itemState); 372 itemsToSubscribe.add(itemState); 373 } 374 } 375 // Remove unused sections 376 for (MediaItemState itemState : mItemStates.values()) { 377 unsubscribe(itemState); 378 } 379 mItemStates = newItemStates; 380 // Subscribe items once we have updated the map (updates might happen synchronously 381 // if data is already available). 382 for (MediaItemState itemState : itemsToSubscribe) { 383 subscribe(itemState); 384 } 385 } else { 386 MediaItemState itemState = mItemStates.get(parentId); 387 if (itemState == null) { 388 Log.w(TAG, "Loaded children for a section we don't have: " + parentId); 389 return; 390 } 391 itemState.setChildren(children); 392 itemState.mState = State.LOADED; 393 } 394 updateGlobalState(); 395 notify(Observer::onDirty); 396 } 397 398 private void notify(Consumer<Observer> notification) { 399 for (Observer observer : mObservers) { 400 notification.accept(observer); 401 } 402 } 403 404 private void onLoadingError(String parentId) { 405 if (parentId.equals(mParentMediaItemId)) { 406 mState = State.ERROR; 407 } else { 408 MediaItemState state = mItemStates.get(parentId); 409 if (state == null) { 410 Log.w(TAG, "Error loading children for a section we don't have: " + parentId); 411 return; 412 } 413 state.setChildren(new ArrayList<>()); 414 state.mState = State.ERROR; 415 updateGlobalState(); 416 } 417 notify(Observer::onDirty); 418 } 419 420 private void updateGlobalState() { 421 for (MediaItemState state: mItemStates.values()) { 422 if (state.mState == State.LOADING) { 423 mState = State.LOADING; 424 return; 425 } 426 } 427 mState = State.LOADED; 428 } 429 430 private DiffUtil.Callback createDiffUtil(List<BrowseViewData> oldList, 431 List<BrowseViewData> newList) { 432 return new DiffUtil.Callback() { 433 @Override 434 public int getOldListSize() { 435 return oldList.size(); 436 } 437 438 @Override 439 public int getNewListSize() { 440 return newList.size(); 441 } 442 443 @Override 444 public boolean areItemsTheSame(int oldPos, int newPos) { 445 BrowseViewData oldItem = oldList.get(oldPos); 446 BrowseViewData newItem = newList.get(newPos); 447 448 return Objects.equals(oldItem.mMediaItem, newItem.mMediaItem) 449 && Objects.equals(oldItem.mText, newItem.mText); 450 } 451 452 @Override 453 public boolean areContentsTheSame(int oldPos, int newPos) { 454 BrowseViewData oldItem = oldList.get(oldPos); 455 BrowseViewData newItem = newList.get(newPos); 456 457 return oldItem.equals(newItem); 458 } 459 }; 460 } 461 462 @Override 463 public void onAttachedToRecyclerView(RecyclerView recyclerView) { 464 if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { 465 GridLayoutManager manager = (GridLayoutManager) recyclerView.getLayoutManager(); 466 mMaxSpanSize = manager.getSpanCount(); 467 manager.setSpanSizeLookup(getSpanSizeLookup()); 468 } 469 } 470 471 private class ItemsBuilder { 472 private List<BrowseViewData> result = new ArrayList<>(); 473 474 void addItem(MediaItemMetadata item, State state, 475 BrowseItemViewType viewType, Consumer<Observer> notification) { 476 View.OnClickListener listener = notification != null ? 477 view -> BrowseAdapter.this.notify(notification) : 478 null; 479 result.add(new BrowseViewData(item, viewType, state, listener)); 480 } 481 482 void addItems(List<MediaItemMetadata> items, BrowseItemViewType viewType, int maxRows) { 483 int spanSize = viewType.getSpanSize(mMaxSpanSize); 484 int maxChildren = maxRows * (mMaxSpanSize / spanSize); 485 result.addAll(items.stream() 486 .limit(maxChildren) 487 .map(item -> { 488 Consumer<Observer> notification = item.getQueueId() != null 489 ? observer -> observer.onQueueItemClicked(item) 490 : item.isBrowsable() 491 ? observer -> observer.onBrowseableItemClicked(item) 492 : observer -> observer.onPlayableItemClicked(item); 493 return new BrowseViewData(item, viewType, null, view -> 494 BrowseAdapter.this.notify(notification)); 495 }) 496 .collect(Collectors.toList())); 497 } 498 499 void addTitle(CharSequence title, Consumer<Observer> notification) { 500 result.add(new BrowseViewData(title, BrowseItemViewType.HEADER, 501 view -> BrowseAdapter.this.notify(notification))); 502 503 } 504 505 void addBrowseBlock(MediaItemMetadata header, State state, 506 List<MediaItemMetadata> items, BrowseItemViewType viewType, int maxChildren, 507 boolean showHeader, boolean showMoreFooter) { 508 if (showHeader) { 509 addItem(header, state, BrowseItemViewType.HEADER, null); 510 } 511 addItems(items, viewType, maxChildren); 512 if (showMoreFooter) { 513 addItem(header, null, BrowseItemViewType.MORE_FOOTER, 514 observer -> observer.onMoreButtonClicked(header)); 515 } 516 } 517 518 List<BrowseViewData> build() { 519 return result; 520 } 521 } 522 523 /** 524 * Flatten the given collection of item states into a list of {@link BrowseViewData}s. To avoid 525 * flickering, the flatting will stop at the first "loading" section, avoiding unnecessary 526 * insertion animations during the initial data load. 527 */ 528 private List<BrowseViewData> generateViewData(Collection<MediaItemState> itemStates) { 529 ItemsBuilder itemsBuilder = new ItemsBuilder(); 530 531 if (Log.isLoggable(TAG, Log.VERBOSE)) { 532 Log.v(TAG, "Generating browse view from:"); 533 for (MediaItemState item : itemStates) { 534 Log.v(TAG, String.format("[%s%s] '%s' (%s)", 535 item.mItem.isBrowsable() ? "B" : " ", 536 item.mItem.isPlayable() ? "P" : " ", 537 item.mItem.getTitle(), 538 item.mItem.getId())); 539 List<MediaItemMetadata> items = new ArrayList<>(); 540 items.addAll(item.mBrowsableChildren); 541 items.addAll(item.mPlayableChildren); 542 for (MediaItemMetadata child : items) { 543 Log.v(TAG, String.format(" [%s%s] '%s' (%s)", 544 child.isBrowsable() ? "B" : " ", 545 child.isPlayable() ? "P" : " ", 546 child.getTitle(), 547 child.getId())); 548 } 549 } 550 } 551 552 if (mQueue != null && !mQueue.isEmpty() && mCFBStrategy.getMaxQueueRows() > 0 553 && mCFBStrategy.getQueueViewType() != null) { 554 if (mQueueTitle != null) { 555 itemsBuilder.addTitle(mQueueTitle, Observer::onQueueTitleClicked); 556 } 557 itemsBuilder.addItems(mQueue, mCFBStrategy.getQueueViewType(), 558 mCFBStrategy.getMaxQueueRows()); 559 } 560 561 boolean containsBrowsableItems = false; 562 boolean containsPlayableItems = false; 563 for (MediaItemState itemState : itemStates) { 564 containsBrowsableItems |= itemState.mItem.isBrowsable(); 565 containsPlayableItems |= itemState.mItem.isPlayable(); 566 } 567 568 for (MediaItemState itemState : itemStates) { 569 MediaItemMetadata item = itemState.mItem; 570 if (containsPlayableItems && containsBrowsableItems) { 571 // If we have a mix of browsable and playable items: show them all in a list 572 itemsBuilder.addItem(item, itemState.mState, 573 BrowseItemViewType.PANEL_ITEM, 574 item.isBrowsable() 575 ? observer -> observer.onBrowseableItemClicked(item) 576 : observer -> observer.onPlayableItemClicked(item)); 577 } else if (itemState.mItem.isBrowsable()) { 578 // If we only have browsable items, check whether we should expand them or not. 579 if (!itemState.mBrowsableChildren.isEmpty() 580 && !itemState.mPlayableChildren.isEmpty() 581 || !mCFBStrategy.shouldBeExpanded(item)) { 582 itemsBuilder.addItem(item, itemState.mState, 583 mCFBStrategy.getBrowsableViewType(mParentMediaItem), null); 584 } else if (!itemState.mPlayableChildren.isEmpty()) { 585 itemsBuilder.addBrowseBlock(item, 586 itemState.mState, 587 itemState.mPlayableChildren, 588 mCFBStrategy.getPlayableViewType(item), 589 mCFBStrategy.getMaxRows(item, mCFBStrategy.getPlayableViewType(item)), 590 mCFBStrategy.includeHeader(item), 591 mCFBStrategy.showMoreButton(item)); 592 } else if (!itemState.mBrowsableChildren.isEmpty()) { 593 itemsBuilder.addBrowseBlock(item, 594 itemState.mState, 595 itemState.mBrowsableChildren, 596 mCFBStrategy.getBrowsableViewType(item), 597 mCFBStrategy.getMaxRows(item, mCFBStrategy.getBrowsableViewType(item)), 598 mCFBStrategy.includeHeader(item), 599 mCFBStrategy.showMoreButton(item)); 600 } 601 } else if (item.isPlayable()) { 602 // If we only have playable items: show them as so. 603 itemsBuilder.addItem(item, itemState.mState, 604 mCFBStrategy.getPlayableViewType(mParentMediaItem), 605 observer -> observer.onPlayableItemClicked(item)); 606 } 607 } 608 609 return itemsBuilder.build(); 610 } 611 612 @Override 613 public boolean shouldHideDivider(int position) { 614 return position >= mViewData.size() - 1 615 || position < 0 616 || mViewData.get(position).mViewType != BrowseItemViewType.PANEL_ITEM 617 || mViewData.get(position + 1).mViewType != BrowseItemViewType.PANEL_ITEM; 618 } 619 } 620