Merge pull request #3371 from mauriciocolli/feed-hide-played-items

Add ability to hide played items in a feed
This commit is contained in:
Tobi 2021-06-18 09:18:48 +02:00 committed by GitHub
commit be676ad93c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 745 additions and 217 deletions

View file

@ -9,7 +9,8 @@ import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime
@ -20,21 +21,34 @@ abstract class FeedDAO {
@Query(
"""
SELECT s.* FROM streams s
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
abstract fun getAllStreams(): Flowable<List<StreamWithState>>
@Query(
"""
SELECT s.* FROM streams s
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
@ -42,16 +56,88 @@ abstract class FeedDAO {
INNER JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id
INNER JOIN feed_group fg
ON fg.uid = fgs.group_id
WHERE fgs.group_id = :groupId
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>>
abstract fun getAllStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
/**
* @see StreamStateEntity.isFinished()
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
* @return all of the non-live, never-played and non-finished streams in the feed
* (all of the cited conditions must hold for a stream to be in the returned list)
*/
@Query(
"""
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
WHERE (
sh.stream_id IS NULL
OR sst.stream_id IS NULL
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
OR sst.progress_time < s.duration * 1000 * 3 / 4
OR s.stream_type = 'LIVE_STREAM'
OR s.stream_type = 'AUDIO_LIVE_STREAM'
)
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreams(): Flowable<List<StreamWithState>>
/**
* @see StreamStateEntity.isFinished()
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
* @param groupId the group id to get streams of
* @return all of the non-live, never-played and non-finished streams for the given feed group
* (all of the cited conditions must hold for a stream to be in the returned list)
*/
@Query(
"""
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
INNER JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id
WHERE fgs.group_id = :groupId
AND (
sh.stream_id IS NULL
OR sst.stream_id IS NULL
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
OR sst.progress_time < s.duration * 1000 * 3 / 4
OR s.stream_type = 'LIVE_STREAM'
OR s.stream_type = 'AUDIO_LIVE_STREAM'
)
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
@Query(
"""

View file

@ -21,7 +21,7 @@ import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WA
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao
@ -80,7 +80,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
+ " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_TIME
+ STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
public abstract Flowable<List<StreamStatisticsEntry>> getStatistics();

View file

@ -12,8 +12,8 @@ data class PlaylistStreamEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_TIME, defaultValue = "0")
val progressTime: Long,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
val streamId: Long,

View file

@ -25,7 +25,7 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PL
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao
@ -64,7 +64,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_TIME
+ STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS

View file

@ -5,7 +5,7 @@ import androidx.room.Embedded
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import java.time.OffsetDateTime
@ -13,8 +13,8 @@ class StreamStatisticsEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = STREAM_PROGRESS_TIME, defaultValue = "0")
val progressTime: Long,
@ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
val streamId: Long,

View file

@ -0,0 +1,14 @@
package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
data class StreamWithState(
@Embedded
val stream: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
val stateProgressMillis: Long?
)

View file

@ -5,7 +5,7 @@ import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import java.util.concurrent.TimeUnit;
import java.util.Objects;
import static androidx.room.ForeignKey.CASCADE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
@ -25,26 +25,31 @@ public class StreamStateEntity {
// This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already
public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias";
public static final String STREAM_PROGRESS_TIME = "progress_time";
public static final String STREAM_PROGRESS_MILLIS = "progress_time";
/**
* Playback state will not be saved, if playback time is less than this threshold.
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
*/
private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5;
private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
/**
* Playback state will not be saved, if time left is less than this threshold.
* Stream will be considered finished if the playback time left exceeds this threshold
* (60000ms = 60s).
* @see #isFinished(long)
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
*/
private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10;
public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000;
@ColumnInfo(name = JOIN_STREAM_ID)
private long streamUid;
@ColumnInfo(name = STREAM_PROGRESS_TIME)
private long progressTime;
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
private long progressMillis;
public StreamStateEntity(final long streamUid, final long progressTime) {
public StreamStateEntity(final long streamUid, final long progressMillis) {
this.streamUid = streamUid;
this.progressTime = progressTime;
this.progressMillis = progressMillis;
}
public long getStreamUid() {
@ -55,27 +60,53 @@ public class StreamStateEntity {
this.streamUid = streamUid;
}
public long getProgressTime() {
return progressTime;
public long getProgressMillis() {
return progressMillis;
}
public void setProgressTime(final long progressTime) {
this.progressTime = progressTime;
public void setProgressMillis(final long progressMillis) {
this.progressMillis = progressMillis;
}
public boolean isValid(final int durationInSeconds) {
final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime);
return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS
&& seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS;
/**
* The state will be considered valid, and thus be saved, if the progress is more than {@link
* #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether this stream state entity should be saved or not
*/
public boolean isValid(final long durationInSeconds) {
return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|| progressMillis > durationInSeconds * 1000 / 4;
}
/**
* The video will be considered as finished, if the time left is less than {@link
* #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length.
* The state will be saved anyway, so that it can be shown under stream info items, but the
* player will not resume if a state is considered as finished. Finished streams are also the
* ones that can be filtered out in the feed fragment.
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether the stream is finished or not
*/
public boolean isFinished(final long durationInSeconds) {
return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
&& progressMillis >= durationInSeconds * 1000 * 3 / 4;
}
@Override
public boolean equals(@Nullable final Object obj) {
if (obj instanceof StreamStateEntity) {
return ((StreamStateEntity) obj).streamUid == streamUid
&& ((StreamStateEntity) obj).progressTime == progressTime;
&& ((StreamStateEntity) obj).progressMillis == progressMillis;
} else {
return false;
}
}
@Override
public int hashCode() {
return Objects.hash(streamUid, progressMillis);
}
}

View file

@ -130,7 +130,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
inflater.inflate(R.menu.main_fragment_menu, menu);
inflater.inflate(R.menu.menu_main_fragment, menu);
final ActionBar supportActionBar = activity.getSupportActionBar();
if (supportActionBar != null) {

View file

@ -1669,7 +1669,7 @@ public final class VideoDetailFragment
.onErrorComplete()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(state -> {
showPlaybackProgress(state.getProgressTime(), info.getDuration() * 1000);
showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000);
animate(binding.positionView, true, 500);
animate(binding.detailPositionView, true, 500);
}, e -> {

View file

@ -66,7 +66,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state2.getProgressTime()));
.toSeconds(state2.getProgressMillis()));
} else {
itemProgressView.setVisibility(View.GONE);
}
@ -121,10 +121,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
itemProgressView.setMax((int) item.getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
.toSeconds(state.getProgressMillis()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime()));
.toSeconds(state.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) {
fun database() = database
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> {
val streams = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams()
else -> feedTable.getAllStreamsFromGroup(groupId)
}
return streams.map {
val items = ArrayList<StreamInfoItem>(it.size)
it.mapTo(items) { stream -> stream.toStreamInfoItem() }
return@map items
fun getStreams(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
getPlayedStreams: Boolean = true
): Flowable<List<StreamWithState>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> {
if (getPlayedStreams) feedTable.getAllStreams()
else feedTable.getLiveOrNotPlayedStreams()
}
else -> {
if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId)
else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId)
}
}
}
@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) {
}
}
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) =
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
fun outdatedSubscriptionsForGroup(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
outdatedThreshold: OffsetDateTime
) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) {
}
feedTable.setLastUpdatedForSubscription(
FeedLastUpdatedEntity(
subscriptionId,
OffsetDateTime.now(ZoneOffset.UTC)
)
FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC))
)
}
@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) {
fun clear() {
feedTable.deleteAll()
val deletedOrphans = streamTable.deleteOrphans()
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
if (DEBUG) {
Log.d(
this::class.java.simpleName,
"clear() → streamTable.deleteOrphans() → $deletedOrphans"
)
}
}
// /////////////////////////////////////////////////////////////////////////
@ -122,7 +130,8 @@ class FeedDatabaseManager(context: Context) {
}
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
return Completable
.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}

View file

@ -19,7 +19,11 @@
package org.schabi.newpipe.local.feed
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
@ -30,11 +34,18 @@ import android.view.View
import android.view.ViewGroup
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.GroupieViewHolder
import com.xwray.groupie.Item
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener
import icepick.State
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
@ -49,33 +60,46 @@ import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.list.BaseListFragment
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.InfoItemDialog
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.StreamDialogEntry
import java.time.OffsetDateTime
import java.util.ArrayList
import kotlin.math.floor
import kotlin.math.max
class FeedFragment : BaseListFragment<FeedState, Unit>() {
class FeedFragment : BaseStateFragment<FeedState>() {
private var _feedBinding: FragmentFeedBinding? = null
private val feedBinding get() = _feedBinding!!
private val disposables = CompositeDisposable()
private lateinit var viewModel: FeedViewModel
@State
@JvmField
var listState: Parcelable? = null
@State @JvmField var listState: Parcelable? = null
private var groupId = FeedGroupEntity.GROUP_ALL_ID
private var groupName = ""
private var oldestSubscriptionUpdate: OffsetDateTime? = null
private lateinit var groupAdapter: GroupAdapter<GroupieViewHolder>
@State @JvmField var showPlayedItems: Boolean = true
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
private var updateListViewModeOnResume = false
init {
setHasOptionsMenu(true)
setUseDefaultStateSaving(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -84,6 +108,14 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
?: FeedGroupEntity.GROUP_ALL_ID
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key.equals(getString(R.string.list_view_mode_key))) {
updateListViewModeOnResume = true
}
}
PreferenceManager.getDefaultSharedPreferences(activity)
.registerOnSharedPreferenceChangeListener(onSettingsChangeListener)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -95,8 +127,17 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
_feedBinding = FragmentFeedBinding.bind(rootView)
super.onViewCreated(rootView, savedInstanceState)
viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems)
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
groupAdapter = GroupAdapter<GroupieViewHolder>().apply {
setOnItemClickListener(listenerStreamItem)
setOnItemLongClickListener(listenerStreamItem)
}
feedBinding.itemsList.adapter = groupAdapter
setupListViewMode()
}
override fun onPause() {
@ -107,6 +148,23 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun onResume() {
super.onResume()
updateRelativeTimeViews()
if (updateListViewModeOnResume) {
updateListViewModeOnResume = false
setupListViewMode()
if (viewModel.stateLiveData.value != null) {
handleResult(viewModel.stateLiveData.value!!)
}
}
}
fun setupListViewMode() {
// does everything needed to setup the layouts for grid or list modes
groupAdapter.spanCount = if (shouldUseGridLayout()) getGridSpanCount() else 1
feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
spanSizeLookup = groupAdapter.spanSizeLookup
}
}
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
@ -129,21 +187,21 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
activity.supportActionBar?.subtitle = groupName
inflater.inflate(R.menu.menu_feed_fragment, menu)
if (useAsFrontPage) {
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
val usingDedicatedMethod = sharedPreferences
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
val enableDisableButtonText = when {
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
else -> R.string.feed_use_dedicated_fetch_method_enable_button
@ -160,6 +218,10 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
.create()
.show()
return true
} else if (item.itemId == R.id.menu_item_feed_toggle_played_items) {
showPlayedItems = !item.isChecked
updateTogglePlayedItemsButton(item)
viewModel.togglePlayedItems(showPlayedItems)
}
return super.onOptionsItemSelected(item)
@ -172,18 +234,33 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun onDestroy() {
disposables.dispose()
if (onSettingsChangeListener != null) {
PreferenceManager.getDefaultSharedPreferences(activity)
.unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener)
onSettingsChangeListener = null
}
super.onDestroy()
activity?.supportActionBar?.subtitle = null
}
override fun onDestroyView() {
feedBinding.itemsList.adapter = null
_feedBinding = null
super.onDestroyView()
}
// /////////////////////////////////////////////////////////////////////////
private fun updateTogglePlayedItemsButton(menuItem: MenuItem) {
menuItem.isChecked = showPlayedItems
menuItem.icon = AppCompatResources.getDrawable(
requireContext(),
if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off
)
}
// //////////////////////////////////////////////////////////////////////////
// Handling
// /////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////
override fun showLoading() {
super.showLoading()
@ -195,6 +272,7 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun hideLoading() {
super.hideLoading()
feedBinding.itemsList.animate(true, 0)
feedBinding.refreshRootView.animate(true, 200)
feedBinding.loadingProgressText.animate(false, 0)
feedBinding.swipeRefreshLayout.isRefreshing = false
@ -220,7 +298,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun handleError() {
super.handleError()
infoListAdapter.clearStreamItemList()
feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling()
feedBinding.refreshRootView.animate(false, 0)
feedBinding.loadingProgressText.animate(false, 0)
@ -248,8 +325,74 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
feedBinding.loadingProgressBar.max = progressState.maxProgress
}
private fun showStreamDialog(item: StreamInfoItem) {
val context = context
val activity: Activity? = getActivity()
if (context == null || context.resources == null || activity == null) return
val entries = ArrayList<StreamDialogEntry>()
if (PlayerHolder.getType() != null) {
entries.add(StreamDialogEntry.enqueue)
}
if (item.streamType == StreamType.AUDIO_STREAM) {
entries.addAll(
listOf(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share,
StreamDialogEntry.open_in_browser
)
)
} else {
entries.addAll(
listOf(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share,
StreamDialogEntry.open_in_browser
)
)
}
StreamDialogEntry.setEnabledEntries(entries)
InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
StreamDialogEntry.clickOn(which, this, item)
}.show()
}
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
override fun onItemClick(item: Item<*>, view: View) {
if (item is StreamItem) {
val stream = item.streamWithState.stream
NavigationHelper.openVideoDetailFragment(
requireContext(), fm,
stream.serviceId, stream.url, stream.title, null, false
)
}
}
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
if (item is StreamItem) {
showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
return true
}
return false
}
}
@SuppressLint("StringFormatMatches")
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
infoListAdapter.setInfoItemList(loadedState.items)
val itemVersion = if (shouldUseGridLayout()) {
StreamItem.ItemVersion.GRID
} else {
StreamItem.ItemVersion.NORMAL
}
loadedState.items.forEach { it.itemVersion = itemVersion }
groupAdapter.updateAsync(loadedState.items, false, null)
listState?.run {
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
listState = null
@ -290,7 +433,7 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
}
private fun handleItemsErrors(errors: List<Throwable>) {
errors.forEachIndexed() { i, t ->
errors.forEachIndexed { i, t ->
if (t is FeedLoadService.RequestException &&
t.cause is ContentNotAvailableException
) {
@ -357,7 +500,10 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
private fun updateRelativeTimeViews() {
updateRefreshViewState()
infoListAdapter.notifyDataSetChanged()
groupAdapter.notifyItemRangeChanged(
0, groupAdapter.itemCount,
StreamItem.UPDATE_RELATIVE_TIME
)
}
private fun updateRefreshViewState() {
@ -372,8 +518,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
// /////////////////////////////////////////////////////////////////////////
override fun doInitialLoadLogic() {}
override fun loadMoreItems() {}
override fun hasMoreItems() = false
override fun reloadContent() {
getActivity()?.startService(
@ -384,6 +528,35 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
listState = null
}
// /////////////////////////////////////////////////////////////////////////
// Grid Mode
// /////////////////////////////////////////////////////////////////////////
// TODO: Move these out of this class, as it can be reused
private fun shouldUseGridLayout(): Boolean {
val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
return when (listMode) {
getString(R.string.list_view_mode_auto_key) -> {
val configuration = resources.configuration
(
configuration.orientation == Configuration.ORIENTATION_LANDSCAPE &&
configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
)
}
getString(R.string.list_view_mode_grid_key) -> true
else -> false
}
}
private fun getGridSpanCount(): Int {
val minWidth = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width)
return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
}
companion object {
const val KEY_GROUP_ID = "ARG_GROUP_ID"
const val KEY_GROUP_NAME = "ARG_GROUP_NAME"

View file

@ -1,7 +1,7 @@
package org.schabi.newpipe.local.feed
import androidx.annotation.StringRes
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.item.StreamItem
import java.time.OffsetDateTime
sealed class FeedState {
@ -12,7 +12,7 @@ sealed class FeedState {
) : FeedState()
data class LoadedState(
val items: List<StreamInfoItem>,
val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime? = null,
val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList()

View file

@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.functions.Function4
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedEventManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
@ -20,26 +22,33 @@ import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(context.applicationContext, groupId) as T
}
}
class FeedViewModel(
applicationContext: Context,
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
initialShowPlayedItems: Boolean = true
) : ViewModel() {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val streamItems = toggleShowPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
.switchMap { showPlayedItems ->
feedDatabaseManager.getStreams(groupId, showPlayedItems)
}
private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
private var combineDisposable = Flowable
.combineLatest(
FeedEventManager.events(),
feedDatabaseManager.asStreamItems(groupId),
streamItems,
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long, t4: List<OffsetDateTime> ->
Function4 { t1: FeedEventManager.Event, t2: List<StreamWithState>,
t3: Long, t4: List<OffsetDateTime> ->
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
}
)
@ -49,9 +58,9 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue(
when (event) {
is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount)
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount)
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount, event.itemsErrors)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error)
}
)
@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn
combineDisposable.dispose()
}
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamInfoItem>, val t3: Long, val t4: OffsetDateTime?)
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamWithState>, val t3: Long, val t4: OffsetDateTime?)
fun togglePlayedItems(showPlayedItems: Boolean) {
toggleShowPlayedItems.onNext(showPlayedItems)
}
class Factory(
private val context: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
private val showPlayedItems: Boolean
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(context.applicationContext, groupId, showPlayedItems) as T
}
}
}

View file

@ -0,0 +1,153 @@
package org.schabi.newpipe.local.feed.item
import android.content.Context
import android.text.TextUtils
import android.view.View
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.nostra13.universalimageloader.core.ImageLoader
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.databinding.ListStreamItemBinding
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.ImageDisplayConstants
import org.schabi.newpipe.util.Localization
import java.util.concurrent.TimeUnit
data class StreamItem(
val streamWithState: StreamWithState,
var itemVersion: ItemVersion = ItemVersion.NORMAL
) : BindableItem<ListStreamItemBinding>() {
companion object {
const val UPDATE_RELATIVE_TIME = 1
}
private val stream: StreamEntity = streamWithState.stream
private val stateProgressTime: Long? = streamWithState.stateProgressMillis
override fun getId(): Long = stream.uid
enum class ItemVersion { NORMAL, MINI, GRID }
override fun getLayout(): Int = when (itemVersion) {
ItemVersion.NORMAL -> R.layout.list_stream_item
ItemVersion.MINI -> R.layout.list_stream_mini_item
ItemVersion.GRID -> R.layout.list_stream_grid_item
}
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)
override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList<Any>) {
if (payloads.contains(UPDATE_RELATIVE_TIME)) {
if (itemVersion != ItemVersion.MINI) {
viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
}
return
}
super.bind(viewBinding, position, payloads)
}
override fun bind(viewBinding: ListStreamItemBinding, position: Int) {
viewBinding.itemVideoTitleView.text = stream.title
viewBinding.itemUploaderView.text = stream.uploader
val isLiveStream = stream.streamType == LIVE_STREAM || stream.streamType == AUDIO_LIVE_STREAM
if (stream.duration > 0) {
viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration)
viewBinding.itemDurationView.setBackgroundColor(
ContextCompat.getColor(
viewBinding.itemDurationView.context,
R.color.duration_background_color
)
)
viewBinding.itemDurationView.visibility = View.VISIBLE
if (stateProgressTime != null) {
viewBinding.itemProgressView.visibility = View.VISIBLE
viewBinding.itemProgressView.max = stream.duration.toInt()
viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt()
} else {
viewBinding.itemProgressView.visibility = View.GONE
}
} else if (isLiveStream) {
viewBinding.itemDurationView.setText(R.string.duration_live)
viewBinding.itemDurationView.setBackgroundColor(
ContextCompat.getColor(
viewBinding.itemDurationView.context,
R.color.live_duration_background_color
)
)
viewBinding.itemDurationView.visibility = View.VISIBLE
viewBinding.itemProgressView.visibility = View.GONE
} else {
viewBinding.itemDurationView.visibility = View.GONE
viewBinding.itemProgressView.visibility = View.GONE
}
ImageLoader.getInstance().displayImage(
stream.thumbnailUrl, viewBinding.itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS
)
if (itemVersion != ItemVersion.MINI) {
viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
}
}
override fun isLongClickable() = when (stream.streamType) {
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true
else -> false
}
private fun getStreamInfoDetailLine(context: Context): String {
var viewsAndDate = ""
val viewCount = stream.viewCount
if (viewCount != null && viewCount >= 0) {
viewsAndDate = when (stream.streamType) {
AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount)
LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount)
else -> Localization.shortViewCount(context, viewCount)
}
}
val uploadDate = getFormattedRelativeUploadDate(context)
return when {
!TextUtils.isEmpty(uploadDate) -> when {
viewsAndDate.isEmpty() -> uploadDate!!
else -> Localization.concatenateStrings(viewsAndDate, uploadDate)
}
else -> viewsAndDate
}
}
private fun getFormattedRelativeUploadDate(context: Context): String? {
val uploadDate = stream.uploadDate
return if (uploadDate != null) {
var formattedRelativeTime = Localization.relativeTime(uploadDate)
if (MainActivity.DEBUG) {
val key = context.getString(R.string.show_original_time_ago_key)
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) {
formattedRelativeTime += " (" + stream.textualUploadDate + ")"
}
}
formattedRelativeTime
} else {
stream.textualUploadDate
}
}
override fun getSpanSize(spanCount: Int, position: Int): Int {
return if (itemVersion == ItemVersion.GRID) 1 else spanCount
}
}

View file

@ -211,11 +211,11 @@ public class HistoryRecordManager {
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map((info) -> streamTable.upsert(new StreamEntity(info)))
.map(info -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.filter(state -> state.isValid((int) queueItem.getDuration()))
.filter(state -> state.isValid(queueItem.getDuration()))
.subscribeOn(Schedulers.io());
}
@ -224,18 +224,16 @@ public class HistoryRecordManager {
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.filter(state -> state.isValid((int) info.getDuration()))
.filter(state -> state.isValid(info.getDuration()))
.subscribeOn(Schedulers.io());
}
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) {
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
return Completable.fromAction(() -> database.runInTransaction(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
final StreamStateEntity state = new StreamStateEntity(streamId, progressTime);
if (state.isValid((int) info.getDuration())) {
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
if (state.isValid(info.getDuration())) {
streamStateTable.upsert(state);
} else {
streamStateTable.deleteState(streamId);
}
})).subscribeOn(Schedulers.io());
}

View file

@ -68,11 +68,11 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
if (item.getProgressTime() > 0) {
if (item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setVisibility(View.GONE);
}
@ -109,14 +109,14 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) {
if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -96,11 +96,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
if (item.getProgressTime() > 0) {
if (item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setVisibility(View.GONE);
}
@ -140,14 +140,14 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) {
if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime()));
.toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -671,7 +671,11 @@ public final class Player implements
//.doFinally()
.subscribe(
state -> {
newQueue.setRecovery(newQueue.getIndex(), state.getProgressTime());
if (!state.isFinished(newQueue.getItem().getDuration())) {
// resume playback only if the stream was not played to the end
newQueue.setRecovery(newQueue.getIndex(),
state.getProgressMillis());
}
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
},
@ -1935,9 +1939,7 @@ public final class Player implements
break;
case com.google.android.exoplayer2.Player.STATE_ENDED: // 4
changeState(STATE_COMPLETED);
if (currentMetadata != null) {
resetStreamProgressState(currentMetadata.getMetadata());
}
saveStreamProgressStateCompleted();
isPrepared = false;
break;
}
@ -2398,7 +2400,7 @@ public final class Player implements
case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
case DISCONTINUITY_REASON_INTERNAL:
if (playQueue.getIndex() != newWindowIndex) {
resetStreamProgressState(playQueue.getItem());
saveStreamProgressStateCompleted(); // current stream has ended
playQueue.setIndex(newWindowIndex);
}
break;
@ -2789,61 +2791,47 @@ public final class Player implements
}
}
private void saveStreamProgressState(final StreamInfo info, final long progress) {
if (info == null) {
private void saveStreamProgressState(final long progressMillis) {
if (currentMetadata == null
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called");
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
+ ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]");
}
if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
final Disposable stateSaver = recordManager.saveStreamState(info, progress)
.observeOn(AndroidSchedulers.mainThread())
.doOnError((e) -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe();
databaseUpdateDisposable.add(stateSaver);
}
}
private void resetStreamProgressState(final PlayQueueItem queueItem) {
if (queueItem == null) {
return;
}
if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
final Disposable stateSaver = queueItem.getStream()
.flatMapCompletable(info -> recordManager.saveStreamState(info, 0))
.observeOn(AndroidSchedulers.mainThread())
.doOnError((e) -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe();
databaseUpdateDisposable.add(stateSaver);
}
}
private void resetStreamProgressState(final StreamInfo info) {
saveStreamProgressState(info, 0);
databaseUpdateDisposable
.add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError((e) -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe());
}
public void saveStreamProgressState() {
if (exoPlayerIsNull() || currentMetadata == null) {
if (exoPlayerIsNull() || currentMetadata == null || playQueue == null
|| playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) {
// Make sure play queue and current window index are equal, to prevent saving state for
// the wrong stream on discontinuity (e.g. when the stream just changed but the
// playQueue index and currentMetadata still haven't updated)
return;
}
final StreamInfo currentInfo = currentMetadata.getMetadata();
if (playQueue != null) {
// Save current position. It will help to restore this position once a user
// wants to play prev or next stream from the queue
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition());
// Save current position. It will help to restore this position once a user
// wants to play prev or next stream from the queue
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition());
saveStreamProgressState(simpleExoPlayer.getCurrentPosition());
}
public void saveStreamProgressStateCompleted() {
if (currentMetadata != null) {
// current stream has ended, so the progress is its duration (+1 to overcome rounding)
saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000);
}
saveStreamProgressState(currentInfo, simpleExoPlayer.getCurrentPosition());
}
//endregion

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="-90"
android:endColor="#B2000000"
android:startColor="#E6000000"/>
</shape>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/itemRoot"
android:layout_width="match_parent"
@ -13,20 +14,18 @@
android:id="@+id/itemThumbnailView"
android:layout_width="@dimen/video_item_grid_thumbnail_image_width"
android:layout_height="@dimen/video_item_grid_thumbnail_image_height"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:contentDescription="@string/list_thumbnail_view_description"
android:scaleType="centerCrop"
android:src="@drawable/dummy_thumbnail"
tools:ignore="RtlHardcoded" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/itemDurationView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignRight="@id/itemThumbnailView"
android:layout_alignBottom="@id/itemThumbnailView"
android:layout_marginRight="@dimen/video_item_search_duration_margin"
android:layout_marginEnd="@dimen/video_item_search_duration_margin"
android:layout_marginBottom="@dimen/video_item_search_duration_margin"
android:background="@color/duration_background_color"
android:paddingLeft="@dimen/video_item_search_duration_horizontal_padding"
@ -37,57 +36,60 @@
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textSize="@dimen/video_item_search_duration_text_size"
tools:ignore="RtlHardcoded"
app:layout_constraintBottom_toBottomOf="@+id/itemThumbnailView"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
tools:text="1:09:10" />
<TextView
android:id="@+id/itemVideoTitleView"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/itemThumbnailView"
android:layout_alignLeft="@id/itemThumbnailView"
android:layout_alignEnd="@id/itemThumbnailView"
android:layout_alignRight="@id/itemThumbnailView"
android:ellipsize="end"
android:maxLines="2"
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" />
app:layout_constraintBottom_toTopOf="@+id/itemUploaderView"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@+id/itemProgressView"
tools:text="@tools:sample/lorem[10]" />
<TextView
android:id="@+id/itemUploaderView"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@+id/itemVideoTitleView"
android:layout_alignLeft="@id/itemVideoTitleView"
android:layout_alignRight="@id/itemVideoTitleView"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
tools:text="Uploader" />
app:layout_constraintBottom_toTopOf="@+id/itemAdditionalDetails"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@+id/itemVideoTitleView"
tools:text="Uploader name long very very long long" />
<TextView
android:id="@+id/itemAdditionalDetails"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/itemUploaderView"
android:layout_alignStart="@id/itemUploaderView"
android:layout_alignEnd="@id/itemUploaderView"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@+id/itemUploaderView"
tools:text="2 years ago • 10M views" />
<org.schabi.newpipe.views.AnimatedProgressBar
android:id="@+id/itemProgressView"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="4dp"
android:layout_below="@id/itemThumbnailView"
android:layout_alignStart="@id/itemThumbnailView"
android:layout_alignEnd="@id/itemThumbnailView"
android:layout_marginTop="-2dp"
android:progressDrawable="?progress_horizontal_drawable" />
android:progressDrawable="?progress_horizontal_drawable"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@+id/itemThumbnailView" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -14,47 +14,41 @@
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_marginRight="@dimen/video_item_search_image_right_margin"
android:contentDescription="@string/list_thumbnail_view_description"
android:scaleType="centerCrop"
android:src="@drawable/dummy_thumbnail"
app:layout_constraintEnd_toStartOf="@+id/itemVideoTitleView"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintBottom_toTopOf="@+id/itemProgressView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:ignore="RtlHardcoded" />
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/itemDurationView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="@dimen/video_item_search_duration_margin"
android:layout_marginEnd="@dimen/video_item_search_duration_margin"
android:layout_marginBottom="@dimen/video_item_search_duration_margin"
android:background="@color/duration_background_color"
android:paddingLeft="@dimen/video_item_search_duration_horizontal_padding"
android:paddingTop="@dimen/video_item_search_duration_vertical_padding"
android:paddingRight="@dimen/video_item_search_duration_horizontal_padding"
android:paddingBottom="@dimen/video_item_search_duration_vertical_padding"
android:paddingHorizontal="@dimen/video_item_search_duration_horizontal_padding"
android:paddingVertical="@dimen/video_item_search_duration_vertical_padding"
android:textAllCaps="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textSize="@dimen/video_item_search_duration_text_size"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintRight_toRightOf="@id/itemThumbnailView"
tools:ignore="RtlHardcoded"
tools:text="1:09:10" />
<TextView
android:id="@+id/itemVideoTitleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/video_item_search_image_right_margin"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_search_title_text_size"
app:layout_constraintBottom_toTopOf="@+id/itemUploaderView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/itemThumbnailView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum" />
@ -67,8 +61,9 @@
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
app:layout_constraintLeft_toLeftOf="@+id/itemVideoTitleView"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@+id/itemAdditionalDetails"
app:layout_constraintEnd_toEndOf="@+id/itemVideoTitleView"
app:layout_constraintStart_toStartOf="@+id/itemVideoTitleView"
app:layout_constraintTop_toBottomOf="@+id/itemVideoTitleView"
tools:text="Uploader" />
@ -76,15 +71,13 @@
android:id="@+id/itemAdditionalDetails"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_toEndOf="@+id/itemThumbnailView"
android:layout_toRightOf="@+id/itemThumbnailView"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="@+id/itemVideoTitleView"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintEnd_toEndOf="@+id/itemVideoTitleView"
app:layout_constraintStart_toStartOf="@+id/itemVideoTitleView"
app:layout_constraintTop_toBottomOf="@+id/itemUploaderView"
tools:text="2 years ago • 10M views" />
@ -93,9 +86,8 @@
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="0dp"
android:layout_height="4dp"
android:layout_below="@id/itemThumbnailView"
android:layout_marginTop="-2dp"
android:progressDrawable="?progress_horizontal_drawable"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@+id/itemThumbnailView" />

View file

@ -2,9 +2,19 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_item_feed_toggle_played_items"
android:orderInCategory="2"
android:checkable="true"
android:checked="true"
android:icon="@drawable/ic_visibility_on"
android:title="@string/feed_toggle_show_played_items"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_item_feed_help"
android:icon="@drawable/ic_help"
android:title="@string/help"
app:showAsAction="always" />
android:orderInCategory="3"
app:showAsAction="ifRoom" />
</menu>

View file

@ -56,6 +56,7 @@
<color name="duration_text_color">#EEFFFFFF</color>
<color name="playlist_stream_count_text_color">#ffffff</color>
<color name="video_overlay_color">#64000000</color>
<color name="item_in_history_indicator_text_color">#E6FFFFFF</color>
<color name="background_notification_color">#323232</color>
<color name="background_title_color">#ffffff</color>

View file

@ -20,6 +20,7 @@
<dimen name="video_item_search_uploader_text_size">11sp</dimen>
<dimen name="video_item_search_upload_date_text_size">12sp</dimen>
<dimen name="header_footer_text_size">16sp</dimen>
<dimen name="item_in_history_indicator_text_size">12sp</dimen>
<!-- Elements Size -->
<!-- 16 / 9 ratio-->
<dimen name="video_item_search_thumbnail_image_width">124dp</dimen>
@ -52,6 +53,8 @@
<dimen name="player_main_buttons_min_width">40dp</dimen>
<dimen name="player_notification_thumbnail_width">200dp</dimen>
<dimen name="item_in_history_indicator_vertical_margin">2dp</dimen>
<dimen name="item_in_history_indicator_horizontal_margin">8dp</dimen>
<!-- Miscellaneous -->
<dimen name="popup_default_width">180dp</dimen>
<dimen name="popup_minimum_width">150dp</dimen>

View file

@ -228,6 +228,7 @@
<string name="delete_search_history_alert">Delete entire search history?</string>
<string name="search_history_deleted">Search history deleted.</string>
<string name="help">Help</string>
<string name="item_in_history">In History</string>
<!-- error strings -->
<string name="general_error">Error</string>
<string name="download_to_sdcard_error_title">External storage unavailable</string>
@ -702,7 +703,7 @@
<string name="feed_use_dedicated_fetch_method_enable_button">Enable fast mode</string>
<string name="feed_use_dedicated_fetch_method_disable_button">Disable fast mode</string>
<string name="feed_use_dedicated_fetch_method_help_text">Do you think feed loading is too slow? If so, try enabling fast loading (you can change it in settings or by pressing the button below).\n\nNewPipe offers two feed loading strategies:\n• Fetching the whole subscription channel, which is slow but complete.\n• Using a dedicated service endpoint, which is fast but usually not complete.\n\nThe difference between the two is that the fast one usually lacks some information, like the item\'s duration or type (can\'t distinguish between live videos and normal ones) and it may return less items.\n\nYouTube is an example of a service that offers this fast method with its RSS feed.\n\nSo the choice boils down to what you prefer: speed or precise information.</string>
<string name="feed_toggle_show_played_items">Show played items</string>
<string name="content_not_supported">This content is not yet supported by NewPipe.\n\nIt will hopefully be supported in a future version.</string>
<string name="detail_sub_channel_thumbnail_view_description">Channel\'s avatar thumbnail</string>
<string name="channel_created_by">Created by %s</string>