• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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