diff options
author | android-build-team Robot <android-build-team-robot@google.com> | 2018-04-09 07:24:38 +0000 |
---|---|---|
committer | android-build-team Robot <android-build-team-robot@google.com> | 2018-04-09 07:24:38 +0000 |
commit | bc339a36c69e8379fc7e66f1136061ef7f421e89 (patch) | |
tree | 5d7b46b797ab075441ec6df25029badf6df499e6 | |
parent | 3c7f9582c73ffcfb100164d007b88d974eed9ef8 (diff) | |
parent | 7e6aeee1cb8d64ad271a25c0bc8e0bbe4068ca9f (diff) | |
download | Media-bc339a36c69e8379fc7e66f1136061ef7f421e89.tar.gz |
Snap for 4707594 from 7e6aeee1cb8d64ad271a25c0bc8e0bbe4068ca9f to pi-release
Change-Id: Ib1d47f18ce32d565e28a1f522307fc30a2783782
-rw-r--r-- | res/layout/fragment_playback.xml | 7 | ||||
-rw-r--r-- | res/layout/media_browse_grid_item.xml | 26 | ||||
-rw-r--r-- | res/layout/media_browse_header_item.xml | 13 | ||||
-rw-r--r-- | res/layout/media_browse_list_item.xml | 41 | ||||
-rw-r--r-- | res/layout/media_browse_more_footer.xml | 15 | ||||
-rw-r--r-- | res/layout/media_browse_panel_item.xml | 38 | ||||
-rw-r--r-- | res/values/strings.xml | 2 | ||||
-rw-r--r-- | src/com/android/car/media/PlaybackFragment.java | 192 | ||||
-rw-r--r-- | src/com/android/car/media/browse/BrowseAdapter.java | 542 | ||||
-rw-r--r-- | src/com/android/car/media/browse/BrowseItemViewType.java | 70 | ||||
-rw-r--r-- | src/com/android/car/media/browse/BrowseViewData.java | 102 | ||||
-rw-r--r-- | src/com/android/car/media/browse/BrowseViewHolder.java | 70 | ||||
-rw-r--r-- | src/com/android/car/media/browse/ContentForwardStrategy.java | 120 |
13 files changed, 1165 insertions, 73 deletions
diff --git a/res/layout/fragment_playback.xml b/res/layout/fragment_playback.xml index e37a712..601844f 100644 --- a/res/layout/fragment_playback.xml +++ b/res/layout/fragment_playback.xml @@ -56,12 +56,9 @@ android:id="@+id/browse_list" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_weight="1" - app:showPagedListViewDivider="true" - app:listDividerColor="@color/car_list_divider_inverse" + android:layout_marginBottom="@dimen/car_padding_4" + app:showPagedListViewDivider="false" app:layout_behavior="@string/appbar_scrolling_view_behavior" - app:dividerStartMargin="@dimen/car_keyline_1" - app:dividerEndMargin="@dimen/car_keyline_1" app:layout_constraintTop_toBottomOf="@+id/metadata_container" app:layout_constraintBottom_toTopOf="@+id/playback_controls"/> diff --git a/res/layout/media_browse_grid_item.xml b/res/layout/media_browse_grid_item.xml new file mode 100644 index 0000000..086f11a --- /dev/null +++ b/res/layout/media_browse_grid_item.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/container" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <com.android.car.media.common.FixedRatioImageView + android:id="@+id/thumbnail" + android:layout_width="match_parent" + android:layout_height="0dp" + android:scaleType="centerCrop" + app:aspect_ratio="1"/> + + <TextView + android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_padding_2" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.Car.Body2.Light"/> + +</LinearLayout>
\ No newline at end of file diff --git a/res/layout/media_browse_header_item.xml b/res/layout/media_browse_header_item.xml new file mode 100644 index 0000000..b4db506 --- /dev/null +++ b/res/layout/media_browse_header_item.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TextView + android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:textAppearance="@style/TextAppearance.Car.Body1.Light"/> +</FrameLayout>
\ No newline at end of file diff --git a/res/layout/media_browse_list_item.xml b/res/layout/media_browse_list_item.xml new file mode 100644 index 0000000..f20b20d --- /dev/null +++ b/res/layout/media_browse_list_item.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:focusable="true" + android:orientation="horizontal"> + <ImageView + android:id="@+id/thumbnail" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" + android:layout_marginEnd="@dimen/car_drawer_list_item_icon_end_margin" + android:layout_gravity="center_vertical" + android:scaleType="centerCrop" /> + <LinearLayout + android:id="@+id/text_container" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_gravity="center_vertical" + android:orientation="vertical" > + <TextView + android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/car_padding_1" + android:includeFontPadding="false" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.Car.Body1.Light" /> + <TextView + android:id="@+id/subtitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.Car.Body2.Light" /> + </LinearLayout> +</LinearLayout> diff --git a/res/layout/media_browse_more_footer.xml b/res/layout/media_browse_more_footer.xml new file mode 100644 index 0000000..ea79378 --- /dev/null +++ b/res/layout/media_browse_more_footer.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TextView + android:id="@+id/title" + android:text="@string/media_browse_more" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/car_padding_1" + android:includeFontPadding="false" + android:textAppearance="@style/TextAppearance.Car.Body1.Light"/> +</FrameLayout> diff --git a/res/layout/media_browse_panel_item.xml b/res/layout/media_browse_panel_item.xml new file mode 100644 index 0000000..3b56c36 --- /dev/null +++ b/res/layout/media_browse_panel_item.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:focusable="true" + android:orientation="horizontal"> + <ImageView + android:id="@+id/thumbnail" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" + android:layout_marginEnd="@dimen/car_drawer_list_item_icon_end_margin" + android:layout_gravity="center_vertical" + android:scaleType="centerCrop" /> + <LinearLayout + android:id="@+id/text_container" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_gravity="center_vertical" + android:orientation="vertical" > + <TextView + android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/car_text_vertical_margin" + android:maxLines="1" + android:textAppearance="?attr/drawerItemTitleTextAppearance" /> + <TextView + android:id="@+id/subtitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="?attr/drawerItemBodyTextAppearance" /> + </LinearLayout> +</LinearLayout>
\ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index dbe3930..84d60fd 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -26,4 +26,6 @@ <string name="loading">Getting your selection…</string> <!-- The text for unknown playback error. [CHAR LIMIT=50] --> <string name="unknown_error">Something went wrong</string> + <!-- Text of the button displayed at the bottom of a media browse section to provide access to additional items. [CHAR LIMIT=100] --> + <string name="media_browse_more">More …</string> </resources> diff --git a/src/com/android/car/media/PlaybackFragment.java b/src/com/android/car/media/PlaybackFragment.java index 69ca0b0..bf1e9ec 100644 --- a/src/com/android/car/media/PlaybackFragment.java +++ b/src/com/android/car/media/PlaybackFragment.java @@ -1,12 +1,11 @@ package com.android.car.media; -import static java.security.AccessController.getContext; - -import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.PorterDuff; +import android.media.browse.MediaBrowser; import android.os.Bundle; import android.support.v4.app.Fragment; +import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.TypedValue; import android.view.LayoutInflater; @@ -16,29 +15,29 @@ import android.widget.ImageView; import android.widget.SeekBar; import android.widget.TextView; -import com.android.car.apps.common.ImageUtils; +import com.android.car.media.browse.BrowseAdapter; +import com.android.car.media.browse.ContentForwardStrategy; +import com.android.car.media.common.GridSpacingItemDecoration; import com.android.car.media.common.MediaItemMetadata; +import com.android.car.media.common.MediaSource; import com.android.car.media.common.PlaybackControls; import com.android.car.media.common.PlaybackModel; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; -import java.util.List; import java.util.Locale; +import java.util.Objects; -import androidx.car.widget.ListItem; -import androidx.car.widget.ListItemAdapter; -import androidx.car.widget.ListItemProvider; import androidx.car.widget.PagedListView; -import androidx.car.widget.TextListItem; /** * A {@link Fragment} that implements both the playback and the content forward browsing experience. * It observes a {@link PlaybackModel} and updates its information depending on the currently - * playing media source through the {@link MediaSession} API. + * playing media source through the {@link android.media.session.MediaSession} API. */ -public class PlaybackFragment extends Fragment { +public class PlaybackFragment extends Fragment implements PlaybackModel.PlaybackObserver, + MediaSource.Observer, BrowseAdapter.Observer { private static final String TAG = "PlaybackFragment"; private static final DateFormat TIME_FORMAT = new SimpleDateFormat("m:ss", Locale.US); @@ -51,64 +50,18 @@ public class PlaybackFragment extends Fragment { private TextView mSubtitle; private SeekBar mSeekbar; private PagedListView mBrowseList; - private ListItemAdapter mMediaAdapter; private int mBackgroundRawImageSize; private float mBackgroundBlurRadius; private float mBackgroundBlurScale; - - private PlaybackModel.PlaybackObserver mObserver = new PlaybackModel.PlaybackObserver() { - @Override - public void onPlaybackStateChanged() { - updateState(); - } - - @Override - public void onSourceChanged() { - updateState(); - updateMetadata(); - updateAccentColor(); - updateBrowse(); - } - - @Override - public void onMetadataChanged() { - updateMetadata(); - } - }; - - private ListItemProvider mMediaItemsProvider = new ListItemProvider() { - @Override - public ListItem get(int position) { - if (mModel == null || !mModel.hasQueue()) { - return null; - } - List<MediaItemMetadata> queue = mModel.getQueue(); - if (position < 0 || position >= queue.size()) { - return null; - } - MediaItemMetadata item = queue.get(position); - TextListItem textListItem = new TextListItem(getContext()); - textListItem.setTitle(item.getTitle().toString()); - textListItem.setBody(item.getSubtitle().toString()); - textListItem.setOnClickListener(v -> mModel.onSkipToQueueItem(item.getQueueId())); - return textListItem; - } - - @Override - public int size() { - if (mModel == null || !mModel.hasQueue()) { - return 0; - } - return mModel.getQueue().size(); - } - }; + private MediaSource mMediaSource; + private BrowseAdapter mBrowseAdapter; @Override public View onCreateView(LayoutInflater inflater, final ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_playback, container, false); mModel = new PlaybackModel(getContext()); - mModel.registerObserver(mObserver); + mModel.registerObserver(this); mAlbumBackground = view.findViewById(R.id.album_background); mPlaybackControls = view.findViewById(R.id.playback_controls); mPlaybackControls.setModel(mModel); @@ -120,12 +73,14 @@ public class PlaybackFragment extends Fragment { mSeekbar = view.findViewById(R.id.seek_bar); mTime = view.findViewById(R.id.time); mBrowseList = view.findViewById(R.id.browse_list); - mMediaAdapter = new ListItemAdapter(getContext(), mMediaItemsProvider); - mBrowseList.setAdapter(mMediaAdapter); - RecyclerView recyclerView = mBrowseList.findViewById(R.id.recycler_view); + GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), 4); + RecyclerView recyclerView = mBrowseList.getRecyclerView(); + recyclerView.setLayoutManager(gridLayoutManager); recyclerView.setVerticalFadingEdgeEnabled(true); recyclerView.setFadingEdgeLength(getResources() - .getDimensionPixelSize(R.dimen.car_padding_3)); + .getDimensionPixelSize(R.dimen.car_padding_4)); + recyclerView.addItemDecoration(new GridSpacingItemDecoration(getResources() + .getDimensionPixelSize(R.dimen.car_padding_4))); TypedValue outValue = new TypedValue(); getResources().getValue(R.dimen.playback_background_blur_radius, outValue, true); mBackgroundBlurRadius = outValue.getFloat(); @@ -140,14 +95,42 @@ public class PlaybackFragment extends Fragment { public void onStart() { super.onStart(); mModel.start(); - mMediaAdapter.start(); + if (mMediaSource != null) { + mMediaSource.subscribe(this); + } + if (mBrowseAdapter != null) { + mBrowseAdapter.start(); + } } @Override public void onStop() { super.onStop(); mModel.stop(); - mMediaAdapter.stop(); + if (mMediaSource != null) { + mMediaSource.unsubscribe(this); + } + if (mBrowseAdapter != null) { + mBrowseAdapter.stop(); + } + } + + @Override + public void onPlaybackStateChanged() { + updateState(); + } + + @Override + public void onSourceChanged() { + updateState(); + updateMetadata(); + updateAccentColor(); + updateBrowse(); + } + + @Override + public void onMetadataChanged() { + updateMetadata(); } private void updateState() { @@ -159,6 +142,10 @@ public class PlaybackFragment extends Fragment { mSeekbar.removeCallbacks(mSeekBarRunnable); } mBrowseList.setVisibility(mModel.hasQueue() ? View.VISIBLE : View.GONE); + + if (mBrowseAdapter != null) { + mBrowseAdapter.setQueue(mModel.getQueue(), mModel.getQueueTitle()); + } } private void updateMetadata() { @@ -186,7 +173,8 @@ public class PlaybackFragment extends Fragment { } private void updateAccentColor() { - int color = mModel.getAccentColor(); + int defaultColor = getResources().getColor(android.R.color.background_dark, null); + int color = mModel.getMediaSource().getAccentColor(defaultColor); mSeekbar.getProgressDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); } @@ -225,7 +213,75 @@ public class PlaybackFragment extends Fragment { } private void updateBrowse() { + MediaSource newSource = mModel.getMediaSource(); + if (Objects.equals(mMediaSource, newSource)) { + return; + } + if (mMediaSource != null) { + mMediaSource.unsubscribe(this); + } + mMediaSource = newSource; + mMediaSource.subscribe(this); MediaManager.getInstance(getContext()) - .setMediaClientComponent(mModel.getMediaBrowseServiceComponent()); + .setMediaClientComponent(mMediaSource.getBrowseServiceComponentName()); + } + + @Override + public void onBrowseConnected(MediaBrowser mediaBrowser) { + if (mBrowseAdapter != null) { + mBrowseAdapter.stop(); + mBrowseAdapter = null; + } + if (mediaBrowser == null) { + mBrowseList.setVisibility(View.GONE); + // TODO(b/77647430) implement intermediate states. + return; + } + mBrowseAdapter = new BrowseAdapter(getContext(), mediaBrowser, null, + ContentForwardStrategy.DEFAULT_STRATEGY); + mBrowseList.setAdapter(mBrowseAdapter); + mBrowseAdapter.registerObserver(this); + mBrowseAdapter.start(); + } + + @Override + public void onBrowseDisconnected() { + mBrowseAdapter.stop(); + } + + @Override + public void onDirty() { + mBrowseAdapter.update(); + if (mBrowseAdapter.getItemCount() > 0) { + mBrowseList.setVisibility(View.VISIBLE); + } else { + mBrowseList.setVisibility(View.GONE); + // TODO(b/77647430) implement intermediate states. + } + } + + @Override + public void onPlayableItemClicked(MediaItemMetadata item) { + mModel.onPlayItem(item.getId()); + } + + @Override + public void onBrowseableItemClicked(MediaItemMetadata item) { + // TODO(b/77527398): Drill down in the navigation. + } + + @Override + public void onMoreButtonClicked(MediaItemMetadata item) { + // TODO(b/77527398): Drill down in the navigation + } + + @Override + public void onQueueTitleClicked() { + // TODO(b/77527398): Show full queue + } + + @Override + public void onQueueItemClicked(MediaItemMetadata item) { + mModel.onSkipToQueueItem(item.getQueueId()); } } diff --git a/src/com/android/car/media/browse/BrowseAdapter.java b/src/com/android/car/media/browse/BrowseAdapter.java new file mode 100644 index 0000000..be50b9b --- /dev/null +++ b/src/com/android/car/media/browse/BrowseAdapter.java @@ -0,0 +1,542 @@ +/* + * 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 com.android.car.media.browse; + +import android.content.Context; +import android.media.browse.MediaBrowser; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.util.DiffUtil; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.car.media.common.MediaItemMetadata; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * A {@link RecyclerView.Adapter} that can be used to display a single level of a + * {@link android.service.media.MediaBrowserService} media tree into a + * {@link androidx.car.widget.PagedListView} or any other {@link RecyclerView}. + * + * <p>This adapter assumes that the attached {@link RecyclerView} uses a {@link GridLayoutManager}, + * as it can use both grid and list elements to produce the desired representation. + * + * <p> The actual strategy to group and expand media items has to be supplied by providing an + * instance of {@link ContentForwardStrategy}. + * + * <p> The adapter will only start updating once {@link #start()} is invoked. At this point, the + * provided {@link MediaBrowser} must be already in connected state. + * + * <p>Resources and asynchronous data loading must be released by callign {@link #stop()}. + * + * <p>No views will be actually updated until {@link #update()} is invoked (normally as a result of + * the {@link Observer#onDirty()} event. This way, the consumer of this adapter has the opportunity + * to decide whether updates should be displayd immediately, or if they should be delayed to + * prevent flickering. + * + * <p>Consumers of this adapter should use {@link #registerObserver(Observer)} to receive updates. + */ +public class BrowseAdapter extends RecyclerView.Adapter<BrowseViewHolder> { + private static final String TAG = "MediaBrowseAdapter"; + @NonNull + private final Context mContext; + private final MediaBrowser mMediaBrowser; + private final MediaItemMetadata mParentMediaItem; + private final ContentForwardStrategy mCFBStrategy; + private LinkedHashMap<String, MediaItemState> mItemStates = new LinkedHashMap<>(); + private List<BrowseViewData> mViewData = new ArrayList<>(); + private String mParentMediaItemId; + private List<Observer> mObservers = new ArrayList<>(); + private List<MediaItemMetadata> mQueue; + private CharSequence mQueueTitle; + private int mMaxSpanSize = 1; + private BrowseViewData.State mState = BrowseViewData.State.IDLE; + + /** + * An {@link BrowseAdapter} observer. + */ + public interface Observer { + /** + * Callback invoked anytime there is more information to be displayed, or if there is a + * change in the overall state of the adapter. + */ + void onDirty(); + + /** + * Callback invoked when a user clicks on a playable item. + */ + void onPlayableItemClicked(MediaItemMetadata item); + + /** + * Callback invoked when a user clicks on a browsable item. + */ + void onBrowseableItemClicked(MediaItemMetadata item); + + /** + * Callback invoked when a user clicks on a the "more items" button on a section. + */ + void onMoreButtonClicked(MediaItemMetadata item); + + /** + * Callback invoked when the user clicks on the title of the queue. + */ + void onQueueTitleClicked(); + + /** + * Callback invoked when the user clicks on a queue item. + */ + void onQueueItemClicked(MediaItemMetadata item); + } + + private MediaBrowser.SubscriptionCallback mSubscriptionCallback = + new MediaBrowser.SubscriptionCallback() { + @Override + public void onChildrenLoaded(String parentId, List<MediaBrowser.MediaItem> children) { + onItemsLoaded(parentId, children); + } + + @Override + public void onChildrenLoaded(String parentId, List<MediaBrowser.MediaItem> children, + Bundle options) { + onItemsLoaded(parentId, children); + } + + @Override + public void onError(String parentId) { + onLoadingError(parentId); + } + + @Override + public void onError(String parentId, Bundle options) { + onLoadingError(parentId); + } + }; + + + /** + * Represents the loading state of children of a single {@link MediaItemMetadata} in the + * {@link BrowseAdapter} + */ + private class MediaItemState { + /** + * {@link com.android.car.media.common.MediaItemMetadata} whose children are being loaded + */ + final MediaItemMetadata mItem; + /** Current loading state for this item */ + BrowseViewData.State mState = BrowseViewData.State.LOADING; + /** Playable children of the given item */ + List<MediaItemMetadata> mPlayableChildren = new ArrayList<>(); + /** Browsable children of the given item */ + List<MediaItemMetadata> mBrowsableChildren = new ArrayList<>(); + /** Whether we are subscribed to updates for this item or not */ + boolean mIsSubscribed; + + MediaItemState(MediaBrowser.MediaItem item) { + mItem = new MediaItemMetadata(item); + } + + void setChildren(List<MediaBrowser.MediaItem> children) { + mPlayableChildren.clear(); + mBrowsableChildren.clear(); + for (MediaBrowser.MediaItem child : children) { + if (child.isBrowsable()) { + // Browsable items could also be playable + mBrowsableChildren.add(new MediaItemMetadata(child)); + } else if (child.isPlayable()) { + mPlayableChildren.add(new MediaItemMetadata(child)); + } + } + } + } + + /** + * Creates a {@link BrowseAdapter} that displays the children of the given media tree node. + * + * @param mediaBrowser the {@link MediaBrowser} to get data from. + * @param parentItem the node to display children of, or NULL if the + * @param strategy a {@link ContentForwardStrategy} that would determine which items would be + * expanded and how. + */ + public BrowseAdapter(Context context, @NonNull MediaBrowser mediaBrowser, + @Nullable MediaItemMetadata parentItem, @NonNull ContentForwardStrategy strategy) { + mContext = context; + mMediaBrowser = mediaBrowser; + mParentMediaItem = parentItem; + mCFBStrategy = strategy; + } + + /** + * Initiates or resumes the data loading process and subscribes to updates. The client can use + * {@link #registerObserver(Observer)} to receive updates on the progress. + */ + public void start() { + mParentMediaItemId = mParentMediaItem != null + ? mParentMediaItem.getId() + : mMediaBrowser.getRoot(); + mMediaBrowser.subscribe(mParentMediaItemId, mSubscriptionCallback); + for (MediaItemState itemState : mItemStates.values()) { + subscribe(itemState); + } + } + + /** + * Stops the data loading and releases any subscriptions. + */ + public void stop() { + if (mParentMediaItemId == null) { + // Not started + return; + } + mMediaBrowser.unsubscribe(mParentMediaItemId, mSubscriptionCallback); + for (MediaItemState itemState : mItemStates.values()) { + unsubscribe(itemState); + } + mParentMediaItemId = null; + } + + /** + * Sets media queue items into this adapter. + */ + public void setQueue(List<MediaItemMetadata> items, CharSequence queueTitle) { + mQueue = items; + mQueueTitle = queueTitle; + notify(Observer::onDirty); + } + + /** + * Registers an {@link Observer} + */ + public void registerObserver(Observer observer) { + mObservers.add(observer); + } + + /** + * Unregisters an {@link Observer} + */ + public void unregisterObserver(Observer observer) { + mObservers.remove(observer); + } + + /** + * @return the global loading state. Consumers can use this state to determine if more + * information is still pending to arrive or not. This method will report + * {@link BrowseViewData.State#ERROR} only if the list of immediate children fails to load. + */ + public BrowseViewData.State getState() { + return mState; + } + + /** + * Sets the number of columns that items can take. This method only needs to be used if the + * attached {@link RecyclerView} is NOT using a {@link GridLayoutManager}. This class will + * automatically determine this value on {@link #onAttachedToRecyclerView(RecyclerView)} + * otherwise. + */ + public void setMaxSpanSize(int maxSpanSize) { + mMaxSpanSize = maxSpanSize; + } + + /** + * @return a {@link GridLayoutManager.SpanSizeLookup} that can be used to obtain the span size + * of each item in this adapter. This method is only needed if the {@link RecyclerView} is NOT + * using a {@link GridLayoutManager}. This class will automatically use it on\ + * {@link #onAttachedToRecyclerView(RecyclerView)} otherwise. + */ + public GridLayoutManager.SpanSizeLookup getSpanSizeLookup() { + return new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + BrowseItemViewType viewType = mViewData.get(position).mViewType; + return viewType.getSpanSize(mMaxSpanSize); + } + }; + } + + /** + * Updates the {@link RecyclerView} with newly loaded information. This normally should be + * invoked as a result of a {@link Observer#onDirty()} callback. + * + * This method is idempotent and can be used at any time (even delayed if needed). Additions, + * removals and insertions would be notified to the {@link RecyclerView} so it can be + * animated appropriately. + */ + public void update() { + List<BrowseViewData> newItems = generateViewData(mItemStates.values()); + List<BrowseViewData> oldItems = mViewData; + mViewData = newItems; + DiffUtil.DiffResult result = DiffUtil.calculateDiff(createDiffUtil(oldItems, newItems)); + result.dispatchUpdatesTo(this); + } + + private void subscribe(MediaItemState state) { + if (!state.mIsSubscribed && state.mItem.isBrowsable()) { + mMediaBrowser.subscribe(state.mItem.getId(), mSubscriptionCallback); + state.mIsSubscribed = true; + } + } + + private void unsubscribe(MediaItemState state) { + if (state.mIsSubscribed) { + mMediaBrowser.unsubscribe(state.mItem.getId(), mSubscriptionCallback); + state.mIsSubscribed = false; + } + } + + @NonNull + @Override + public BrowseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + int layoutId = BrowseItemViewType.values()[viewType].getLayoutId(); + View view = LayoutInflater.from(mContext).inflate(layoutId, parent, false); + return new BrowseViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull BrowseViewHolder holder, int position) { + BrowseViewData viewData = mViewData.get(position); + holder.bind(mContext, viewData); + } + + @Override + public int getItemCount() { + return mViewData.size(); + } + + @Override + public int getItemViewType(int position) { + return mViewData.get(position).mViewType.ordinal(); + } + + private void onItemsLoaded(String parentId, List<MediaBrowser.MediaItem> children) { + if (parentId.equals(mParentMediaItemId)) { + // Direct children from the requested media item id. Update subscription list. + LinkedHashMap<String, MediaItemState> newItemStates = new LinkedHashMap<>(); + for (MediaBrowser.MediaItem item : children) { + MediaItemState itemState = mItemStates.get(item.getMediaId()); + if (itemState != null) { + // Reuse existing section. + newItemStates.put(item.getMediaId(), itemState); + mItemStates.remove(item.getMediaId()); + } else { + // New section, subscribe to it. + itemState = new MediaItemState(item); + newItemStates.put(item.getMediaId(), itemState); + subscribe(itemState); + } + } + // Remove unused sections + for (MediaItemState itemState : mItemStates.values()) { + unsubscribe(itemState); + } + mItemStates = newItemStates; + } else { + MediaItemState itemState = mItemStates.get(parentId); + if (itemState == null) { + Log.w(TAG, "Loaded children for a section we don't have: " + parentId); + return; + } + itemState.setChildren(children); + itemState.mState = BrowseViewData.State.LOADED; + } + updateGlobalState(); + notify(Observer::onDirty); + } + + private void notify(Consumer<Observer> notification) { + for (Observer observer : mObservers) { + notification.accept(observer); + } + } + + private void onLoadingError(String parentId) { + if (parentId.equals(mParentMediaItemId)) { + mState = BrowseViewData.State.ERROR; + } else { + MediaItemState state = mItemStates.get(parentId); + if (state == null) { + Log.w(TAG, "Error loading children for a section we don't have: " + parentId); + return; + } + state.setChildren(new ArrayList<>()); + state.mState = BrowseViewData.State.ERROR; + } + updateGlobalState(); + notify(Observer::onDirty); + } + + private void updateGlobalState() { + for (MediaItemState state: mItemStates.values()) { + if (state.mState == BrowseViewData.State.LOADING) { + mState = BrowseViewData.State.LOADING; + return; + } + } + mState = BrowseViewData.State.LOADED; + } + + private DiffUtil.Callback createDiffUtil(List<BrowseViewData> oldList, + List<BrowseViewData> newList) { + return new DiffUtil.Callback() { + @Override + public int getOldListSize() { + return oldList.size(); + } + + @Override + public int getNewListSize() { + return newList.size(); + } + + @Override + public boolean areItemsTheSame(int oldPos, int newPos) { + BrowseViewData oldItem = oldList.get(oldPos); + BrowseViewData newItem = newList.get(newPos); + + return Objects.equals(oldItem.mMediaItem, newItem.mMediaItem) + && Objects.equals(oldItem.mText, newItem.mText); + } + + @Override + public boolean areContentsTheSame(int oldPos, int newPos) { + BrowseViewData oldItem = oldList.get(oldPos); + BrowseViewData newItem = newList.get(newPos); + + return oldItem.equals(newItem); + } + }; + } + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { + GridLayoutManager manager = (GridLayoutManager) recyclerView.getLayoutManager(); + mMaxSpanSize = manager.getSpanCount(); + manager.setSpanSizeLookup(getSpanSizeLookup()); + } + } + + private class ItemsBuilder { + private List<BrowseViewData> result = new ArrayList<>(); + + void addItem(MediaItemMetadata item, BrowseViewData.State state, + BrowseItemViewType viewType, Consumer<Observer> notification) { + result.add(new BrowseViewData(item, viewType, state, + view -> BrowseAdapter.this.notify(notification))); + } + + void addItems(List<MediaItemMetadata> items, BrowseItemViewType viewType, int maxRows) { + int spanSize = viewType.getSpanSize(mMaxSpanSize); + int maxChildren = maxRows * (mMaxSpanSize / spanSize); + result.addAll(items.stream() + .limit(maxChildren) + .map(item -> { + Consumer<Observer> notification = item.getQueueId() != null + ? observer -> observer.onQueueItemClicked(item) + : item.isBrowsable() + ? observer -> observer.onBrowseableItemClicked(item) + : observer -> observer.onPlayableItemClicked(item); + return new BrowseViewData(item, viewType, null, view -> + BrowseAdapter.this.notify(notification)); + }) + .collect(Collectors.toList())); + } + + void addTitle(CharSequence title, Consumer<Observer> notification) { + result.add(new BrowseViewData(title, BrowseItemViewType.HEADER, + view -> BrowseAdapter.this.notify(notification))); + + } + + void addBrowseBlock(MediaItemMetadata header, BrowseViewData.State state, + List<MediaItemMetadata> items, BrowseItemViewType viewType, int maxChildren, + boolean showHeader, boolean showMoreFooter) { + if (showHeader) { + addItem(header, state, BrowseItemViewType.HEADER, null); + } + addItems(items, viewType, maxChildren); + if (showMoreFooter) { + addItem(header, null, BrowseItemViewType.MORE_FOOTER, + observer -> observer.onMoreButtonClicked(header)); + } + } + + List<BrowseViewData> build() { + return result; + } + } + + /** + * Flatten the given collection of item states into a list of {@link BrowseViewData}s. To avoid + * flickering, the flatting will stop at the first "loading" section, avoiding unnecessary + * insertion animations during the initial data load. + */ + private List<BrowseViewData> generateViewData(Collection<MediaItemState> itemStates) { + ItemsBuilder itemsBuilder = new ItemsBuilder(); + + if (mQueue != null && !mQueue.isEmpty() && mCFBStrategy.getMaxQueueRows() > 0 + && mCFBStrategy.getQueueViewType() != null) { + if (mQueueTitle != null) { + itemsBuilder.addTitle(mQueueTitle, Observer::onQueueTitleClicked); + } + itemsBuilder.addItems(mQueue, mCFBStrategy.getQueueViewType(), + mCFBStrategy.getMaxQueueRows()); + } + for (MediaItemState itemState : itemStates) { + MediaItemMetadata item = itemState.mItem; + if (itemState.mItem.isBrowsable()) { + if (!itemState.mBrowsableChildren.isEmpty() + && !itemState.mPlayableChildren.isEmpty() + || !mCFBStrategy.shouldBeExpanded(item)) { + itemsBuilder.addItem(item, itemState.mState, + mCFBStrategy.getBrowsableViewType(mParentMediaItem), null); + } else if (!itemState.mPlayableChildren.isEmpty()) { + itemsBuilder.addBrowseBlock(item, + itemState.mState, + itemState.mPlayableChildren, + mCFBStrategy.getPlayableViewType(item), + mCFBStrategy.getMaxRows(item, mCFBStrategy.getPlayableViewType(item)), + mCFBStrategy.showMoreButton(item), + mCFBStrategy.includeHeader(item)); + } else if (!itemState.mBrowsableChildren.isEmpty()) { + itemsBuilder.addBrowseBlock(item, + itemState.mState, + itemState.mBrowsableChildren, + mCFBStrategy.getBrowsableViewType(item), + mCFBStrategy.getMaxRows(item, mCFBStrategy.getBrowsableViewType(item)), + mCFBStrategy.showMoreButton(item), + mCFBStrategy.includeHeader(item)); + } + } else if (item.isPlayable()) { + itemsBuilder.addItem(item, itemState.mState, + mCFBStrategy.getPlayableViewType(mParentMediaItem), null); + } + } + + return itemsBuilder.build(); + } +} diff --git a/src/com/android/car/media/browse/BrowseItemViewType.java b/src/com/android/car/media/browse/BrowseItemViewType.java new file mode 100644 index 0000000..cfbdee7 --- /dev/null +++ b/src/com/android/car/media/browse/BrowseItemViewType.java @@ -0,0 +1,70 @@ +/* + * 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 com.android.car.media.browse; + +import android.support.annotation.LayoutRes; + +/** + * Possible view types that would be used by the {@link BrowseAdapter}. + */ +public enum BrowseItemViewType { + /** A section header */ + HEADER(com.android.car.media.common.R.layout.media_browse_header_item), + /** A grid item including an image and a title */ + GRID_ITEM(com.android.car.media.common.R.layout.media_browse_grid_item, 1), + /** A list item including title and subtitle */ + LIST_ITEM(com.android.car.media.common.R.layout.media_browse_list_item), + /** An item in a panel of items (menu) */ + PANEL_ITEM(com.android.car.media.common.R.layout.media_browse_panel_item), + /** A footer that can be used to navigate to an expanded version of a section */ + MORE_FOOTER(com.android.car.media.common.R.layout.media_browse_more_footer), + ; + private final @LayoutRes int mLayoutId; + private final int mSpanSize; + + /** + * {@link BrowseItemViewType} that take the whole width of the + * {@link android.support.v7.widget.RecyclerView} + */ + BrowseItemViewType(@LayoutRes int layoutId) { + mLayoutId = layoutId; + mSpanSize = -1; + } + + /** + * {@link BrowseItemViewType} that only takes the given number of columns. + */ + BrowseItemViewType(@LayoutRes int layoutId, int spanSize) { + mLayoutId = layoutId; + mSpanSize = spanSize; + } + + /** + * @param maxSpanSize maximum number of columns of the underlying grid + * @return number of columns this view wants to use. + */ + public int getSpanSize(int maxSpanSize) { + return mSpanSize < 0 ? maxSpanSize : mSpanSize; + } + + /** + * @return layout that should be inflated to generate this view type. + */ + public @LayoutRes int getLayoutId() { + return mLayoutId; + } +} diff --git a/src/com/android/car/media/browse/BrowseViewData.java b/src/com/android/car/media/browse/BrowseViewData.java new file mode 100644 index 0000000..31d0588 --- /dev/null +++ b/src/com/android/car/media/browse/BrowseViewData.java @@ -0,0 +1,102 @@ +/* + * 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 com.android.car.media.browse; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; + +import com.android.car.media.common.MediaItemMetadata; + +import java.util.Objects; + +/** + * Information necessary to update a {@link BrowseViewHolder} + */ +class BrowseViewData { + /** {@link com.android.car.media.common.MediaItemMetadata} associated with this item */ + public final MediaItemMetadata mMediaItem; + /** View type associated with this item */ + @NonNull public final BrowseItemViewType mViewType; + /** Current state of this item */ + public final State mState; + /** Text associated with this item */ + public final CharSequence mText; + /** Click listener to set for this item */ + public final View.OnClickListener mOnClickListener; + + /** + * Possible states of a view + */ + public enum State { + /** Loading of this item hasn't started yet */ + IDLE, + /** There is pending information before this item can be displayed */ + LOADING, + /** It was not possible to load metadata for this item */ + ERROR, + /** Metadata for this items has been correctly loaded */ + LOADED + } + + /** + * Creates a {@link BrowseViewData} for a particular {@link MediaItemMetadata}. + * + * @param mediaItem {@link MediaItemMetadata} metadata + * @param viewType view type to use to represent this item + * @param state current item state + * @param onClickListener optional {@link android.view.View.OnClickListener} + */ + BrowseViewData(MediaItemMetadata mediaItem, @NonNull BrowseItemViewType viewType, + @NonNull State state, View.OnClickListener onClickListener) { + mMediaItem = mediaItem; + mViewType = viewType; + mState = state; + mText = null; + mOnClickListener = onClickListener; + } + + /** + * Creates a {@link BrowseViewData} for a given text (normally used for headers or footers) + * @param text text to set + * @param viewType view type to use + * @param onClickListener optional {@link android.view.View.OnClickListener} + */ + BrowseViewData(@NonNull CharSequence text, @NonNull BrowseItemViewType viewType, + View.OnClickListener onClickListener) { + mText = text; + mViewType = viewType; + mMediaItem = null; + mState = null; + mOnClickListener = onClickListener; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BrowseViewData item = (BrowseViewData) o; + return Objects.equals(mMediaItem, item.mMediaItem) && + mState == item.mState && + mViewType == item.mViewType; + } + + @Override + public int hashCode() { + return Objects.hash(mMediaItem, mState, mViewType); + } +} diff --git a/src/com/android/car/media/browse/BrowseViewHolder.java b/src/com/android/car/media/browse/BrowseViewHolder.java new file mode 100644 index 0000000..28ec64c --- /dev/null +++ b/src/com/android/car/media/browse/BrowseViewHolder.java @@ -0,0 +1,70 @@ +/* + * 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 com.android.car.media.browse; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.car.media.common.MediaItemMetadata; + +/** + * Generic {@link RecyclerView.ViewHolder} to use for all views in the {@link BrowseAdapter} + */ +class BrowseViewHolder extends RecyclerView.ViewHolder { + final TextView mTitle; + final TextView mSubtitle; + final ImageView mAlbumArt; + final ViewGroup mContainer; + + /** + * Creates a {@link BrowseViewHolder} for the given view. + */ + BrowseViewHolder(View itemView) { + super(itemView); + mTitle = itemView.findViewById(com.android.car.media.common.R.id.title); + mSubtitle = itemView.findViewById(com.android.car.media.common.R.id.subtitle); + mAlbumArt = itemView.findViewById(com.android.car.media.common.R.id.thumbnail); + mContainer = itemView.findViewById(com.android.car.media.common.R.id.container); + } + + /** + * Updates this {@link BrowseViewHolder} with the given data + */ + public void bind(Context context, BrowseViewData data) { + if (mTitle != null) { + mTitle.setText(data.mText != null + ? data.mText : data.mMediaItem != null + ? data.mMediaItem.getTitle() + : null); + } + if (mSubtitle != null) { + mSubtitle.setText(data.mMediaItem != null + ? data.mMediaItem.getSubtitle() + : null); + } + if (mAlbumArt != null) { + MediaItemMetadata.updateImageView(context, data.mMediaItem, mAlbumArt, 0); + } + if (mContainer != null && data.mOnClickListener != null) { + mContainer.setOnClickListener(data.mOnClickListener); + } + } +} diff --git a/src/com/android/car/media/browse/ContentForwardStrategy.java b/src/com/android/car/media/browse/ContentForwardStrategy.java new file mode 100644 index 0000000..7720366 --- /dev/null +++ b/src/com/android/car/media/browse/ContentForwardStrategy.java @@ -0,0 +1,120 @@ +/* + * 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 com.android.car.media.browse; + +import com.android.car.media.common.MediaItemMetadata; + +/** + * Strategy used to group and expand media items in the {@link BrowseAdapter} + */ +public interface ContentForwardStrategy { + /** + * @return true if a header should be included when expanding the given media item into a + * section. Only used if {@link #shouldBeExpanded(MediaItemMetadata)} returns true. + */ + boolean includeHeader(MediaItemMetadata mediaItem); + + /** + * @return maximum number of rows to use when when expanding the given media item into a + * section. The number can be different depending on the {@link BrowseItemViewType} that + * will be used to represent media item children (i.e.: we might allow more rows for lists + * than for grids). Only used if {@link #shouldBeExpanded(MediaItemMetadata)} returns true. + */ + int getMaxRows(MediaItemMetadata mediaItem, BrowseItemViewType viewType); + + /** + * @return whether the given media item should be expanded or not. If not expanded, the item + * will be displayed according to its parent preferred view type. + */ + boolean shouldBeExpanded(MediaItemMetadata mediaItem); + + /** + * @return view type to use to render browsable children of the given media item. Only used if + * {@link #shouldBeExpanded(MediaItemMetadata)} returns true. + */ + BrowseItemViewType getBrowsableViewType(MediaItemMetadata mediaItem); + + /** + * @return view type to use to render playable children fo the given media item. Only used if + * {@link #shouldBeExpanded(MediaItemMetadata)} returns true. + */ + BrowseItemViewType getPlayableViewType(MediaItemMetadata mediaItem); + + /** + * @return true if a "more" button should be displayed as a footer for a section displaying the + * given media item, in case that there item has more children than the ones that can be + * displayed according to {@link #getMaxQueueRows()}. Only used if + * {@link #shouldBeExpanded(MediaItemMetadata)} returns true. + */ + boolean showMoreButton(MediaItemMetadata mediaItem); + + /** + * @return maximum number of items to show for the media queue, if one is provided. + */ + int getMaxQueueRows(); + + /** + * @return view type to use to display queue items. + */ + BrowseItemViewType getQueueViewType(); + + /** + * Default strategy + * TODO(b/77646944): Expand this implementation to honor the media source expectations. + */ + ContentForwardStrategy DEFAULT_STRATEGY = new ContentForwardStrategy() { + @Override + public boolean includeHeader(MediaItemMetadata mediaItem) { + return false; + } + + @Override + public int getMaxRows(MediaItemMetadata mediaItem, BrowseItemViewType viewType) { + return viewType == BrowseItemViewType.GRID_ITEM ? 2 : 8; + } + + @Override + public boolean shouldBeExpanded(MediaItemMetadata mediaItem) { + return true; + } + + @Override + public BrowseItemViewType getBrowsableViewType(MediaItemMetadata mediaItem) { + return BrowseItemViewType.PANEL_ITEM; + } + + @Override + public BrowseItemViewType getPlayableViewType(MediaItemMetadata mediaItem) { + return BrowseItemViewType.GRID_ITEM; + } + + @Override + public boolean showMoreButton(MediaItemMetadata mediaItem) { + return true; + } + + @Override + public int getMaxQueueRows() { + return 8; + } + + @Override + public BrowseItemViewType getQueueViewType() { + return BrowseItemViewType.LIST_ITEM; + } + }; +} |