mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2024-12-25 01:20:34 +00:00
-Added rudimentary playlist fragment.
-Added schema for stream storage.
This commit is contained in:
parent
391d3e7fc7
commit
e70dcdc642
@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.database;
|
||||||
|
|
||||||
|
import android.arch.persistence.room.TypeConverter;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
public class Converters {
|
||||||
|
@TypeConverter
|
||||||
|
public static Date fromTimestamp(Long value) {
|
||||||
|
return value == null ? null : new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
public static Long dateToTimestamp(Date date) {
|
||||||
|
return date == null ? null : date.getTime();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
package org.schabi.newpipe.database.playlist;
|
||||||
|
|
||||||
|
import android.arch.persistence.room.ColumnInfo;
|
||||||
|
import android.arch.persistence.room.Entity;
|
||||||
|
import android.arch.persistence.room.Ignore;
|
||||||
|
import android.arch.persistence.room.PrimaryKey;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.database.playlist.PlaylistEntity.PLAYLIST_TABLE;
|
||||||
|
|
||||||
|
@Entity(tableName = PLAYLIST_TABLE)
|
||||||
|
public class PlaylistEntity {
|
||||||
|
|
||||||
|
final static String PLAYLIST_TABLE = "playlists";
|
||||||
|
final static String PLAYLIST_URL = "url";
|
||||||
|
final static String PLAYLIST_TITLE = "title";
|
||||||
|
final static String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||||
|
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
private long uid = 0;
|
||||||
|
|
||||||
|
@ColumnInfo(name = PLAYLIST_TITLE)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/* This is used as a reference to the source, should this playlist be dynamic */
|
||||||
|
@ColumnInfo(name = PLAYLIST_URL)
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
||||||
|
private String thumbnailUrl;
|
||||||
|
|
||||||
|
public long getUid() {
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep this package-private since UID should always be auto generated by Room impl */
|
||||||
|
void setUid(long uid) {
|
||||||
|
this.uid = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThumbnailUrl(String thumbnailUrl) {
|
||||||
|
this.thumbnailUrl = thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
public void setData(final String title,
|
||||||
|
final String thumbnailUrl) {
|
||||||
|
this.setTitle(title);
|
||||||
|
this.setThumbnailUrl(thumbnailUrl);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package org.schabi.newpipe.database.stream;
|
||||||
|
|
||||||
|
import android.arch.persistence.room.Dao;
|
||||||
|
import android.arch.persistence.room.Query;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.database.BasicDAO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reactivex.Flowable;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_SERVICE_ID;
|
||||||
|
import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_TABLE;
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
public interface StreamDAO extends BasicDAO<StreamEntity> {
|
||||||
|
@Override
|
||||||
|
@Query("SELECT * FROM " + STREAM_TABLE)
|
||||||
|
Flowable<List<StreamEntity>> findAll();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId")
|
||||||
|
Flowable<List<StreamEntity>> listByService(int serviceId);
|
||||||
|
}
|
@ -0,0 +1,207 @@
|
|||||||
|
package org.schabi.newpipe.database.stream;
|
||||||
|
|
||||||
|
import android.arch.persistence.room.ColumnInfo;
|
||||||
|
import android.arch.persistence.room.Entity;
|
||||||
|
import android.arch.persistence.room.Ignore;
|
||||||
|
import android.arch.persistence.room.Index;
|
||||||
|
import android.arch.persistence.room.PrimaryKey;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.AbstractStreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream_info.StreamInfoItem;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_SERVICE_ID;
|
||||||
|
import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_TABLE;
|
||||||
|
import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_URL;
|
||||||
|
|
||||||
|
@Entity(tableName = STREAM_TABLE,
|
||||||
|
indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)})
|
||||||
|
public class StreamEntity {
|
||||||
|
public final static String STREAM_UID = "uid";
|
||||||
|
|
||||||
|
final static String STREAM_TABLE = "streams";
|
||||||
|
final static String STREAM_ID = "id";
|
||||||
|
final static String STREAM_TYPE = "type";
|
||||||
|
final static String STREAM_SERVICE_ID = "service_id";
|
||||||
|
final static String STREAM_URL = "url";
|
||||||
|
final static String STREAM_TITLE = "title";
|
||||||
|
final static String STREAM_THUMBNAIL_URL = "thumbnail_url";
|
||||||
|
final static String STREAM_VIEW_COUNT = "view_count";
|
||||||
|
final static String STREAM_UPLOADER = "uploader";
|
||||||
|
final static String STREAM_UPLOAD_DATE = "upload_date";
|
||||||
|
final static String STREAM_DURATION = "duration";
|
||||||
|
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
private long uid = 0;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_SERVICE_ID)
|
||||||
|
private int serviceId = -1;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_ID)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_TYPE)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_URL)
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_TITLE)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
||||||
|
private String thumbnailUrl;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_VIEW_COUNT)
|
||||||
|
private Long viewCount;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_UPLOADER)
|
||||||
|
private String uploader;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_UPLOAD_DATE)
|
||||||
|
private long uploadDate;
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_DURATION)
|
||||||
|
private int duration;
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
public StreamInfoItem toStreamInfoItem() {
|
||||||
|
StreamInfoItem item = new StreamInfoItem();
|
||||||
|
|
||||||
|
item.stream_type = AbstractStreamInfo.StreamType.valueOf( this.getType() );
|
||||||
|
|
||||||
|
item.service_id = this.getServiceId();
|
||||||
|
item.id = this.getId();
|
||||||
|
item.webpage_url = this.getUrl();
|
||||||
|
item.title = this.getTitle();
|
||||||
|
item.thumbnail_url = this.getThumbnailUrl();
|
||||||
|
item.view_count = this.getViewCount();
|
||||||
|
item.uploader = this.getUploader();
|
||||||
|
|
||||||
|
// TODO: temporary until upload date parsing is fleshed out
|
||||||
|
item.upload_date = "Unknown";
|
||||||
|
item.duration = this.getDuration();
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
public StreamEntity(final StreamInfoItem item) {
|
||||||
|
setData(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
public void setData(final StreamInfoItem item) {
|
||||||
|
// Do not store ordinals into db since they may change in the future
|
||||||
|
this.type = item.stream_type.name();
|
||||||
|
|
||||||
|
this.serviceId = item.service_id;
|
||||||
|
this.id = item.id;
|
||||||
|
this.url = item.webpage_url;
|
||||||
|
this.title = item.title;
|
||||||
|
this.thumbnailUrl = item.thumbnail_url;
|
||||||
|
this.viewCount = item.view_count;
|
||||||
|
this.uploader = item.uploader;
|
||||||
|
|
||||||
|
// TODO: temporary until upload date parsing is fleshed out
|
||||||
|
this.uploadDate = new Date().getTime();
|
||||||
|
this.duration = item.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
public boolean is(final StreamInfoItem item) {
|
||||||
|
return this.type.equals( item.stream_type.name() ) &&
|
||||||
|
this.serviceId == item.service_id &&
|
||||||
|
this.id.equals( item.id ) &&
|
||||||
|
this.url.equals( item.webpage_url );
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUid() {
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUid(long uid) {
|
||||||
|
this.uid = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getServiceId() {
|
||||||
|
return serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServiceId(int serviceId) {
|
||||||
|
this.serviceId = serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThumbnailUrl(String thumbnailUrl) {
|
||||||
|
this.thumbnailUrl = thumbnailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getViewCount() {
|
||||||
|
return viewCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setViewCount(Long viewCount) {
|
||||||
|
this.viewCount = viewCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUploader() {
|
||||||
|
return uploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUploader(String uploader) {
|
||||||
|
this.uploader = uploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUploadDate() {
|
||||||
|
return uploadDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUploadDate(long uploadDate) {
|
||||||
|
this.uploadDate = uploadDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDuration() {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDuration(int duration) {
|
||||||
|
this.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,445 @@
|
|||||||
|
package org.schabi.newpipe.fragments.playlist;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.support.v4.app.Fragment;
|
||||||
|
import android.support.v4.content.ContextCompat;
|
||||||
|
import android.support.v7.app.ActionBar;
|
||||||
|
import android.support.v7.widget.LinearLayoutManager;
|
||||||
|
import android.support.v7.widget.RecyclerView;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuInflater;
|
||||||
|
import android.view.MenuItem;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.ImageErrorLoadingListener;
|
||||||
|
import org.schabi.newpipe.MainActivity;
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlayListExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlayListInfo;
|
||||||
|
import org.schabi.newpipe.fragments.BaseFragment;
|
||||||
|
import org.schabi.newpipe.fragments.search.OnScrollBelowItemsListener;
|
||||||
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
|
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||||
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
|
import org.schabi.newpipe.report.UserAction;
|
||||||
|
import org.schabi.newpipe.util.Constants;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
|
||||||
|
import io.reactivex.Observable;
|
||||||
|
import io.reactivex.Observer;
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.annotations.NonNull;
|
||||||
|
import io.reactivex.disposables.Disposable;
|
||||||
|
import io.reactivex.schedulers.Schedulers;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
||||||
|
|
||||||
|
public class PlaylistFragment extends BaseFragment {
|
||||||
|
private final String TAG = "PlaylistFragment@" + Integer.toHexString(hashCode());
|
||||||
|
|
||||||
|
private static final String INFO_LIST_KEY = "info_list_key";
|
||||||
|
private static final String PLAYLIST_INFO_KEY = "playlist_info_key";
|
||||||
|
private static final String PAGE_NUMBER_KEY = "page_number_key";
|
||||||
|
|
||||||
|
private InfoListAdapter infoListAdapter;
|
||||||
|
|
||||||
|
private PlayListInfo currentPlaylistInfo;
|
||||||
|
private int serviceId = -1;
|
||||||
|
private String playlistTitle = "";
|
||||||
|
private String playlistUrl = "";
|
||||||
|
private int pageNumber = 0;
|
||||||
|
private boolean hasNextPage = true;
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Views
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private RecyclerView playlistStreams;
|
||||||
|
|
||||||
|
private View headerRootLayout;
|
||||||
|
private ImageView headerBannerView;
|
||||||
|
private ImageView headerAvatarView;
|
||||||
|
private TextView headerTitleView;
|
||||||
|
|
||||||
|
/*////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
public PlaylistFragment() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fragment getInstance(int serviceId, String playlistUrl, String title) {
|
||||||
|
PlaylistFragment instance = new PlaylistFragment();
|
||||||
|
instance.setPlaylist(serviceId, playlistUrl, title);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment's LifeCycle
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setHasOptionsMenu(true);
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
playlistUrl = savedInstanceState.getString(Constants.KEY_URL);
|
||||||
|
playlistTitle = savedInstanceState.getString(Constants.KEY_TITLE);
|
||||||
|
serviceId = savedInstanceState.getInt(Constants.KEY_SERVICE_ID, -1);
|
||||||
|
|
||||||
|
pageNumber = savedInstanceState.getInt(PAGE_NUMBER_KEY, 0);
|
||||||
|
Serializable serializable = savedInstanceState.getSerializable(PLAYLIST_INFO_KEY);
|
||||||
|
if (serializable instanceof PlayListInfo) currentPlaylistInfo = (PlayListInfo) serializable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]");
|
||||||
|
return inflater.inflate(R.layout.fragment_channel, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
|
||||||
|
if (currentPlaylistInfo == null) loadPage(0);
|
||||||
|
else handlePlayListInfo(currentPlaylistInfo, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
if (DEBUG) Log.d(TAG, "onDestroyView() called");
|
||||||
|
headerAvatarView.setImageBitmap(null);
|
||||||
|
headerBannerView.setImageBitmap(null);
|
||||||
|
playlistStreams.removeAllViews();
|
||||||
|
|
||||||
|
playlistStreams = null;
|
||||||
|
headerRootLayout = null;
|
||||||
|
headerBannerView = null;
|
||||||
|
headerAvatarView = null;
|
||||||
|
headerTitleView = null;
|
||||||
|
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
if (DEBUG) Log.d(TAG, "onResume() called");
|
||||||
|
super.onResume();
|
||||||
|
if (wasLoading.getAndSet(false)) {
|
||||||
|
loadPage(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStop() {
|
||||||
|
if (DEBUG) Log.d(TAG, "onStop() called");
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
disposable = null;
|
||||||
|
|
||||||
|
super.onStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSaveInstanceState(Bundle outState) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onSaveInstanceState() called with: outState = [" + outState + "]");
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
outState.putString(Constants.KEY_URL, playlistUrl);
|
||||||
|
outState.putString(Constants.KEY_TITLE, playlistTitle);
|
||||||
|
outState.putInt(Constants.KEY_SERVICE_ID, serviceId);
|
||||||
|
|
||||||
|
outState.putSerializable(INFO_LIST_KEY, infoListAdapter.getItemsList());
|
||||||
|
outState.putSerializable(PLAYLIST_INFO_KEY, currentPlaylistInfo);
|
||||||
|
outState.putInt(PAGE_NUMBER_KEY, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Menu
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
@Override
|
||||||
|
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||||
|
super.onCreateOptionsMenu(menu, inflater);
|
||||||
|
inflater.inflate(R.menu.menu_channel, menu);
|
||||||
|
|
||||||
|
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||||
|
if (supportActionBar != null) {
|
||||||
|
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||||
|
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onOptionsItemSelected(MenuItem item) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]");
|
||||||
|
super.onOptionsItemSelected(item);
|
||||||
|
switch (item.getItemId()) {
|
||||||
|
case R.id.menu_item_openInBrowser: {
|
||||||
|
Intent intent = new Intent();
|
||||||
|
intent.setAction(Intent.ACTION_VIEW);
|
||||||
|
intent.setData(Uri.parse(playlistUrl));
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.choose_browser)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case R.id.menu_item_share: {
|
||||||
|
Intent intent = new Intent();
|
||||||
|
intent.setAction(Intent.ACTION_SEND);
|
||||||
|
intent.putExtra(Intent.EXTRA_TEXT, playlistUrl);
|
||||||
|
intent.setType("text/plain");
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return super.onOptionsItemSelected(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Init's
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||||
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
|
playlistStreams = (RecyclerView) rootView.findViewById(R.id.channel_streams_view);
|
||||||
|
|
||||||
|
playlistStreams.setLayoutManager(new LinearLayoutManager(activity));
|
||||||
|
if (infoListAdapter == null) {
|
||||||
|
infoListAdapter = new InfoListAdapter(activity);
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
//noinspection unchecked
|
||||||
|
ArrayList<InfoItem> serializable = (ArrayList<InfoItem>) savedInstanceState.getSerializable(INFO_LIST_KEY);
|
||||||
|
infoListAdapter.addInfoItemList(serializable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistStreams.setAdapter(infoListAdapter);
|
||||||
|
headerRootLayout = activity.getLayoutInflater().inflate(R.layout.playlist_header, playlistStreams, false);
|
||||||
|
infoListAdapter.setHeader(headerRootLayout);
|
||||||
|
infoListAdapter.setFooter(activity.getLayoutInflater().inflate(R.layout.pignate_footer, playlistStreams, false));
|
||||||
|
|
||||||
|
headerBannerView = (ImageView) headerRootLayout.findViewById(R.id.playlist_banner_image);
|
||||||
|
headerAvatarView = (ImageView) headerRootLayout.findViewById(R.id.playlist_avatar_view);
|
||||||
|
headerTitleView = (TextView) headerRootLayout.findViewById(R.id.playlist_title_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void initListeners() {
|
||||||
|
super.initListeners();
|
||||||
|
|
||||||
|
infoListAdapter.setOnStreamInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void selected(int serviceId, String url, String title) {
|
||||||
|
if (DEBUG) Log.d(TAG, "selected() called with: serviceId = [" + serviceId + "], url = [" + url + "], title = [" + title + "]");
|
||||||
|
NavigationHelper.openVideoDetailFragment(getFragmentManager(), serviceId, url, title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playlistStreams.clearOnScrollListeners();
|
||||||
|
playlistStreams.addOnScrollListener(new OnScrollBelowItemsListener() {
|
||||||
|
@Override
|
||||||
|
public void onScrolledDown(RecyclerView recyclerView) {
|
||||||
|
loadMore(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void reloadContent() {
|
||||||
|
if (DEBUG) Log.d(TAG, "reloadContent() called");
|
||||||
|
currentPlaylistInfo = null;
|
||||||
|
infoListAdapter.clearStreamItemList();
|
||||||
|
loadPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Playlist Loader
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private StreamingService getService(final int serviceId) throws ExtractionException {
|
||||||
|
return NewPipe.getService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Disposable disposable;
|
||||||
|
|
||||||
|
private void loadMore(final boolean onlyVideos) {
|
||||||
|
final Callable<PlayListInfo> task = new Callable<PlayListInfo>() {
|
||||||
|
@Override
|
||||||
|
public PlayListInfo call() throws Exception {
|
||||||
|
final PlayListExtractor extractor = getService(serviceId)
|
||||||
|
.getPlayListExtractorInstance(playlistUrl, pageNumber);
|
||||||
|
|
||||||
|
return PlayListInfo.getInfo(extractor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Observable.fromCallable(task)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(new Observer<PlayListInfo>() {
|
||||||
|
@Override
|
||||||
|
public void onSubscribe(@NonNull Disposable d) {
|
||||||
|
if (disposable == null || disposable.isDisposed()) {
|
||||||
|
disposable = d;
|
||||||
|
isLoading.set(true);
|
||||||
|
} else {
|
||||||
|
d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(@NonNull PlayListInfo playListInfo) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onReceive() called with: info = [" + playListInfo + "]");
|
||||||
|
if (playListInfo == null || isRemoving() || !isVisible()) return;
|
||||||
|
|
||||||
|
handlePlayListInfo(playListInfo, onlyVideos, true);
|
||||||
|
isLoading.set(false);
|
||||||
|
pageNumber++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(@NonNull Throwable e) {
|
||||||
|
onRxError(e, "Observer failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onComplete() {
|
||||||
|
if (disposable != null) {
|
||||||
|
disposable.dispose();
|
||||||
|
disposable = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private void loadPage(int page) {
|
||||||
|
if (DEBUG) Log.d(TAG, "loadPage() called with: page = [" + page + "]");
|
||||||
|
isLoading.set(true);
|
||||||
|
pageNumber = page;
|
||||||
|
infoListAdapter.showFooter(false);
|
||||||
|
|
||||||
|
animateView(loadingProgressBar, true, 200);
|
||||||
|
animateView(errorPanel, false, 200);
|
||||||
|
|
||||||
|
imageLoader.cancelDisplayTask(headerBannerView);
|
||||||
|
imageLoader.cancelDisplayTask(headerAvatarView);
|
||||||
|
|
||||||
|
headerTitleView.setText(playlistTitle != null ? playlistTitle : "");
|
||||||
|
headerBannerView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.channel_banner));
|
||||||
|
headerAvatarView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.buddy));
|
||||||
|
if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(playlistTitle != null ? playlistTitle : "");
|
||||||
|
|
||||||
|
loadMore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setPlaylist(int serviceId, String playlistUrl, String title) {
|
||||||
|
this.serviceId = serviceId;
|
||||||
|
this.playlistUrl = playlistUrl;
|
||||||
|
this.playlistTitle = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePlayListInfo(PlayListInfo info, boolean onlyVideos, boolean addVideos) {
|
||||||
|
currentPlaylistInfo = info;
|
||||||
|
|
||||||
|
animateView(errorPanel, false, 300);
|
||||||
|
animateView(playlistStreams, true, 200);
|
||||||
|
animateView(loadingProgressBar, false, 200);
|
||||||
|
|
||||||
|
if (!onlyVideos) {
|
||||||
|
if (activity.getSupportActionBar() != null) activity.getSupportActionBar().invalidateOptionsMenu();
|
||||||
|
|
||||||
|
headerRootLayout.setVisibility(View.VISIBLE);
|
||||||
|
//animateView(loadingProgressBar, false, 200, null);
|
||||||
|
|
||||||
|
if (!TextUtils.isEmpty(info.playList_name)) {
|
||||||
|
if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(info.playList_name);
|
||||||
|
headerTitleView.setText(info.playList_name);
|
||||||
|
playlistTitle = info.playList_name;
|
||||||
|
} else playlistTitle = "";
|
||||||
|
|
||||||
|
if (!TextUtils.isEmpty(info.banner_url)) {
|
||||||
|
imageLoader.displayImage(info.banner_url, headerBannerView, displayImageOptions, new ImageErrorLoadingListener(activity, getView(), info.service_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TextUtils.isEmpty(info.avatar_url)) {
|
||||||
|
headerAvatarView.setVisibility(View.VISIBLE);
|
||||||
|
imageLoader.displayImage(info.avatar_url, headerAvatarView, displayImageOptions, new ImageErrorLoadingListener(activity, getView(), info.service_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
infoListAdapter.showFooter(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNextPage = info.hasNextPage;
|
||||||
|
if (!hasNextPage) infoListAdapter.showFooter(false);
|
||||||
|
|
||||||
|
//if (!listRestored) {
|
||||||
|
if (addVideos) infoListAdapter.addInfoItemList(info.related_streams);
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setErrorMessage(String message, boolean showRetryButton) {
|
||||||
|
super.setErrorMessage(message, showRetryButton);
|
||||||
|
|
||||||
|
animateView(playlistStreams, false, 200);
|
||||||
|
currentPlaylistInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Error Handlers
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private void onRxError(final Throwable exception, final String tag) {
|
||||||
|
if (exception instanceof IOException) {
|
||||||
|
onRecoverableError(R.string.network_error);
|
||||||
|
} else {
|
||||||
|
onUnrecoverableError(exception, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onRecoverableError(int messageId) {
|
||||||
|
if (!this.isAdded()) return;
|
||||||
|
|
||||||
|
if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]");
|
||||||
|
setErrorMessage(getString(messageId), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onUnrecoverableError(Throwable exception, final String tag) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]");
|
||||||
|
ErrorActivity.reportError(
|
||||||
|
getContext(),
|
||||||
|
exception,
|
||||||
|
MainActivity.class,
|
||||||
|
null,
|
||||||
|
ErrorActivity.ErrorInfo.make(UserAction.REQUESTED_PLAYLIST, "Feed", tag, R.string.general_error)
|
||||||
|
);
|
||||||
|
|
||||||
|
activity.finish();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
package org.schabi.newpipe.fragments.search;
|
||||||
|
|
||||||
|
public class PlaylistService {
|
||||||
|
}
|
@ -0,0 +1,496 @@
|
|||||||
|
package org.schabi.newpipe.fragments.subscription;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.support.v7.app.ActionBar;
|
||||||
|
import android.support.v7.widget.LinearLayoutManager;
|
||||||
|
import android.support.v7.widget.RecyclerView;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import com.jakewharton.rxbinding2.view.RxView;
|
||||||
|
|
||||||
|
import org.reactivestreams.Subscriber;
|
||||||
|
import org.reactivestreams.Subscription;
|
||||||
|
import org.schabi.newpipe.MainActivity;
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
|
import org.schabi.newpipe.fragments.BaseFragment;
|
||||||
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
|
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||||
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import io.reactivex.Flowable;
|
||||||
|
import io.reactivex.MaybeObserver;
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.annotations.NonNull;
|
||||||
|
import io.reactivex.disposables.Disposable;
|
||||||
|
import io.reactivex.functions.Consumer;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.report.UserAction.REQUESTED_CHANNEL;
|
||||||
|
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
||||||
|
|
||||||
|
public class FeedFragment extends BaseFragment {
|
||||||
|
private static final String VIEW_STATE_KEY = "view_state_key";
|
||||||
|
private static final String INFO_ITEMS_KEY = "info_items_key";
|
||||||
|
|
||||||
|
private static final int FEED_LOAD_SIZE = 4;
|
||||||
|
private static final int LOAD_ITEM_DEBOUNCE_INTERVAL = 500;
|
||||||
|
|
||||||
|
private final String TAG = "FeedFragment@" + Integer.toHexString(hashCode());
|
||||||
|
|
||||||
|
private View inflatedView;
|
||||||
|
private View emptyPanel;
|
||||||
|
private View loadItemFooter;
|
||||||
|
|
||||||
|
private InfoListAdapter infoListAdapter;
|
||||||
|
private RecyclerView resultRecyclerView;
|
||||||
|
|
||||||
|
private Parcelable viewState;
|
||||||
|
private AtomicBoolean retainFeedItems;
|
||||||
|
|
||||||
|
private SubscriptionEngine subscriptionEngine;
|
||||||
|
|
||||||
|
private Disposable loadItemObserver;
|
||||||
|
private Disposable subscriptionObserver;
|
||||||
|
private Subscription feedSubscriber;
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment LifeCycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
subscriptionEngine = SubscriptionEngine.getInstance(getContext());
|
||||||
|
|
||||||
|
retainFeedItems = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
if (infoListAdapter == null) {
|
||||||
|
infoListAdapter = new InfoListAdapter(getActivity());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
// Get recycler view state
|
||||||
|
viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY);
|
||||||
|
|
||||||
|
// Deserialize and get recycler adapter list
|
||||||
|
final Object[] serializedInfoItems = (Object[]) savedInstanceState.getSerializable(INFO_ITEMS_KEY);
|
||||||
|
if (serializedInfoItems != null) {
|
||||||
|
final InfoItem[] infoItems = Arrays.copyOf(
|
||||||
|
serializedInfoItems,
|
||||||
|
serializedInfoItems.length,
|
||||||
|
InfoItem[].class
|
||||||
|
);
|
||||||
|
final List<InfoItem> feedInfos = Arrays.asList(infoItems);
|
||||||
|
infoListAdapter.addInfoItemList( feedInfos );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already displayed feed items survive configuration changes
|
||||||
|
retainFeedItems.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||||
|
if (inflatedView == null) {
|
||||||
|
inflatedView = inflater.inflate(R.layout.fragment_subscription, container, false);
|
||||||
|
}
|
||||||
|
return inflatedView;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSaveInstanceState(Bundle outState) {
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
|
||||||
|
if (resultRecyclerView != null) {
|
||||||
|
outState.putParcelable(
|
||||||
|
VIEW_STATE_KEY,
|
||||||
|
resultRecyclerView.getLayoutManager().onSaveInstanceState()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoListAdapter != null) {
|
||||||
|
outState.putSerializable(INFO_ITEMS_KEY, infoListAdapter.getItemsList().toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
// Do not monitor for updates when user is not viewing the feed fragment.
|
||||||
|
// This is a waste of bandwidth.
|
||||||
|
if (loadItemObserver != null) loadItemObserver.dispose();
|
||||||
|
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||||
|
if (feedSubscriber != null) feedSubscriber.cancel();
|
||||||
|
|
||||||
|
loadItemObserver = null;
|
||||||
|
subscriptionObserver = null;
|
||||||
|
feedSubscriber = null;
|
||||||
|
|
||||||
|
loadItemFooter = null;
|
||||||
|
|
||||||
|
// Retain the already displayed items for backstack pops
|
||||||
|
retainFeedItems.set(true);
|
||||||
|
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
subscriptionEngine = null;
|
||||||
|
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Views
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||||
|
super.onCreateOptionsMenu(menu, inflater);
|
||||||
|
|
||||||
|
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||||
|
if (supportActionBar != null) {
|
||||||
|
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||||
|
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecyclerView.OnScrollListener getOnScrollListener() {
|
||||||
|
return new RecyclerView.OnScrollListener() {
|
||||||
|
@Override
|
||||||
|
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
|
||||||
|
super.onScrollStateChanged(recyclerView, newState);
|
||||||
|
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||||
|
viewState = recyclerView.getLayoutManager().onSaveInstanceState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||||
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
|
if (infoListAdapter == null) return;
|
||||||
|
|
||||||
|
animateView(errorPanel, false, 200);
|
||||||
|
animateView(loadingProgressBar, true, 200);
|
||||||
|
|
||||||
|
emptyPanel = rootView.findViewById(R.id.empty_panel);
|
||||||
|
|
||||||
|
resultRecyclerView = rootView.findViewById(R.id.result_list_view);
|
||||||
|
resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity));
|
||||||
|
|
||||||
|
loadItemFooter = activity.getLayoutInflater().inflate(R.layout.load_item_footer, resultRecyclerView, false);
|
||||||
|
infoListAdapter.setFooter(loadItemFooter);
|
||||||
|
infoListAdapter.showFooter(false);
|
||||||
|
infoListAdapter.setOnStreamInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void selected(int serviceId, String url, String title) {
|
||||||
|
NavigationHelper.openVideoDetailFragment(getFragmentManager(), serviceId, url, title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resultRecyclerView.setAdapter(infoListAdapter);
|
||||||
|
resultRecyclerView.addOnScrollListener(getOnScrollListener());
|
||||||
|
|
||||||
|
if (viewState != null) {
|
||||||
|
resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState);
|
||||||
|
viewState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(R.string.fragment_whats_new);
|
||||||
|
|
||||||
|
populateFeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetFragment() {
|
||||||
|
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||||
|
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void reloadContent() {
|
||||||
|
resetFragment();
|
||||||
|
populateFeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setErrorMessage(String message, boolean showRetryButton) {
|
||||||
|
super.setErrorMessage(message, showRetryButton);
|
||||||
|
|
||||||
|
resetFragment();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the state of the load item footer.
|
||||||
|
*
|
||||||
|
* If the current state of the feed is loaded, this displays the load item button and
|
||||||
|
* starts its reactor.
|
||||||
|
*
|
||||||
|
* Otherwise, show a spinner in place of the loader button. */
|
||||||
|
private void setLoader(final boolean isLoaded) {
|
||||||
|
if (loadItemFooter == null) return;
|
||||||
|
|
||||||
|
if (loadItemObserver != null) loadItemObserver.dispose();
|
||||||
|
|
||||||
|
if (isLoaded) {
|
||||||
|
loadItemObserver = getLoadItemObserver(loadItemFooter);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadItemFooter.findViewById(R.id.paginate_progress_bar).setVisibility(isLoaded ? View.GONE : View.VISIBLE);
|
||||||
|
loadItemFooter.findViewById(R.id.load_more_text).setVisibility(isLoaded ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Feeds Loader
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for reacting to subscription database updates and displaying feeds.
|
||||||
|
*
|
||||||
|
* Upon each update, the feed info list is cleared unless the fragment is
|
||||||
|
* recently recovered from a configuration change or backstack.
|
||||||
|
*
|
||||||
|
* All existing and pending feed requests are dropped.
|
||||||
|
*
|
||||||
|
* The newly received list of subscriptions is then transformed into a
|
||||||
|
* flowable, reacting to pulling requests.
|
||||||
|
*
|
||||||
|
* Pulled requests are transformed first into ChannelInfo, then Stream Info items and
|
||||||
|
* displayed on the feed fragment.
|
||||||
|
**/
|
||||||
|
private void populateFeed() {
|
||||||
|
final Consumer<List<SubscriptionEntity>> consumer = new Consumer<List<SubscriptionEntity>>() {
|
||||||
|
@Override
|
||||||
|
public void accept(@NonNull List<SubscriptionEntity> subscriptionEntities) throws Exception {
|
||||||
|
animateView(loadingProgressBar, false, 200);
|
||||||
|
|
||||||
|
if (subscriptionEntities.isEmpty()) {
|
||||||
|
infoListAdapter.clearStreamItemList();
|
||||||
|
emptyPanel.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
emptyPanel.setVisibility(View.INVISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// show progress bar on receiving a non-empty updated list of subscriptions
|
||||||
|
if (!retainFeedItems.get() && !subscriptionEntities.isEmpty()) {
|
||||||
|
infoListAdapter.clearStreamItemList();
|
||||||
|
animateView(loadingProgressBar, true, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
retainFeedItems.set(false);
|
||||||
|
Flowable.fromIterable(subscriptionEntities)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(getSubscriptionObserver());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
final Consumer<Throwable> onError = new Consumer<Throwable>() {
|
||||||
|
@Override
|
||||||
|
public void accept(@NonNull Throwable exception) throws Exception {
|
||||||
|
onRxError(exception, "Subscription Database Reactor");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
||||||
|
subscriptionObserver = subscriptionEngine.getSubscription()
|
||||||
|
.onErrorReturnItem(Collections.<SubscriptionEntity>emptyList())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(consumer, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for reacting to user pulling request and starting a request for new feed stream.
|
||||||
|
*
|
||||||
|
* On initialization, it automatically requests the amount of feed needed to display
|
||||||
|
* a minimum amount required (FEED_LOAD_SIZE).
|
||||||
|
*
|
||||||
|
* Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
|
||||||
|
* containing the feed streams.
|
||||||
|
**/
|
||||||
|
private Subscriber<SubscriptionEntity> getSubscriptionObserver() {
|
||||||
|
return new Subscriber<SubscriptionEntity>() {
|
||||||
|
@Override
|
||||||
|
public void onSubscribe(Subscription s) {
|
||||||
|
if (feedSubscriber != null) feedSubscriber.cancel();
|
||||||
|
feedSubscriber = s;
|
||||||
|
|
||||||
|
final int requestSize = FEED_LOAD_SIZE - infoListAdapter.getItemsList().size();
|
||||||
|
if (requestSize > 0) {
|
||||||
|
requestFeed(requestSize);
|
||||||
|
} else {
|
||||||
|
setLoader(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
animateView(loadingProgressBar, false, 200);
|
||||||
|
// Footer spinner persists until subscription list is exhausted.
|
||||||
|
infoListAdapter.showFooter(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(SubscriptionEntity subscriptionEntity) {
|
||||||
|
setLoader(false);
|
||||||
|
|
||||||
|
subscriptionEngine.getChannelInfo(subscriptionEntity)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.onErrorComplete()
|
||||||
|
.subscribe(getChannelInfoObserver());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable exception) {
|
||||||
|
onRxError(exception, "Feed Pull Reactor");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onComplete() {
|
||||||
|
infoListAdapter.showFooter(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On each request, a subscription item from the updated table is transformed
|
||||||
|
* into a ChannelInfo, containing the latest streams from the channel.
|
||||||
|
*
|
||||||
|
* Currently, the feed uses the first into from the list of streams.
|
||||||
|
*
|
||||||
|
* If chosen feed already displayed, then we request another feed from another
|
||||||
|
* subscription, until the subscription table runs out of new items.
|
||||||
|
*
|
||||||
|
* This Observer is self-contained and will dispose itself when complete. However, this
|
||||||
|
* does not obey the fragment lifecycle and may continue running in the background
|
||||||
|
* until it is complete. This is done due to RxJava2 no longer propagate errors once
|
||||||
|
* an observer is unsubscribed while the thread process is still running.
|
||||||
|
*
|
||||||
|
* To solve the above issue, we can either set a global RxJava Error Handler, or
|
||||||
|
* manage exceptions case by case. This should be done if the current implementation is
|
||||||
|
* too costly when dealing with larger subscription sets.
|
||||||
|
**/
|
||||||
|
private MaybeObserver<ChannelInfo> getChannelInfoObserver() {
|
||||||
|
return new MaybeObserver<ChannelInfo>() {
|
||||||
|
Disposable observer;
|
||||||
|
@Override
|
||||||
|
public void onSubscribe(Disposable d) {
|
||||||
|
observer = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called only when response is non-empty
|
||||||
|
@Override
|
||||||
|
public void onSuccess(ChannelInfo channelInfo) {
|
||||||
|
emptyPanel.setVisibility(View.INVISIBLE);
|
||||||
|
|
||||||
|
if (infoListAdapter == null || channelInfo.related_streams.isEmpty()) return;
|
||||||
|
|
||||||
|
final InfoItem item = channelInfo.related_streams.get(0);
|
||||||
|
// Keep requesting new items if the current one already exists
|
||||||
|
if (!doesItemExist(infoListAdapter.getItemsList(), item)) {
|
||||||
|
infoListAdapter.addInfoItem(item);
|
||||||
|
} else {
|
||||||
|
requestFeed(1);
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable exception) {
|
||||||
|
onRxError(exception, "Feed Display Reactor");
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called only when response is empty
|
||||||
|
@Override
|
||||||
|
public void onComplete() {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onDone() {
|
||||||
|
setLoader(true);
|
||||||
|
|
||||||
|
observer.dispose();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean doesItemExist(final List<InfoItem> items, final InfoItem item) {
|
||||||
|
for (final InfoItem existingItem: items) {
|
||||||
|
if (existingItem.infoType() == item.infoType() &&
|
||||||
|
existingItem.getTitle().equals(item.getTitle()) &&
|
||||||
|
existingItem.getLink().equals(item.getLink())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestFeed(final int count) {
|
||||||
|
if (feedSubscriber == null) return;
|
||||||
|
|
||||||
|
feedSubscriber.request(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Disposable getLoadItemObserver(@NonNull final View itemLoader) {
|
||||||
|
final Consumer<Object> onNext = new Consumer<Object>() {
|
||||||
|
@Override
|
||||||
|
public void accept(Object o) throws Exception {
|
||||||
|
requestFeed(FEED_LOAD_SIZE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
final Consumer<Throwable> onError = new Consumer<Throwable>() {
|
||||||
|
@Override
|
||||||
|
public void accept(Throwable throwable) throws Exception {
|
||||||
|
onRxError(throwable, "Load Button Reactor");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return RxView.clicks(itemLoader)
|
||||||
|
.debounce(LOAD_ITEM_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
|
||||||
|
.subscribe(onNext, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Error Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private void onRxError(final Throwable exception, final String tag) {
|
||||||
|
if (exception instanceof IOException) {
|
||||||
|
onRecoverableError(R.string.network_error);
|
||||||
|
} else {
|
||||||
|
onUnrecoverableError(exception, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onRecoverableError(int messageId) {
|
||||||
|
if (!this.isAdded()) return;
|
||||||
|
|
||||||
|
if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]");
|
||||||
|
setErrorMessage(getString(messageId), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onUnrecoverableError(Throwable exception, final String tag) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]");
|
||||||
|
ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "Feed", tag, R.string.general_error));
|
||||||
|
|
||||||
|
activity.finish();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,170 @@
|
|||||||
|
package org.schabi.newpipe.fragments.subscription;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase;
|
||||||
|
import org.schabi.newpipe.database.AppDatabase;
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import io.reactivex.Completable;
|
||||||
|
import io.reactivex.CompletableSource;
|
||||||
|
import io.reactivex.Flowable;
|
||||||
|
import io.reactivex.Maybe;
|
||||||
|
import io.reactivex.Scheduler;
|
||||||
|
import io.reactivex.annotations.NonNull;
|
||||||
|
import io.reactivex.functions.Function;
|
||||||
|
import io.reactivex.schedulers.Schedulers;
|
||||||
|
|
||||||
|
/** Subscription Service singleton:
|
||||||
|
* Provides a basis for channel Subscriptions.
|
||||||
|
* Provides access to subscription table in database as well as
|
||||||
|
* up-to-date observations on the subscribed channels
|
||||||
|
* */
|
||||||
|
public class SubscriptionEngine {
|
||||||
|
|
||||||
|
private static SubscriptionEngine sInstance;
|
||||||
|
private static final Object LOCK = new Object();
|
||||||
|
|
||||||
|
public static SubscriptionEngine getInstance(Context context) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
synchronized (LOCK) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
sInstance = new SubscriptionEngine(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected final String TAG = "SubscriptionEngine@" + Integer.toHexString(hashCode());
|
||||||
|
private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
|
||||||
|
private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
|
||||||
|
|
||||||
|
private AppDatabase db;
|
||||||
|
private Flowable<List<SubscriptionEntity>> subscription;
|
||||||
|
|
||||||
|
private Scheduler subscriptionScheduler;
|
||||||
|
|
||||||
|
private SubscriptionEngine(Context context) {
|
||||||
|
db = NewPipeDatabase.getInstance( context );
|
||||||
|
subscription = getSubscriptionInfos();
|
||||||
|
|
||||||
|
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
|
||||||
|
subscriptionScheduler = Schedulers.from(subscriptionExecutor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Part of subscription observation pipeline
|
||||||
|
* @see SubscriptionEngine#getSubscription()
|
||||||
|
*/
|
||||||
|
private Flowable<List<SubscriptionEntity>> getSubscriptionInfos() {
|
||||||
|
return subscriptionTable().findAll()
|
||||||
|
// Wait for a period of infrequent updates and return the latest update
|
||||||
|
.debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
|
||||||
|
.share() // Share allows multiple subscribers on the same observable
|
||||||
|
.replay(1) // Replay synchronizes subscribers to the last emitted result
|
||||||
|
.autoConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an observer to the latest update to the subscription table.
|
||||||
|
*
|
||||||
|
* This observer may be subscribed multiple times, where each subscriber obtains
|
||||||
|
* the latest synchronized changes available, effectively share the same data
|
||||||
|
* across all subscribers.
|
||||||
|
*
|
||||||
|
* This observer has a debounce cooldown, meaning if multiple updates are observed
|
||||||
|
* in the cooldown interval, only the latest changes are emitted to the subscribers.
|
||||||
|
* This reduces the amount of observations caused by frequent updates to the database.
|
||||||
|
* */
|
||||||
|
@android.support.annotation.NonNull
|
||||||
|
public Flowable<List<SubscriptionEntity>> getSubscription() {
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
|
||||||
|
final StreamingService service = getService(subscriptionEntity.getServiceId());
|
||||||
|
if (service == null) return Maybe.empty();
|
||||||
|
|
||||||
|
final String url = subscriptionEntity.getUrl();
|
||||||
|
final Callable<ChannelInfo> callable = new Callable<ChannelInfo>() {
|
||||||
|
@Override
|
||||||
|
public ChannelInfo call() throws Exception {
|
||||||
|
final ChannelExtractor extractor = service.getChannelExtractorInstance(url, 0);
|
||||||
|
return ChannelInfo.getInfo(extractor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Maybe.fromCallable(callable).subscribeOn(subscriptionScheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamingService getService(final int serviceId) {
|
||||||
|
try {
|
||||||
|
return NewPipe.getService(serviceId);
|
||||||
|
} catch (ExtractionException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the database access interface for subscription table. */
|
||||||
|
public SubscriptionDAO subscriptionTable() {
|
||||||
|
return db.subscriptionDAO();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Completable updateChannelInfo(final int serviceId,
|
||||||
|
final String channelUrl,
|
||||||
|
final ChannelInfo info) {
|
||||||
|
final Function<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
|
||||||
|
@Override
|
||||||
|
public CompletableSource apply(@NonNull List<SubscriptionEntity> subscriptionEntities) throws Exception {
|
||||||
|
if (subscriptionEntities.size() == 1) {
|
||||||
|
SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||||
|
|
||||||
|
// Subscriber count changes very often, making this check almost unnecessary.
|
||||||
|
// Consider removing it later.
|
||||||
|
if (isSubscriptionUpToDate(channelUrl, info, subscription)) {
|
||||||
|
subscription.setData(info.channel_name, info.avatar_url, "", info.subscriberCount);
|
||||||
|
|
||||||
|
return update(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Completable.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return subscriptionTable().findAll(serviceId, channelUrl)
|
||||||
|
.firstOrError()
|
||||||
|
.flatMapCompletable(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Completable update(final SubscriptionEntity updatedSubscription) {
|
||||||
|
return Completable.fromRunnable(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
subscriptionTable().update(updatedSubscription);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSubscriptionUpToDate(final String channelUrl,
|
||||||
|
final ChannelInfo info,
|
||||||
|
final SubscriptionEntity entity) {
|
||||||
|
return channelUrl.equals( entity.getUrl() ) &&
|
||||||
|
info.service_id == entity.getServiceId() &&
|
||||||
|
info.channel_name.equals( entity.getTitle() ) &&
|
||||||
|
info.avatar_url.equals( entity.getThumbnailUrl() ) &&
|
||||||
|
info.subscriberCount == entity.getSubscriberCount();
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,7 @@
|
|||||||
package org.schabi.newpipe.fragments.subscription;
|
package org.schabi.newpipe.fragments.subscription;
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Parcelable;
|
import android.os.Parcelable;
|
||||||
import android.support.annotation.NonNull;
|
|
||||||
import android.support.annotation.Nullable;
|
import android.support.annotation.Nullable;
|
||||||
import android.support.v7.widget.LinearLayoutManager;
|
import android.support.v7.widget.LinearLayoutManager;
|
||||||
import android.support.v7.widget.RecyclerView;
|
import android.support.v7.widget.RecyclerView;
|
||||||
@ -12,43 +10,50 @@ import android.view.LayoutInflater;
|
|||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
import org.schabi.newpipe.fragments.BaseFragment;
|
||||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
import org.schabi.newpipe.util.KioskTranslator;
|
import org.schabi.newpipe.util.KioskTranslator;
|
||||||
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import icepick.State;
|
|
||||||
import io.reactivex.Observer;
|
import io.reactivex.Observer;
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
import io.reactivex.disposables.CompositeDisposable;
|
||||||
import io.reactivex.disposables.Disposable;
|
import io.reactivex.disposables.Disposable;
|
||||||
import io.reactivex.schedulers.Schedulers;
|
import io.reactivex.schedulers.Schedulers;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.report.UserAction.REQUESTED_CHANNEL;
|
||||||
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
||||||
|
|
||||||
public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEntity>> {
|
public class SubscriptionFragment extends BaseFragment {
|
||||||
|
private static final String VIEW_STATE_KEY = "view_state_key";
|
||||||
|
private final String TAG = "SubscriptionFragment@" + Integer.toHexString(hashCode());
|
||||||
|
|
||||||
|
private View inflatedView;
|
||||||
|
private View emptyPanel;
|
||||||
private View headerRootLayout;
|
private View headerRootLayout;
|
||||||
|
private View whatsNewView;
|
||||||
|
|
||||||
private InfoListAdapter infoListAdapter;
|
private InfoListAdapter infoListAdapter;
|
||||||
private RecyclerView itemsList;
|
private RecyclerView resultRecyclerView;
|
||||||
|
private Parcelable viewState;
|
||||||
@State
|
|
||||||
protected Parcelable itemsListState;
|
|
||||||
|
|
||||||
/* Used for independent events */
|
/* Used for independent events */
|
||||||
private CompositeDisposable disposables = new CompositeDisposable();
|
private CompositeDisposable disposables;
|
||||||
private SubscriptionService subscriptionService;
|
private SubscriptionEngine subscriptionEngine;
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
// Fragment LifeCycle
|
// Fragment LifeCycle
|
||||||
@ -66,10 +71,15 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(Context context) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
super.onAttach(context);
|
super.onCreate(savedInstanceState);
|
||||||
infoListAdapter = new InfoListAdapter(activity);
|
|
||||||
subscriptionService = SubscriptionService.getInstance();
|
disposables = new CompositeDisposable();
|
||||||
|
subscriptionEngine = SubscriptionEngine.getInstance( getContext() );
|
||||||
|
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@ -84,15 +94,19 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPause() {
|
public void onSaveInstanceState(Bundle outState) {
|
||||||
super.onPause();
|
super.onSaveInstanceState(outState);
|
||||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
|
||||||
|
outState.putParcelable(VIEW_STATE_KEY, viewState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroyView() {
|
public void onDestroyView() {
|
||||||
if (disposables != null) disposables.clear();
|
if (disposables != null) disposables.clear();
|
||||||
|
|
||||||
|
headerRootLayout = null;
|
||||||
|
whatsNewView = null;
|
||||||
|
|
||||||
super.onDestroyView();
|
super.onDestroyView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +114,8 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
if (disposables != null) disposables.dispose();
|
if (disposables != null) disposables.dispose();
|
||||||
disposables = null;
|
disposables = null;
|
||||||
subscriptionService = null;
|
|
||||||
|
subscriptionEngine = null;
|
||||||
|
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
@ -109,39 +124,70 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
// Fragment Views
|
// Fragment Views
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Override
|
private RecyclerView.OnScrollListener getOnScrollListener() {
|
||||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
return new RecyclerView.OnScrollListener() {
|
||||||
super.initViews(rootView, savedInstanceState);
|
@Override
|
||||||
|
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
|
||||||
infoListAdapter = new InfoListAdapter(getActivity());
|
super.onScrollStateChanged(recyclerView, newState);
|
||||||
itemsList = rootView.findViewById(R.id.items_list);
|
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||||
itemsList.setLayoutManager(new LinearLayoutManager(activity));
|
viewState = recyclerView.getLayoutManager().onSaveInstanceState();
|
||||||
|
}
|
||||||
infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
|
}
|
||||||
infoListAdapter.useMiniItemVariants(true);
|
};
|
||||||
|
|
||||||
itemsList.setAdapter(infoListAdapter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private View.OnClickListener getWhatsNewOnClickListener() {
|
||||||
protected void initListeners() {
|
return new View.OnClickListener() {
|
||||||
super.initListeners();
|
|
||||||
|
|
||||||
infoListAdapter.setOnChannelSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener<ChannelInfoItem>() {
|
|
||||||
@Override
|
|
||||||
public void selected(ChannelInfoItem selectedItem) {
|
|
||||||
// Requires the parent fragment to find holder for fragment replacement
|
|
||||||
NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), selectedItem.service_id, selectedItem.url, selectedItem.name);
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
headerRootLayout.setOnClickListener(new View.OnClickListener() {
|
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View view) {
|
public void onClick(View view) {
|
||||||
NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager());
|
NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager());
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||||
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
|
emptyPanel = rootView.findViewById(R.id.empty_panel);
|
||||||
|
|
||||||
|
resultRecyclerView = rootView.findViewById(R.id.result_list_view);
|
||||||
|
resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity));
|
||||||
|
resultRecyclerView.addOnScrollListener(getOnScrollListener());
|
||||||
|
|
||||||
|
if (infoListAdapter == null) {
|
||||||
|
infoListAdapter = new InfoListAdapter(getActivity());
|
||||||
|
infoListAdapter.setFooter(activity.getLayoutInflater().inflate(R.layout.pignate_footer, resultRecyclerView, false));
|
||||||
|
infoListAdapter.showFooter(false);
|
||||||
|
infoListAdapter.setOnChannelInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void selected(int serviceId, String url, String title) {
|
||||||
|
/* Requires the parent fragment to find holder for fragment replacement */
|
||||||
|
NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), serviceId, url, title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, resultRecyclerView, false);
|
||||||
|
infoListAdapter.setHeader(headerRootLayout);
|
||||||
|
|
||||||
|
whatsNewView = headerRootLayout.findViewById(R.id.whatsNew);
|
||||||
|
whatsNewView.setOnClickListener(getWhatsNewOnClickListener());
|
||||||
|
|
||||||
|
resultRecyclerView.setAdapter(infoListAdapter);
|
||||||
|
|
||||||
|
populateView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void reloadContent() {
|
||||||
|
populateView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setErrorMessage(String message, boolean showRetryButton) {
|
||||||
|
super.setErrorMessage(message, showRetryButton);
|
||||||
|
resetFragment();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resetFragment() {
|
private void resetFragment() {
|
||||||
@ -153,12 +199,13 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
// Subscriptions Loader
|
// Subscriptions Loader
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Override
|
private void populateView() {
|
||||||
public void startLoading(boolean forceLoad) {
|
|
||||||
super.startLoading(forceLoad);
|
|
||||||
resetFragment();
|
resetFragment();
|
||||||
|
|
||||||
subscriptionService.getSubscription().toObservable()
|
animateView(loadingProgressBar, true, 200);
|
||||||
|
animateView(errorPanel, false, 200);
|
||||||
|
|
||||||
|
subscriptionEngine.getSubscription().toObservable()
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(getSubscriptionObserver());
|
.subscribe(getSubscriptionObserver());
|
||||||
@ -168,91 +215,80 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||||||
return new Observer<List<SubscriptionEntity>>() {
|
return new Observer<List<SubscriptionEntity>>() {
|
||||||
@Override
|
@Override
|
||||||
public void onSubscribe(Disposable d) {
|
public void onSubscribe(Disposable d) {
|
||||||
showLoading();
|
animateView(loadingProgressBar, true, 200);
|
||||||
disposables.add(d);
|
|
||||||
|
disposables.add( d );
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNext(List<SubscriptionEntity> subscriptions) {
|
public void onNext(List<SubscriptionEntity> subscriptions) {
|
||||||
handleResult(subscriptions);
|
animateView(loadingProgressBar, true, 200);
|
||||||
|
|
||||||
|
infoListAdapter.clearStreamItemList();
|
||||||
|
infoListAdapter.addInfoItemList( getSubscriptionItems(subscriptions) );
|
||||||
|
|
||||||
|
animateView(loadingProgressBar, false, 200);
|
||||||
|
|
||||||
|
emptyPanel.setVisibility(subscriptions.isEmpty() ? View.VISIBLE : View.INVISIBLE);
|
||||||
|
|
||||||
|
if (viewState != null && resultRecyclerView != null) {
|
||||||
|
resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(Throwable exception) {
|
public void onError(Throwable exception) {
|
||||||
SubscriptionFragment.this.onError(exception);
|
if (exception instanceof IOException) {
|
||||||
|
onRecoverableError(R.string.network_error);
|
||||||
|
} else {
|
||||||
|
onUnrecoverableError(exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onComplete() {
|
public void onComplete() {
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleResult(@NonNull List<SubscriptionEntity> result) {
|
|
||||||
super.handleResult(result);
|
|
||||||
|
|
||||||
infoListAdapter.clearStreamItemList();
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
showEmptyState();
|
|
||||||
} else {
|
|
||||||
infoListAdapter.addInfoItemList(getSubscriptionItems(result));
|
|
||||||
if (itemsListState != null) {
|
|
||||||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
|
||||||
itemsListState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
hideLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
|
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
|
||||||
List<InfoItem> items = new ArrayList<>();
|
List<InfoItem> items = new ArrayList<>();
|
||||||
for (final SubscriptionEntity subscription : subscriptions) items.add(subscription.toChannelInfoItem());
|
for (final SubscriptionEntity subscription: subscriptions) {
|
||||||
|
ChannelInfoItem item = new ChannelInfoItem();
|
||||||
|
item.webPageUrl = subscription.getUrl();
|
||||||
|
item.serviceId = subscription.getServiceId();
|
||||||
|
item.channelName = subscription.getTitle();
|
||||||
|
item.thumbnailUrl = subscription.getThumbnailUrl();
|
||||||
|
item.subscriberCount = subscription.getSubscriberCount();
|
||||||
|
item.description = subscription.getDescription();
|
||||||
|
|
||||||
|
items.add( item );
|
||||||
|
}
|
||||||
Collections.sort(items, new Comparator<InfoItem>() {
|
Collections.sort(items, new Comparator<InfoItem>() {
|
||||||
@Override
|
@Override
|
||||||
public int compare(InfoItem o1, InfoItem o2) {
|
public int compare(InfoItem o1, InfoItem o2) {
|
||||||
return o1.name.compareToIgnoreCase(o2.name);
|
return o1.getTitle().compareToIgnoreCase(o2.getTitle());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Contract
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showLoading() {
|
|
||||||
super.showLoading();
|
|
||||||
animateView(itemsList, false, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void hideLoading() {
|
|
||||||
super.hideLoading();
|
|
||||||
animateView(itemsList, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showEmptyState() {
|
|
||||||
super.showEmptyState();
|
|
||||||
animateView(itemsList, false, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
// Fragment Error Handling
|
// Fragment Error Handling
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Override
|
private void onRecoverableError(int messageId) {
|
||||||
protected boolean onError(Throwable exception) {
|
if (!this.isAdded()) return;
|
||||||
resetFragment();
|
|
||||||
if (super.onError(exception)) return true;
|
|
||||||
|
|
||||||
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error);
|
if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]");
|
||||||
return true;
|
setErrorMessage(getString(messageId), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onUnrecoverableError(Throwable exception) {
|
||||||
|
if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]");
|
||||||
|
ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "unknown", "unknown", R.string.general_error));
|
||||||
|
activity.finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
package org.schabi.newpipe.info_list;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created by Christian Schabesberger on 12.02.17.
|
||||||
|
*
|
||||||
|
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
|
||||||
|
* ChannelInfoItemHolder .java is part of NewPipe.
|
||||||
|
*
|
||||||
|
* NewPipe is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* NewPipe is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
public class PlaylistInfoItemHolder extends InfoItemHolder {
|
||||||
|
public final ImageView itemThumbnailView;
|
||||||
|
public final TextView itemPlaylistTitleView;
|
||||||
|
public final TextView itemAdditionalDetailView;
|
||||||
|
|
||||||
|
public final View itemRoot;
|
||||||
|
|
||||||
|
PlaylistInfoItemHolder(View v) {
|
||||||
|
super(v);
|
||||||
|
itemRoot = v.findViewById(R.id.itemRoot);
|
||||||
|
itemThumbnailView = v.findViewById(R.id.itemThumbnailView);
|
||||||
|
itemPlaylistTitleView = v.findViewById(R.id.itemPlaylistTitleView);
|
||||||
|
itemAdditionalDetailView = v.findViewById(R.id.itemAdditionalDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InfoItem.InfoType infoType() {
|
||||||
|
return InfoItem.InfoType.PLAYLIST;
|
||||||
|
}
|
||||||
|
}
|
51
app/src/main/res/layout/playlist_item.xml
Normal file
51
app/src/main/res/layout/playlist_item.xml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/itemRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/video_item_search_height"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:clickable="true"
|
||||||
|
android:padding="@dimen/video_item_search_padding">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/itemThumbnailView"
|
||||||
|
android:layout_width="@dimen/video_item_search_thumbnail_image_width"
|
||||||
|
android:layout_height="@dimen/video_item_search_thumbnail_image_height"
|
||||||
|
android:layout_alignParentLeft="true"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_marginRight="@dimen/video_item_search_image_right_margin"
|
||||||
|
android:contentDescription="@string/list_thumbnail_view_description"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
android:src="@drawable/dummy_thumbnail"
|
||||||
|
tools:ignore="RtlHardcoded"/>
|
||||||
|
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/itemPlaylistTitleView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_toRightOf="@id/itemThumbnailView"
|
||||||
|
android:layout_toEndOf="@id/itemThumbnailView"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:lines="3"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||||
|
android:textSize="@dimen/video_item_search_title_text_size"
|
||||||
|
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/itemAdditionalDetails"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:layout_toRightOf="@id/itemThumbnailView"
|
||||||
|
android:layout_toEndOf="@id/itemThumbnailView"
|
||||||
|
android:lines="1"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
|
android:textSize="@dimen/video_item_search_upload_date_text_size"
|
||||||
|
android:text="@string/playlist"/>
|
||||||
|
</RelativeLayout>
|
Loading…
Reference in New Issue
Block a user