Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.work.rxjava3)
implementation(libs.google.android.material)
implementation(libs.google.flexbox)
implementation(libs.androidx.webkit)

/** Compose & other modern patterns **/
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.ListUpdateCallback;
import androidx.recyclerview.widget.RecyclerView;

import org.schabi.newpipe.databinding.PignateFooterBinding;
Expand Down Expand Up @@ -161,6 +163,72 @@ public void clearStreamItemList() {
notifyDataSetChanged();
}

/**
* Replaces the current item list with {@code newItems} using {@link DiffUtil} to compute
* the minimal set of changes. Notifications are offset by the header position so the header
* ViewHolder is never recycled — keeping any open spinner dropdown in the header alive.
*
* @param newItems the replacement item list
*/
public void replaceStreamItems(@Nullable final List<? extends InfoItem> newItems) {
// Snapshot both lists before mutating infoItemList so the diff is computed against
// a consistent state; infoItemList is then updated before notifications are dispatched
final List<InfoItem> oldItems = new ArrayList<>(infoItemList);
final List<InfoItem> currentItems =
newItems != null ? new ArrayList<>(newItems) : new ArrayList<>();
final int headerOffset = hasHeader() ? 1 : 0;

final DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffUtil.Callback() {
@Override
public int getOldListSize() {
return oldItems.size();
}

@Override
public int getNewListSize() {
return currentItems.size();
}

@Override
public boolean areItemsTheSame(final int oldPos, final int newPos) {
return oldItems.get(oldPos).getUrl().equals(currentItems.get(newPos).getUrl());
}

@Override
public boolean areContentsTheSame(final int oldPos, final int newPos) {
return areItemsTheSame(oldPos, newPos);
}
}, false);

// Mutate backing list after diff is computed but before notifications are dispatched,
// so RecyclerView sees consistent adapter state during any intermediate layout passes
infoItemList.clear();
infoItemList.addAll(currentItems);

diffResult.dispatchUpdatesTo(new ListUpdateCallback() {
@Override
public void onInserted(final int position, final int count) {
notifyItemRangeInserted(position + headerOffset, count);
}

@Override
public void onRemoved(final int position, final int count) {
notifyItemRangeRemoved(position + headerOffset, count);
}

@Override
public void onMoved(final int fromPosition, final int toPosition) {
notifyItemMoved(fromPosition + headerOffset, toPosition + headerOffset);
}

@Override
public void onChanged(final int position, final int count,
@Nullable final Object payload) {
notifyItemRangeChanged(position + headerOffset, count, payload);
}
});
}

public void setHeaderSupplier(@Nullable final Supplier<View> headerSupplier) {
final boolean changed = headerSupplier != this.headerSupplier;
this.headerSupplier = headerSupplier;
Expand Down
95 changes: 95 additions & 0 deletions app/src/main/java/org/schabi/newpipe/util/ChannelItemCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package org.schabi.newpipe.util;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.collection.LruCache;

import org.schabi.newpipe.extractor.stream.StreamInfoItem;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
* In-memory LRU cache for fully-loaded channel video lists, keyed by channel tab URL.
* Caches up to {@code MAX_TOTAL_ITEMS} stream items across all channels, evicting the
* least-recently-accessed channel when the limit is exceeded. Entries expire after 1 hour.
*/
public final class ChannelItemCache {

private static final int MAX_TOTAL_ITEMS = 10_000;
private static final long EXPIRY_MILLIS = TimeUnit.HOURS.toMillis(1);

private static final ChannelItemCache INSTANCE = new ChannelItemCache();

private final LruCache<String, CacheEntry> cache =
new LruCache<String, CacheEntry>(MAX_TOTAL_ITEMS) {
@Override
protected int sizeOf(final String key, final CacheEntry value) {
return value.items.size();
}
};

private ChannelItemCache() {
}

/**
* Returns the singleton instance.
*
* @return the shared {@link ChannelItemCache} instance
*/
public static ChannelItemCache getInstance() {
return INSTANCE;
}

/**
* Returns the cached item list for the given channel tab URL, or null if absent/expired.
*
* @param channelTabUrl the URL of the channel tab
* @return a copy of the cached list, or null if absent or expired
*/
@Nullable
public List<StreamInfoItem> getItems(@NonNull final String channelTabUrl) {
final CacheEntry entry = cache.get(channelTabUrl);
if (entry == null) {
return null;
}
if (entry.isExpired()) {
cache.remove(channelTabUrl);
return null;
}
return new ArrayList<>(entry.items);
}

/**
* Stores or replaces the item list for the given channel tab URL.
*
* @param channelTabUrl the URL of the channel tab
* @param items the full list of stream items to cache
*/
public void putItems(@NonNull final String channelTabUrl,
@NonNull final List<StreamInfoItem> items) {
cache.put(channelTabUrl, new CacheEntry(new ArrayList<>(items)));
}

/**
* Clears all cached entries.
*/
public void clear() {
cache.evictAll();
}

private static final class CacheEntry {
final List<StreamInfoItem> items;
final long expiresAt;

CacheEntry(@NonNull final List<StreamInfoItem> items) {
this.items = items;
this.expiresAt = System.currentTimeMillis() + EXPIRY_MILLIS;
}

boolean isExpired() {
return System.currentTimeMillis() > expiresAt;
}
}
}
23 changes: 23 additions & 0 deletions app/src/main/res/layout/channel_streams_header.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<include
android:id="@+id/playlist_control"
layout="@layout/playlist_control"
android:layout_width="match_parent"
android:layout_height="@dimen/playlist_ctrl_height"
tools:visibility="visible" />

<include
android:id="@+id/channel_filter_panel"
layout="@layout/fragment_channel_filter_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible" />

</LinearLayout>
157 changes: 157 additions & 0 deletions app/src/main/res/layout/fragment_channel_filter_panel.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.flexbox.FlexboxLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:background="?attr/windowBackground"
app:flexWrap="wrap"
app:alignItems="center"
app:justifyContent="flex_start">

<!-- Sort group: [↑/↓] [Field▼] -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp">

<Button
android:id="@+id/filter_sort_order"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="↓"
android:textSize="18sp"
android:minWidth="0dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
style="?attr/borderlessButtonStyle" />

<Spinner
android:id="@+id/filter_sort_field"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp" />

</LinearLayout>

<!-- Age group: [Period▼] [Custom days input] -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp">

<Spinner
android:id="@+id/filter_age_period"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

<EditText
android:id="@+id/filter_age_custom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:hint="@string/filter_age_custom_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionDone"
android:singleLine="true"
android:visibility="gone" />

</LinearLayout>

<!-- Name group: Name [input] -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/filter_name_label"
android:textSize="13sp"
android:paddingEnd="4dp" />

<EditText
android:id="@+id/filter_name_input"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="@string/filter_name_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionNext"
android:singleLine="true" />

</LinearLayout>

<!-- Views group: Views [Preset▼] [Custom input] -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/filter_views_label"
android:textSize="13sp"
android:paddingEnd="4dp" />

<Spinner
android:id="@+id/filter_views_preset"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

<EditText
android:id="@+id/filter_views_custom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:hint="@string/filter_views_custom_hint"
android:inputType="number"
android:maxLines="1"
android:imeOptions="actionDone"
android:singleLine="true"
android:minEms="6"
android:visibility="gone" />

</LinearLayout>

<!-- Loading row — forced to its own line, full width -->
<LinearLayout
android:id="@+id/filter_loading_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone"
app:layout_wrapBefore="true">

<ProgressBar
android:id="@+id/filter_loading_progress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

<TextView
android:id="@+id/filter_loading_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="12sp" />

</LinearLayout>

</com.google.android.flexbox.FlexboxLayout>
Loading
Loading