From 569ad548afc2483abefd6db9fc64d50545a883bc Mon Sep 17 00:00:00 2001 From: h Date: Wed, 22 Apr 2026 16:08:00 +0200 Subject: [PATCH 01/17] Add BulletCommentsView overlay component --- .../newpipe/views/BulletCommentsView.java | 384 ++++++++++++++++++ .../res/layout/bullet_comments_player.xml | 33 ++ app/src/main/res/values/strings.xml | 24 ++ 3 files changed, 441 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java create mode 100644 app/src/main/res/layout/bullet_comments_player.xml diff --git a/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java b/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java new file mode 100644 index 00000000000..4ac51e21d8b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java @@ -0,0 +1,384 @@ +package org.schabi.newpipe.views; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Gravity; +import android.view.animation.LinearInterpolator; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; + +import androidx.preference.PreferenceManager; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.BulletCommentsPlayerBinding; +import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfoItem; + +import java.time.Duration; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.stream.Collectors; + +public final class BulletCommentsView extends ConstraintLayout { + private final String TAG = "BulletCommentsView"; + private SharedPreferences prefs; + + /** + * Tuple of TextView and ObjectAnimator. + */ + private static class AnimatedTextView { + AnimatedTextView(final TextView textView, final ObjectAnimator animator) { + this.textView = textView; + this.animator = animator; + } + + public final TextView textView; + public final ObjectAnimator animator; + } + + public BulletCommentsView(final Context context) { + super(context); + setClipChildren(false); + init(context); + } + + public BulletCommentsView(final Context context, + final AttributeSet attrs) { + super(context, attrs); + setClipChildren(false); + init(context); + } + + public BulletCommentsView(final Context context, + final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + setClipChildren(false); + init(context); + } + + private void init(final Context context) { + final View layout = LayoutInflater.from(context) + .inflate(R.layout.bullet_comments_player, this); + prefs = PreferenceManager.getDefaultSharedPreferences(context); + commentsDuration = prefs.getInt( + context.getString(R.string.top_bottom_bullet_comments_duration_key), 8); + durationFactor = (float) prefs.getInt( + context.getString(R.string.regular_bullet_comments_duration_key), 8) + / (float) commentsDuration; + outlineRadius = prefs.getInt( + context.getString(R.string.bullet_comments_outline_radius_key), 2); + + final boolean limitMaxRows = prefs.getBoolean( + context.getString(R.string.enable_max_rows_customization_key), false); + if (limitMaxRows) { + maxRowsTop = prefs.getInt( + context.getString(R.string.max_bullet_comments_rows_top_key), 15); + maxRowsBottom = prefs.getInt( + context.getString(R.string.max_bullet_comments_rows_bottom_key), 15); + maxRowsRegular = prefs.getInt( + context.getString(R.string.max_bullet_comments_rows_regular_key), 15); + } + + font = prefs.getString(context.getString(R.string.bullet_comments_font_key), "default"); + opacity = prefs.getInt(context.getString(R.string.bullet_comments_opacity_key), 0xFF); + binding = BulletCommentsPlayerBinding.bind(this); + } + + private boolean layoutSet = false; + + private void setLayout() { + final int additionalWidth = additionalSpaceRelative * getWidth(); + binding.bottomRight.getLayoutParams().width = additionalWidth; + requestLayout(); + Log.i(TAG, "Additional width: " + additionalWidth + + ", container width: " + binding.bulletCommentsContainer.getWidth()); + } + + private BulletCommentsPlayerBinding binding; + private final int additionalSpaceRelative = 4; + + private final int commentsRowsCount = 11; + private int lastCalculatedCommentsRowsCount = 11; + private List rows = Collections.synchronizedList(new ArrayList()); + private List> rowsRegular = + Collections.synchronizedList(new ArrayList<>()); + private final double commentRelativeTextSize = 1 / 13.5; + private PriorityQueue bulletCommentsInfoItemRegularPool = + new PriorityQueue<>(); + private PriorityQueue bulletCommentsInfoItemFixedPool = + new PriorityQueue<>(); + + private int commentsDuration; + private float durationFactor; + private int outlineRadius; + private String font; + private int opacity; // 0~255, 0: hide + private final List animatedTextViews = new ArrayList<>(); + + private int maxRowsTop = 1000000; + private int maxRowsBottom = 1000000; + private int maxRowsRegular = 1000000; + + public void clearComments() { + Log.d(TAG, "clearComments() called, animatedViews=" + animatedTextViews.size()); + animatedTextViews.clear(); + if (binding != null) { + binding.bulletCommentsContainer.removeAllViews(); + } + } + + public void setPauseComments(final boolean pause) { + if (pause) { + pauseComments(); + } else { + resumeComments(); + } + } + + public void pauseComments() { + animatedTextViews.stream().forEach(s -> s.animator.pause()); + } + + public void resumeComments() { + animatedTextViews.stream().forEach(s -> s.animator.resume()); + } + + public void drawComments(@NonNull final BulletCommentsInfoItem[] items, + final Duration drawUntilPosition) { + Log.v(TAG, "drawComments() items=" + items.length + + " position=" + drawUntilPosition.toMillis() + "ms"); + if (binding == null || getWidth() == 0 || getHeight() == 0) { + Log.w(TAG, "drawComments() skipped: view not ready"); + return; + } + if (!layoutSet) { + setLayout(); + layoutSet = true; + } + bulletCommentsInfoItemRegularPool.addAll( + Arrays.asList(items).stream() + .filter(x -> x.getPosition() == BulletCommentsInfoItem.Position.REGULAR) + .collect(Collectors.toList())); + bulletCommentsInfoItemFixedPool.addAll( + Arrays.asList(items).stream() + .filter(x -> x.getPosition() != BulletCommentsInfoItem.Position.REGULAR) + .collect(Collectors.toList())); + final int height = getHeight(); + final int width = getWidth(); + final int calculatedCommentRowsCount = + height / Math.min(height, width) * commentsRowsCount; + if (calculatedCommentRowsCount != lastCalculatedCommentsRowsCount) { + lastCalculatedCommentsRowsCount = calculatedCommentRowsCount; + rows.clear(); + rowsRegular.clear(); + } + while (rowsRegular.size() < calculatedCommentRowsCount) { + rowsRegular.add(new AbstractMap.SimpleEntry<>(0L, 0)); + } + while (rows.size() < calculatedCommentRowsCount) { + rows.add(0L); + } + drawCommentsByPool(bulletCommentsInfoItemRegularPool, drawUntilPosition, + height, width, calculatedCommentRowsCount); + drawCommentsByPool(bulletCommentsInfoItemFixedPool, drawUntilPosition, + height, width, calculatedCommentRowsCount); + Log.v(TAG, "drawComments() done, containerChildCount=" + + binding.bulletCommentsContainer.getChildCount()); + } + + public int tryToDrawComment(final BulletCommentsInfoItem item, + final int calculatedCommentRowsCount, + final int width, + final boolean reallyDo) { + final long current = new Date().getTime(); + int row = -1; + final int comparedDuration = (int) (commentsDuration * 1000); + if (item.getPosition().equals(BulletCommentsInfoItem.Position.TOP) + || item.getPosition().equals(BulletCommentsInfoItem.Position.SUPERCHAT)) { + for (int i = 0; i < Math.min(maxRowsTop, calculatedCommentRowsCount); i++) { + final long last = rows.get(i); + if (current - last >= comparedDuration) { + if (reallyDo) { + rows.set(i, current); + } + row = i; + break; + } + } + } else if (item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR)) { + for (int i = 0; i < Math.min(maxRowsRegular, calculatedCommentRowsCount); i++) { + final long lastTime = rowsRegular.get(i).getKey(); + final long lastLength = rowsRegular.get(i).getValue(); + final long t = current - lastTime; + final double tAll = comparedDuration * durationFactor; + final double lx = (lastLength / 25.0 + 1) * width; + final double ly = (item.getCommentText().length() / 25.0 + 1) * width; + final double vx = lx / tAll; + final double vy = ly / tAll; + if ((vy - vx) * (tAll - t) < t * vx - (lastLength / 25.0) * width + && t * vx - (lastLength / 25.0) * width > 0) { + if (reallyDo) { + rowsRegular.set(i, + new AbstractMap.SimpleEntry<>(current, + item.getCommentText().length())); + } + row = i; + break; + } + } + } else { + for (int i = calculatedCommentRowsCount - 1; + i >= Math.max(0, calculatedCommentRowsCount - maxRowsBottom); i--) { + final long last = rows.get(i); + if (current - last >= comparedDuration) { + if (reallyDo) { + rows.set(i, current); + } + row = i; + break; + } + } + } + return row; + } + + private void drawCommentsByPool(final PriorityQueue pool, + final Duration drawUntilPosition, + final int height, + final int width, + final int calculatedCommentRowsCount) { + if (binding == null) { + return; + } + final Context context = binding.bulletCommentsContainer.getContext(); + int drawn = 0; + while (!pool.isEmpty() + && (drawUntilPosition.compareTo(Duration.ofSeconds(Long.MAX_VALUE)) == 0 + || pool.peek().getDuration().toMillis() < drawUntilPosition.toMillis())) { + final BulletCommentsInfoItem item = pool.peek(); + if (item.isLive() + && tryToDrawComment(item, calculatedCommentRowsCount, width, false) == -1) { + Log.v(TAG, "drawCommentsByPool() row collision, skipping item"); + pool.poll(); // skip this item instead of aborting all + continue; + } + pool.poll(); + final TextView textView = new TextView(context); + final Typeface fontToBeUsed; + switch (font) { + case "serif": + fontToBeUsed = Typeface.SERIF; + break; + case "monospace": + fontToBeUsed = Typeface.MONOSPACE; + break; + case "sans-serif": + fontToBeUsed = Typeface.SANS_SERIF; + break; + default: + fontToBeUsed = Typeface.DEFAULT; + break; + } + textView.setGravity(Gravity.CENTER); + int color = item.getArgbColor(); + if (opacity != 0xFF) { + color &= 0x00FFFFFF; + color |= ((opacity & 0xFF) << 24); + } + textView.setTextColor(color); + final String commentText = item.getCommentText(); + Log.v(TAG, "drawCommentsByPool() text=[" + commentText + "] color=" + + String.format("0x%08X", color) + " pos=" + item.getPosition()); + if (commentText.length() == 0) { + Log.v(TAG, "drawCommentsByPool() skipping empty text"); + continue; + } + textView.setText(commentText); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, + (float) (Math.min(height, width) * commentRelativeTextSize + * item.getRelativeFontSize())); + textView.setMaxLines(1); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + textView.setTypeface(Typeface.create(fontToBeUsed, Typeface.BOLD, + item.getPosition().equals(BulletCommentsInfoItem.Position.SUPERCHAT))); + } else { + textView.setTypeface(Typeface.create(fontToBeUsed, Typeface.BOLD)); + } + final Paint paint = textView.getPaint(); + int shadowColor = Color.BLACK & 0x00FFFFFF; + shadowColor |= ((opacity & 0xFF) << 24); + paint.setShadowLayer(outlineRadius, 0, 0, shadowColor); + textView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint); + + final int row = tryToDrawComment(item, calculatedCommentRowsCount, width, true); + if (row == -1) { + continue; + } + textView.setX(width); + textView.post(() -> { + final int textWidth = textView.getWidth(); + final int textHeight = textView.getHeight(); + final ObjectAnimator animator; + if (!item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR)) { + animator = ObjectAnimator.ofFloat( + textView, + View.TRANSLATION_X, + (float) ((width - textWidth) / 2.0), + (float) ((width - textWidth) / 2.0) + ); + } else { + animator = ObjectAnimator.ofFloat( + textView, + View.TRANSLATION_X, + width, + -textWidth + ); + } + textView.setY((float) (height * (0.5 + row) / calculatedCommentRowsCount + - textHeight / 2)); + + final AnimatedTextView animatedTextView = new AnimatedTextView( + textView, animator); + animatedTextViews.add(animatedTextView); + animator.setFrameDelay(1); + animator.setInterpolator(new LinearInterpolator()); + animator.setDuration(item.getLastingTime() != -1 + ? item.getLastingTime() + : (long) (commentsDuration * 1000 + * (item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR) + ? durationFactor : 1))); + animator.addListener(new AnimatorListenerAdapter() { + public void onAnimationEnd(final Animator animation) { + binding.bulletCommentsContainer.removeView(textView); + animatedTextViews.remove(animatedTextView); + } + }); + animator.start(); + }); + binding.bulletCommentsContainer.addView(textView); + drawn++; + } + if (drawn > 0) { + Log.v(TAG, "drawCommentsByPool() drawn=" + drawn); + } + } +} diff --git a/app/src/main/res/layout/bullet_comments_player.xml b/app/src/main/res/layout/bullet_comments_player.xml new file mode 100644 index 00000000000..a42a76925e8 --- /dev/null +++ b/app/src/main/res/layout/bullet_comments_player.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 207f1363f5f..d9a41f1378c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -912,4 +912,28 @@ In August 2025, Google announced that as of September 2026, installing apps will require developer verification for all Android apps on certified devices, including those installed outside of the Play Store. Since the developers of NewPipe do not agree to this requirement, NewPipe will no longer work on certified Android devices after that time. Details Solution + + + top_bottom_bullet_comments_duration_key + regular_bullet_comments_duration_key + bullet_comments_outline_radius_key + bullet_comments_font_key + bullet_comments_opacity_key + enable_max_rows_customization_key + max_bullet_comments_rows_top_key + max_bullet_comments_rows_bottom_key + max_bullet_comments_rows_regular_key + Bullet comments + Configure live chat overlay + Regular comments duration + Duration of scrolling comments in seconds + Top/bottom comments duration + Duration of fixed comments in seconds + Outline radius + Font + Opacity + Limit max rows + Max top rows + Max bottom rows + Max regular rows From 015d18f5cbdd956d393f16f84c6af1a943960ed4 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 22 Apr 2026 16:31:00 +0200 Subject: [PATCH 02/17] Add MovieBulletCommentsPlayer bridge --- .../MovieBulletCommentsPlayer.java | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java diff --git a/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java b/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java new file mode 100644 index 00000000000..d7a0b7b1ee4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java @@ -0,0 +1,194 @@ +package org.schabi.newpipe.player.bulletComments; + +import android.annotation.SuppressLint; +import android.util.Log; + +import org.schabi.newpipe.extractor.bulletComments.BulletCommentsExtractor; +import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfo; +import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfoItem; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.views.BulletCommentsView; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class MovieBulletCommentsPlayer { + public MovieBulletCommentsPlayer(final BulletCommentsView bulletCommentsView) { + super(); + this.bulletCommentsView = bulletCommentsView; + } + + private final String TAG = "MovieBCPlayer"; + protected int serviceId; + protected String url; + protected final BulletCommentsView bulletCommentsView; + protected List commentsInfoItems; + private BulletCommentsExtractor extractor; + public boolean isRoundPlayStream = false; + + /** + * Set data. Call before init(). + * + * @param newServiceId Service id. + * @param newUrl Url. + */ + public void setInitialData(final int newServiceId, final String newUrl) { + Log.d(TAG, "setInitialData() serviceId=" + newServiceId + " url=" + newUrl); + this.serviceId = newServiceId; + this.url = newUrl; + } + + public final Duration interval = Duration.ofMillis(50); + protected boolean isLoading = false; + + /** + * Fetch comments and init. Call after setInitialData(). + */ + @SuppressLint("CheckResult") + public void init() { + Log.d(TAG, "init() called for url=" + this.url); + this.bulletCommentsView.clearComments(); + isLoading = true; + try { + ExtractorHelper.getBulletCommentsInfo(this.serviceId, this.url, false) + .filter(Objects::nonNull) + .map((BulletCommentsInfo commentsInfo) -> { + extractor = commentsInfo.getBulletCommentsExtractor(); + extractor.reconnect(); + return commentsInfo.getRelatedItems(); + } + ) + .filter(Objects::nonNull) + .map(s -> s.stream().toArray(BulletCommentsInfoItem[]::new)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((BulletCommentsInfoItem[] newCommentsInfoItems) -> { + this.commentsInfoItems = Arrays.asList(newCommentsInfoItems); + Log.d(TAG, "init() success: got " + + newCommentsInfoItems.length + + " initial comments for " + this.url); + if (extractor != null) { + Log.d(TAG, "init() extractor isLive=" + extractor.isLive() + + " isDisabled=" + extractor.isDisabled()); + } + isLoading = false; + }, + throwable -> Log.e(TAG, Log.getStackTraceString(throwable)) + ); + } catch (final Exception e) { + Log.e(TAG, Log.getStackTraceString(e)); + } + } + + protected Duration lastPosition = Duration.ZERO; + + /** + * Draw all comments which duration is between last + * drawUntilPosition and current drawUntilPosition. + * + * @param drawUntilPosition Duration to draw comments until. + */ + public void drawComments(final Duration drawUntilPosition) { + if (isLoading) { + return; + } + Log.v(TAG, "drawComments() position=" + drawUntilPosition.toMillis() + + "ms extractor=" + (extractor != null ? "yes" : "null")); + final BulletCommentsInfoItem[] nextCommentsInfoItems; + if (extractor.isDisabled()) { + return; + } + if (extractor != null && extractor.isLive()) { + // have to put all messages we get to the pool as they only appear once + try { + nextCommentsInfoItems = extractor.getLiveMessages() + .stream().toArray(BulletCommentsInfoItem[]::new); + if (drawUntilPosition.compareTo(Duration.ofSeconds(Long.MAX_VALUE)) != 0) { + extractor.setCurrentPlayPosition(drawUntilPosition.toMillis()); + } + } catch (final ParsingException e) { + Log.e(TAG, "drawComments() getLiveMessages failed", e); + throw new RuntimeException(e); + } + } else { // we can filter the messages because we have the full list + if (drawUntilPosition.toString().equals("PT0.049S")) { + return; + } + nextCommentsInfoItems = commentsInfoItems + .stream() + .filter(item -> { + final Duration d = item.getDuration(); + return d.compareTo(lastPosition) >= 0 + && d.compareTo(drawUntilPosition) < 0; + } + ) + .toArray(BulletCommentsInfoItem[]::new); + } + Log.v(TAG, "drawComments() drawing " + nextCommentsInfoItems.length + " comments"); + bulletCommentsView.drawComments(nextCommentsInfoItems, drawUntilPosition); + this.lastPosition = drawUntilPosition; + } + + /** + * Resume comments. (Avoids drawing massive comments after skipping.) + * + * @param currentPosition Current position. + */ + public void start(final Duration currentPosition) { + Log.d(TAG, "start() position=" + currentPosition.toMillis() + "ms"); + this.lastPosition = currentPosition; + bulletCommentsView.resumeComments(); + } + + /** + * Pause comments. + */ + public void pause() { + Log.d(TAG, "pause() called"); + bulletCommentsView.pauseComments(); + } + + /** + * Clear comments. + */ + public void clear() { + Log.d(TAG, "clear() called"); + bulletCommentsView.clearComments(); + } + + public void disconnect() { + Log.d(TAG, "disconnect() called"); + if (extractor != null && extractor.isLive()) { + extractor.disconnect(); + } + } + + /** + * Draw all comments after max(movieDuration - interval, lastPosition). + * + * @param movieDuration The duration of the movie, used to avoid drawing too many comments. + */ + public void complete(final Duration movieDuration) { + Log.d(TAG, "complete() called"); + if (lastPosition == null) { + return; //is actually finished + } + final Duration minimumLastPosition = movieDuration.minus(interval); + if (minimumLastPosition.compareTo(lastPosition) >= 0) { + lastPosition = minimumLastPosition; + } + + //Show all comments. + drawComments(Duration.ofSeconds(Long.MAX_VALUE)); + } + + public String getUrl() { + return url; + } +} From a02ae065df59e7964db12d6fde3a295516428040 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 22 Apr 2026 16:55:00 +0200 Subject: [PATCH 03/17] Add bullet comments settings fragment and resources --- .../BulletCommentsSettingsFragment.java | 87 +++++++++++++++++++ .../settings/SettingsResourceRegistry.java | 1 + app/src/main/res/values/settings_keys.xml | 11 +++ app/src/main/res/values/strings.xml | 9 -- .../main/res/xml/bullet_comments_settings.xml | 65 ++++++++++++++ app/src/main/res/xml/main_settings.xml | 7 ++ 6 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java create mode 100644 app/src/main/res/xml/bullet_comments_settings.xml diff --git a/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java new file mode 100644 index 00000000000..674320e0771 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java @@ -0,0 +1,87 @@ +package org.schabi.newpipe.settings; + +import android.content.SharedPreferences; +import android.os.Bundle; +import androidx.preference.SeekBarPreference; +import org.schabi.newpipe.R; + +public class BulletCommentsSettingsFragment extends BasePreferenceFragment { + + private SharedPreferences.OnSharedPreferenceChangeListener listener; + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + + listener = (sharedPreferences, s) -> { + if (s.equals(getString(R.string.top_bottom_bullet_comments_duration_key))) { + final int newSetting = sharedPreferences.getInt(s, 8); + final SeekBarPreference topBottomBulletCommentsDuration = findPreference(s); + if (topBottomBulletCommentsDuration != null) { + topBottomBulletCommentsDuration.setSummary(newSetting + " seconds"); + } + } else if (s.equals(getString(R.string.regular_bullet_comments_duration_key))) { + final int newSetting = sharedPreferences.getInt(s, 8); + final SeekBarPreference regularBulletCommentsDuration = findPreference(s); + if (regularBulletCommentsDuration != null) { + regularBulletCommentsDuration.setSummary(newSetting + " seconds"); + } + } else if (s.equals(getString(R.string.max_bullet_comments_rows_top_key)) + || s.equals(getString(R.string.max_bullet_comments_rows_bottom_key)) + || s.equals(getString(R.string.max_bullet_comments_rows_regular_key))) { + final int newSetting = sharedPreferences.getInt(s, 15); + final SeekBarPreference rowsPref = findPreference(s); + if (rowsPref != null) { + rowsPref.setSummary(String.valueOf(newSetting)); + } + } + }; + + final SeekBarPreference regularBulletCommentsDuration = + findPreference(getString(R.string.regular_bullet_comments_duration_key)); + if (regularBulletCommentsDuration != null) { + regularBulletCommentsDuration.setMin(5); + } + final SeekBarPreference topBottomBulletCommentsDuration = + findPreference(getString(R.string.top_bottom_bullet_comments_duration_key)); + if (topBottomBulletCommentsDuration != null) { + topBottomBulletCommentsDuration.setMin(5); + } + + final SeekBarPreference topRowsPref = + findPreference(getString(R.string.max_bullet_comments_rows_top_key)); + final SeekBarPreference bottomRowsPref = + findPreference(getString(R.string.max_bullet_comments_rows_bottom_key)); + final SeekBarPreference regularRowsPref = + findPreference(getString(R.string.max_bullet_comments_rows_regular_key)); + + final SharedPreferences sharedPreferences = + getPreferenceManager().getSharedPreferences(); + if (topRowsPref != null) { + topRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( + getString(R.string.max_bullet_comments_rows_top_key), 15))); + } + if (bottomRowsPref != null) { + bottomRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( + getString(R.string.max_bullet_comments_rows_bottom_key), 15))); + } + if (regularRowsPref != null) { + regularRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( + getString(R.string.max_bullet_comments_rows_regular_key), 15))); + } + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceManager().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(listener); + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceManager().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(listener); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index 06e0a7c1eae..18fb601736e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -41,6 +41,7 @@ private SettingsResourceRegistry() { add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); + add(BulletCommentsSettingsFragment.class, R.xml.bullet_comments_settings); add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); } diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 5df029ab317..2b06d8fe9f1 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1513,4 +1513,15 @@ @string/image_quality_medium_key @string/image_quality_high_key + + + top_bottom_bullet_comments_duration_key + regular_bullet_comments_duration_key + bullet_comments_outline_radius_key + bullet_comments_font_key + bullet_comments_opacity_key + enable_max_rows_customization_key + max_bullet_comments_rows_top_key + max_bullet_comments_rows_bottom_key + max_bullet_comments_rows_regular_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9a41f1378c..bf9d65948bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -914,15 +914,6 @@ Solution - top_bottom_bullet_comments_duration_key - regular_bullet_comments_duration_key - bullet_comments_outline_radius_key - bullet_comments_font_key - bullet_comments_opacity_key - enable_max_rows_customization_key - max_bullet_comments_rows_top_key - max_bullet_comments_rows_bottom_key - max_bullet_comments_rows_regular_key Bullet comments Configure live chat overlay Regular comments duration diff --git a/app/src/main/res/xml/bullet_comments_settings.xml b/app/src/main/res/xml/bullet_comments_settings.xml new file mode 100644 index 00000000000..9db909539f8 --- /dev/null +++ b/app/src/main/res/xml/bullet_comments_settings.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml index 5f96989f979..d08c4e6b732 100644 --- a/app/src/main/res/xml/main_settings.xml +++ b/app/src/main/res/xml/main_settings.xml @@ -10,6 +10,13 @@ android:title="@string/settings_category_video_audio_title" app:iconSpaceReserved="false" /> + + Date: Wed, 22 Apr 2026 17:18:00 +0200 Subject: [PATCH 04/17] Wire bullet comments extractor into client --- .../java/org/schabi/newpipe/util/ExtractorHelper.java | 10 ++++++++++ .../main/java/org/schabi/newpipe/util/InfoCache.java | 1 + 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 83f2332ed87..c52d86f6a0f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -40,6 +40,7 @@ import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; @@ -168,6 +169,15 @@ public static Single getKioskInfo(final int serviceId, Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } + public static Single getBulletCommentsInfo(final int serviceId, + final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoCache.Type.BULLET_COMMENTS, + Single.fromCallable(() -> + BulletCommentsInfo.getInfo(NewPipe.getService(serviceId), url))); + } + public static Single> getMoreKioskItems(final int serviceId, final String url, final Page nextPage) { diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index b9c91f8a5b0..7aa23b3cf0c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -55,6 +55,7 @@ public enum Type { CHANNEL, CHANNEL_TAB, COMMENTS, + BULLET_COMMENTS, PLAYLIST, KIOSK, } From 9c3b4dc9035b6621ca5ab25a276575df95f3ccaa Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 18:04:00 +0200 Subject: [PATCH 05/17] Add isLiveChat flag to CommentInfo --- .../newpipe/ui/components/video/comment/CommentInfo.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt index 3bd3ec2453a..1fc193e6707 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt @@ -12,7 +12,8 @@ class CommentInfo( val comments: List, val nextPage: Page?, val commentCount: Int, - val isCommentsDisabled: Boolean + val isCommentsDisabled: Boolean, + val isLiveChat: Boolean = false ) { constructor(commentsInfo: CommentsInfo) : this( commentsInfo.serviceId, @@ -20,6 +21,7 @@ class CommentInfo( commentsInfo.relatedItems, commentsInfo.nextPage, commentsInfo.commentsCount, - commentsInfo.isCommentsDisabled + commentsInfo.isCommentsDisabled, + commentsInfo.isLiveChat ) } From a240aedc2bc65717f45cdcfc32b1505d54a849be Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 18:27:00 +0200 Subject: [PATCH 06/17] Implement live chat polling in CommentsViewModel --- .../newpipe/viewmodels/CommentsViewModel.kt | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 00729249855..0763ecebb23 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -1,44 +1,97 @@ package org.schabi.newpipe.viewmodels +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.paging.CommentsSource import org.schabi.newpipe.ui.components.video.comment.CommentInfo import org.schabi.newpipe.util.KEY_URL import org.schabi.newpipe.viewmodels.util.Resource class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + companion object { + private const val TAG = "CommentsViewModel" + } + val uiState = savedStateHandle.getStateFlow(KEY_URL, "") .map { try { - Resource.Success(CommentInfo(CommentsInfo.getInfo(it))) + val info = CommentsInfo.getInfo(it) + Log.i( + TAG, + "Loaded CommentsInfo: disabled=${info.isCommentsDisabled}, " + + "liveChat=${info.isLiveChat}, items=${info.relatedItems.size}, " + + "nextPage=${info.nextPage != null}" + ) + Resource.Success(CommentInfo(info)) } catch (e: Exception) { + Log.e(TAG, "Failed to load comments", e) Resource.Error(e) } } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) + // Live chat via PagingData (broken approach) @OptIn(ExperimentalCoroutinesApi::class) - val comments = uiState + val comments: Flow> = uiState .filterIsInstance>() .flatMapLatest { - Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { - CommentsSource(it.data) - }.flow + val info = it.data + Log.i(TAG, "flatMapLatest: isLiveChat=${info.isLiveChat}, items=${info.comments.size}") + if (info.isLiveChat) { + liveChatPagingData(info) + } else { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(info) + }.flow + } } .cachedIn(viewModelScope) + + private fun liveChatPagingData(info: CommentInfo): Flow> = flow { + val allItems = info.comments.toMutableList() + emit(PagingData.from(allItems)) + var nextPage = info.nextPage + while (true) { + delay(3000) + if (nextPage == null) { + Log.d(TAG, "liveChatPolling: nextPage is null, skipping") + continue + } + try { + Log.d(TAG, "liveChatPolling: fetching more items...") + val result = CommentsInfo.getMoreItems( + NewPipe.getService(info.serviceId), + info.url, + nextPage + ) + Log.i(TAG, "liveChatPolling: fetched ${result.items.size} items") + allItems.addAll(result.items) + emit(PagingData.from(allItems)) + nextPage = result.nextPage + } catch (e: Exception) { + Log.e(TAG, "liveChatPolling: failed to fetch more items", e) + } + } + } } From 3193427be99631ff2b9e30722526b9a031b5b361 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 18:52:00 +0200 Subject: [PATCH 07/17] Render live chat in CommentSection --- .../video/comment/CommentSection.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 4e49676ef3c..3f3229576b5 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -75,7 +75,7 @@ private fun CommentSection( val commentInfo = uiState.data val count = commentInfo.commentCount - if (commentInfo.isCommentsDisabled) { + if (commentInfo.isCommentsDisabled && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, @@ -85,7 +85,7 @@ private fun CommentSection( ) } - } else if (count == 0) { + } else if (count == 0 && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.NoComments, @@ -96,7 +96,7 @@ private fun CommentSection( } } else { // do not show anything if the comment count is unknown - if (count >= 0) { + if (count >= 0 && !commentInfo.isLiveChat) { item { Text( modifier = Modifier @@ -136,8 +136,19 @@ private fun CommentSection( } else -> { - items(comments.itemCount) { - Comment(comment = comments[it]!!) {} + if (comments.itemCount == 0 && commentInfo.isLiveChat) { + item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + } else { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} + } } } } @@ -211,7 +222,8 @@ private fun CommentSectionSuccessPreview() { comments = comments, nextPage = null, commentCount = 10, - isCommentsDisabled = false + isCommentsDisabled = false, + isLiveChat = false ) ), commentsFlow = flowOf(PagingData.from(comments)) From 7727e972bae0d2c6d2a88712b011e38951be718b Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 19:18:00 +0200 Subject: [PATCH 08/17] Fix live chat to use direct list instead of PagingData --- .../video/comment/CommentSection.kt | 99 ++++++++++++------- .../newpipe/viewmodels/CommentsViewModel.kt | 63 +++++++----- 2 files changed, 99 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 3f3229576b5..2dafa76269b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -45,13 +45,15 @@ import org.schabi.newpipe.viewmodels.util.Resource @Composable fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { val state by commentsViewModel.uiState.collectAsStateWithLifecycle() - CommentSection(state, commentsViewModel.comments) + val liveChatItems by commentsViewModel.liveChatItems.collectAsStateWithLifecycle() + CommentSection(state, commentsViewModel.comments, liveChatItems) } @Composable private fun CommentSection( uiState: Resource, - commentsFlow: Flow> + commentsFlow: Flow>, + liveChatItems: List ) { val comments = commentsFlow.collectAsLazyPagingItems() val nestedScrollInterop = rememberNestedScrollInteropConnection() @@ -75,7 +77,7 @@ private fun CommentSection( val commentInfo = uiState.data val count = commentInfo.commentCount - if (commentInfo.isCommentsDisabled && !commentInfo.isLiveChat) { + if (commentInfo.isCommentsDisabled) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, @@ -85,7 +87,7 @@ private fun CommentSection( ) } - } else if (count == 0 && !commentInfo.isLiveChat) { + } else if (count == 0) { item { EmptyStateComposable( spec = EmptyStateSpec.NoComments, @@ -95,7 +97,7 @@ private fun CommentSection( ) } } else { - // do not show anything if the comment count is unknown + // Show title for regular comments, but not for live chat if (count >= 0 && !commentInfo.isLiveChat) { item { Text( @@ -107,47 +109,67 @@ private fun CommentSection( ) } } - when (val refresh = comments.loadState.refresh) { - is LoadState.Loading -> { - item { - LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) - } - } - - is LoadState.Error -> { - val errorInfo = ErrorInfo( - throwable = refresh.error, - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) + if (commentInfo.isLiveChat) { + // Live chat: render items directly without Paging 3 + if (liveChatItems.isEmpty()) { item { - Box( + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, modifier = Modifier .fillMaxWidth() - ) { - ErrorPanel( - errorInfo = errorInfo, - onRetry = { comments.retry() }, - modifier = Modifier.align(Alignment.Center) - ) - } + .heightIn(min = 128.dp) + ) + } + } else { + items(liveChatItems.size, key = { liveChatItems[it].commentId }) { + Comment(comment = liveChatItems[it]) {} } } + } else { + // Normal comments via Paging 3 + when (val refresh = comments.loadState.refresh) { + is LoadState.Loading -> { + item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + } + + is LoadState.Error -> { + val errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) - else -> { - if (comments.itemCount == 0 && commentInfo.isLiveChat) { item { - EmptyStateComposable( - spec = EmptyStateSpec.NoComments, + Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 128.dp) - ) + ) { + ErrorPanel( + errorInfo = errorInfo, + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } } - } else { - items(comments.itemCount) { - Comment(comment = comments[it]!!) {} + } + + else -> { + if (comments.itemCount == 0) { + item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + } else { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} + } } } } @@ -186,7 +208,7 @@ private fun CommentSection( private fun CommentSectionLoadingPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Loading, commentsFlow = flowOf()) + CommentSection(uiState = Resource.Loading, commentsFlow = flowOf(), liveChatItems = emptyList()) } } } @@ -226,7 +248,8 @@ private fun CommentSectionSuccessPreview() { isLiveChat = false ) ), - commentsFlow = flowOf(PagingData.from(comments)) + commentsFlow = flowOf(PagingData.from(comments)), + liveChatItems = emptyList() ) } } @@ -238,7 +261,7 @@ private fun CommentSectionSuccessPreview() { private fun CommentSectionErrorPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf()) + CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf(), liveChatItems = emptyList()) } } } diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 0763ecebb23..9f475d8d111 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -12,13 +12,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.comments.CommentsInfo import org.schabi.newpipe.extractor.comments.CommentsInfoItem @@ -51,7 +55,10 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) - // Live chat via PagingData (broken approach) + // Separate flow for live chat items (not using Paging 3) + private val _liveChatItems = MutableStateFlow>(emptyList()) + val liveChatItems: StateFlow> = _liveChatItems + @OptIn(ExperimentalCoroutinesApi::class) val comments: Flow> = uiState .filterIsInstance>() @@ -59,7 +66,10 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { val info = it.data Log.i(TAG, "flatMapLatest: isLiveChat=${info.isLiveChat}, items=${info.comments.size}") if (info.isLiveChat) { - liveChatPagingData(info) + _liveChatItems.value = info.comments + startLiveChatPolling(info) + // Return empty PagingData for live chat (items come from liveChatItems flow) + kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) } else { Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { CommentsSource(info) @@ -68,29 +78,32 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { } .cachedIn(viewModelScope) - private fun liveChatPagingData(info: CommentInfo): Flow> = flow { - val allItems = info.comments.toMutableList() - emit(PagingData.from(allItems)) + private fun startLiveChatPolling(info: CommentInfo) { var nextPage = info.nextPage - while (true) { - delay(3000) - if (nextPage == null) { - Log.d(TAG, "liveChatPolling: nextPage is null, skipping") - continue - } - try { - Log.d(TAG, "liveChatPolling: fetching more items...") - val result = CommentsInfo.getMoreItems( - NewPipe.getService(info.serviceId), - info.url, - nextPage - ) - Log.i(TAG, "liveChatPolling: fetched ${result.items.size} items") - allItems.addAll(result.items) - emit(PagingData.from(allItems)) - nextPage = result.nextPage - } catch (e: Exception) { - Log.e(TAG, "liveChatPolling: failed to fetch more items", e) + Log.i(TAG, "startLiveChatPolling() items=${info.comments.size}, nextPage=${nextPage != null}") + + viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(3000) + if (nextPage == null) { + Log.d(TAG, "liveChatPolling: nextPage is null, skipping") + continue + } + try { + Log.d(TAG, "liveChatPolling: fetching more items...") + val result = CommentsInfo.getMoreItems( + NewPipe.getService(info.serviceId), + info.url, + nextPage + ) + Log.i(TAG, "liveChatPolling: fetched ${result.items.size} items, nextPage=${result.nextPage != null}") + if (result.items.isNotEmpty()) { + _liveChatItems.value = _liveChatItems.value + result.items + } + nextPage = result.nextPage + } catch (e: Exception) { + Log.e(TAG, "liveChatPolling: failed to fetch more items", e) + } } } } From eb1f1fa319febfa964dc90a903108c76c7336d1b Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 19:42:00 +0200 Subject: [PATCH 09/17] Show newest live chat messages on top --- .../newpipe/ui/components/video/comment/CommentSection.kt | 4 ++-- .../java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 2dafa76269b..f66f94ad08a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -77,7 +77,7 @@ private fun CommentSection( val commentInfo = uiState.data val count = commentInfo.commentCount - if (commentInfo.isCommentsDisabled) { + if (commentInfo.isCommentsDisabled && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, @@ -87,7 +87,7 @@ private fun CommentSection( ) } - } else if (count == 0) { + } else if (count == 0 && !commentInfo.isLiveChat) { item { EmptyStateComposable( spec = EmptyStateSpec.NoComments, diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 9f475d8d111..d5491f58116 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -98,7 +98,7 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { ) Log.i(TAG, "liveChatPolling: fetched ${result.items.size} items, nextPage=${result.nextPage != null}") if (result.items.isNotEmpty()) { - _liveChatItems.value = _liveChatItems.value + result.items + _liveChatItems.value = result.items + _liveChatItems.value } nextPage = result.nextPage } catch (e: Exception) { From 9eb71721bd841e0e797a8aa6f32daca6fdfbceb7 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 23 Apr 2026 20:08:00 +0200 Subject: [PATCH 10/17] Auto-scroll to top when new live chat messages arrive --- .../newpipe/ui/components/video/comment/CommentInfo.kt | 2 +- .../ui/components/video/comment/CommentSection.kt | 9 +++++++++ .../org/schabi/newpipe/viewmodels/CommentsViewModel.kt | 1 - 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt index 1fc193e6707..f8f9711dd19 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt @@ -13,7 +13,7 @@ class CommentInfo( val nextPage: Page?, val commentCount: Int, val isCommentsDisabled: Boolean, - val isLiveChat: Boolean = false + val isLiveChat: Boolean ) { constructor(commentsInfo: CommentsInfo) : this( commentsInfo.serviceId, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index f66f94ad08a..43057268b28 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,6 +60,14 @@ private fun CommentSection( val nestedScrollInterop = rememberNestedScrollInteropConnection() val state = rememberLazyListState() + // Auto-scroll to top when new live chat messages arrive + val isLiveChat = uiState is Resource.Success && uiState.data.isLiveChat + LaunchedEffect(liveChatItems.size) { + if (isLiveChat && liveChatItems.isNotEmpty()) { + state.scrollToItem(0) + } + } + LazyColumnThemedScrollbar(state = state) { LazyColumn( modifier = Modifier diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index d5491f58116..5477ebbe6a6 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn From 7adf5c11c6424adedf34a633b3b4fe449577d300 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 24 Apr 2026 10:27:00 +0200 Subject: [PATCH 11/17] Remove debug logging from live chat polling --- .../newpipe/viewmodels/CommentsViewModel.kt | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 5477ebbe6a6..5965dab77be 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -1,6 +1,5 @@ package org.schabi.newpipe.viewmodels -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -31,23 +30,12 @@ import org.schabi.newpipe.util.KEY_URL import org.schabi.newpipe.viewmodels.util.Resource class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { - companion object { - private const val TAG = "CommentsViewModel" - } - val uiState = savedStateHandle.getStateFlow(KEY_URL, "") .map { try { val info = CommentsInfo.getInfo(it) - Log.i( - TAG, - "Loaded CommentsInfo: disabled=${info.isCommentsDisabled}, " + - "liveChat=${info.isLiveChat}, items=${info.relatedItems.size}, " + - "nextPage=${info.nextPage != null}" - ) Resource.Success(CommentInfo(info)) } catch (e: Exception) { - Log.e(TAG, "Failed to load comments", e) Resource.Error(e) } } @@ -63,7 +51,6 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { .filterIsInstance>() .flatMapLatest { val info = it.data - Log.i(TAG, "flatMapLatest: isLiveChat=${info.isLiveChat}, items=${info.comments.size}") if (info.isLiveChat) { _liveChatItems.value = info.comments startLiveChatPolling(info) @@ -79,29 +66,25 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { private fun startLiveChatPolling(info: CommentInfo) { var nextPage = info.nextPage - Log.i(TAG, "startLiveChatPolling() items=${info.comments.size}, nextPage=${nextPage != null}") viewModelScope.launch(Dispatchers.IO) { while (isActive) { delay(3000) if (nextPage == null) { - Log.d(TAG, "liveChatPolling: nextPage is null, skipping") continue } try { - Log.d(TAG, "liveChatPolling: fetching more items...") val result = CommentsInfo.getMoreItems( NewPipe.getService(info.serviceId), info.url, nextPage ) - Log.i(TAG, "liveChatPolling: fetched ${result.items.size} items, nextPage=${result.nextPage != null}") if (result.items.isNotEmpty()) { _liveChatItems.value = result.items + _liveChatItems.value } nextPage = result.nextPage } catch (e: Exception) { - Log.e(TAG, "liveChatPolling: failed to fetch more items", e) + // Silently ignore polling errors } } } From e283ab2a641ecad8321ce2e025f690959e783907 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 24 Apr 2026 10:30:00 +0200 Subject: [PATCH 12/17] Remove unused bullet comments code --- .../MovieBulletCommentsPlayer.java | 194 --------- .../BulletCommentsSettingsFragment.java | 87 ---- .../settings/SettingsResourceRegistry.java | 1 - .../schabi/newpipe/util/ExtractorHelper.java | 10 - .../org/schabi/newpipe/util/InfoCache.java | 3 +- .../newpipe/views/BulletCommentsView.java | 384 ------------------ .../res/layout/bullet_comments_player.xml | 33 -- app/src/main/res/values/settings_keys.xml | 10 - app/src/main/res/values/strings.xml | 14 - .../main/res/xml/bullet_comments_settings.xml | 65 --- app/src/main/res/xml/main_settings.xml | 7 - 11 files changed, 1 insertion(+), 807 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java delete mode 100644 app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java delete mode 100644 app/src/main/res/layout/bullet_comments_player.xml delete mode 100644 app/src/main/res/xml/bullet_comments_settings.xml diff --git a/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java b/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java deleted file mode 100644 index d7a0b7b1ee4..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/bulletComments/MovieBulletCommentsPlayer.java +++ /dev/null @@ -1,194 +0,0 @@ -package org.schabi.newpipe.player.bulletComments; - -import android.annotation.SuppressLint; -import android.util.Log; - -import org.schabi.newpipe.extractor.bulletComments.BulletCommentsExtractor; -import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfo; -import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfoItem; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.views.BulletCommentsView; - -import java.time.Duration; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class MovieBulletCommentsPlayer { - public MovieBulletCommentsPlayer(final BulletCommentsView bulletCommentsView) { - super(); - this.bulletCommentsView = bulletCommentsView; - } - - private final String TAG = "MovieBCPlayer"; - protected int serviceId; - protected String url; - protected final BulletCommentsView bulletCommentsView; - protected List commentsInfoItems; - private BulletCommentsExtractor extractor; - public boolean isRoundPlayStream = false; - - /** - * Set data. Call before init(). - * - * @param newServiceId Service id. - * @param newUrl Url. - */ - public void setInitialData(final int newServiceId, final String newUrl) { - Log.d(TAG, "setInitialData() serviceId=" + newServiceId + " url=" + newUrl); - this.serviceId = newServiceId; - this.url = newUrl; - } - - public final Duration interval = Duration.ofMillis(50); - protected boolean isLoading = false; - - /** - * Fetch comments and init. Call after setInitialData(). - */ - @SuppressLint("CheckResult") - public void init() { - Log.d(TAG, "init() called for url=" + this.url); - this.bulletCommentsView.clearComments(); - isLoading = true; - try { - ExtractorHelper.getBulletCommentsInfo(this.serviceId, this.url, false) - .filter(Objects::nonNull) - .map((BulletCommentsInfo commentsInfo) -> { - extractor = commentsInfo.getBulletCommentsExtractor(); - extractor.reconnect(); - return commentsInfo.getRelatedItems(); - } - ) - .filter(Objects::nonNull) - .map(s -> s.stream().toArray(BulletCommentsInfoItem[]::new)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe((BulletCommentsInfoItem[] newCommentsInfoItems) -> { - this.commentsInfoItems = Arrays.asList(newCommentsInfoItems); - Log.d(TAG, "init() success: got " - + newCommentsInfoItems.length - + " initial comments for " + this.url); - if (extractor != null) { - Log.d(TAG, "init() extractor isLive=" + extractor.isLive() - + " isDisabled=" + extractor.isDisabled()); - } - isLoading = false; - }, - throwable -> Log.e(TAG, Log.getStackTraceString(throwable)) - ); - } catch (final Exception e) { - Log.e(TAG, Log.getStackTraceString(e)); - } - } - - protected Duration lastPosition = Duration.ZERO; - - /** - * Draw all comments which duration is between last - * drawUntilPosition and current drawUntilPosition. - * - * @param drawUntilPosition Duration to draw comments until. - */ - public void drawComments(final Duration drawUntilPosition) { - if (isLoading) { - return; - } - Log.v(TAG, "drawComments() position=" + drawUntilPosition.toMillis() - + "ms extractor=" + (extractor != null ? "yes" : "null")); - final BulletCommentsInfoItem[] nextCommentsInfoItems; - if (extractor.isDisabled()) { - return; - } - if (extractor != null && extractor.isLive()) { - // have to put all messages we get to the pool as they only appear once - try { - nextCommentsInfoItems = extractor.getLiveMessages() - .stream().toArray(BulletCommentsInfoItem[]::new); - if (drawUntilPosition.compareTo(Duration.ofSeconds(Long.MAX_VALUE)) != 0) { - extractor.setCurrentPlayPosition(drawUntilPosition.toMillis()); - } - } catch (final ParsingException e) { - Log.e(TAG, "drawComments() getLiveMessages failed", e); - throw new RuntimeException(e); - } - } else { // we can filter the messages because we have the full list - if (drawUntilPosition.toString().equals("PT0.049S")) { - return; - } - nextCommentsInfoItems = commentsInfoItems - .stream() - .filter(item -> { - final Duration d = item.getDuration(); - return d.compareTo(lastPosition) >= 0 - && d.compareTo(drawUntilPosition) < 0; - } - ) - .toArray(BulletCommentsInfoItem[]::new); - } - Log.v(TAG, "drawComments() drawing " + nextCommentsInfoItems.length + " comments"); - bulletCommentsView.drawComments(nextCommentsInfoItems, drawUntilPosition); - this.lastPosition = drawUntilPosition; - } - - /** - * Resume comments. (Avoids drawing massive comments after skipping.) - * - * @param currentPosition Current position. - */ - public void start(final Duration currentPosition) { - Log.d(TAG, "start() position=" + currentPosition.toMillis() + "ms"); - this.lastPosition = currentPosition; - bulletCommentsView.resumeComments(); - } - - /** - * Pause comments. - */ - public void pause() { - Log.d(TAG, "pause() called"); - bulletCommentsView.pauseComments(); - } - - /** - * Clear comments. - */ - public void clear() { - Log.d(TAG, "clear() called"); - bulletCommentsView.clearComments(); - } - - public void disconnect() { - Log.d(TAG, "disconnect() called"); - if (extractor != null && extractor.isLive()) { - extractor.disconnect(); - } - } - - /** - * Draw all comments after max(movieDuration - interval, lastPosition). - * - * @param movieDuration The duration of the movie, used to avoid drawing too many comments. - */ - public void complete(final Duration movieDuration) { - Log.d(TAG, "complete() called"); - if (lastPosition == null) { - return; //is actually finished - } - final Duration minimumLastPosition = movieDuration.minus(interval); - if (minimumLastPosition.compareTo(lastPosition) >= 0) { - lastPosition = minimumLastPosition; - } - - //Show all comments. - drawComments(Duration.ofSeconds(Long.MAX_VALUE)); - } - - public String getUrl() { - return url; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java deleted file mode 100644 index 674320e0771..00000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/BulletCommentsSettingsFragment.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.SharedPreferences; -import android.os.Bundle; -import androidx.preference.SeekBarPreference; -import org.schabi.newpipe.R; - -public class BulletCommentsSettingsFragment extends BasePreferenceFragment { - - private SharedPreferences.OnSharedPreferenceChangeListener listener; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResourceRegistry(); - - listener = (sharedPreferences, s) -> { - if (s.equals(getString(R.string.top_bottom_bullet_comments_duration_key))) { - final int newSetting = sharedPreferences.getInt(s, 8); - final SeekBarPreference topBottomBulletCommentsDuration = findPreference(s); - if (topBottomBulletCommentsDuration != null) { - topBottomBulletCommentsDuration.setSummary(newSetting + " seconds"); - } - } else if (s.equals(getString(R.string.regular_bullet_comments_duration_key))) { - final int newSetting = sharedPreferences.getInt(s, 8); - final SeekBarPreference regularBulletCommentsDuration = findPreference(s); - if (regularBulletCommentsDuration != null) { - regularBulletCommentsDuration.setSummary(newSetting + " seconds"); - } - } else if (s.equals(getString(R.string.max_bullet_comments_rows_top_key)) - || s.equals(getString(R.string.max_bullet_comments_rows_bottom_key)) - || s.equals(getString(R.string.max_bullet_comments_rows_regular_key))) { - final int newSetting = sharedPreferences.getInt(s, 15); - final SeekBarPreference rowsPref = findPreference(s); - if (rowsPref != null) { - rowsPref.setSummary(String.valueOf(newSetting)); - } - } - }; - - final SeekBarPreference regularBulletCommentsDuration = - findPreference(getString(R.string.regular_bullet_comments_duration_key)); - if (regularBulletCommentsDuration != null) { - regularBulletCommentsDuration.setMin(5); - } - final SeekBarPreference topBottomBulletCommentsDuration = - findPreference(getString(R.string.top_bottom_bullet_comments_duration_key)); - if (topBottomBulletCommentsDuration != null) { - topBottomBulletCommentsDuration.setMin(5); - } - - final SeekBarPreference topRowsPref = - findPreference(getString(R.string.max_bullet_comments_rows_top_key)); - final SeekBarPreference bottomRowsPref = - findPreference(getString(R.string.max_bullet_comments_rows_bottom_key)); - final SeekBarPreference regularRowsPref = - findPreference(getString(R.string.max_bullet_comments_rows_regular_key)); - - final SharedPreferences sharedPreferences = - getPreferenceManager().getSharedPreferences(); - if (topRowsPref != null) { - topRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( - getString(R.string.max_bullet_comments_rows_top_key), 15))); - } - if (bottomRowsPref != null) { - bottomRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( - getString(R.string.max_bullet_comments_rows_bottom_key), 15))); - } - if (regularRowsPref != null) { - regularRowsPref.setSummary(String.valueOf(sharedPreferences.getInt( - getString(R.string.max_bullet_comments_rows_regular_key), 15))); - } - } - - @Override - public void onResume() { - super.onResume(); - getPreferenceManager().getSharedPreferences() - .registerOnSharedPreferenceChangeListener(listener); - } - - @Override - public void onPause() { - super.onPause(); - getPreferenceManager().getSharedPreferences() - .unregisterOnSharedPreferenceChangeListener(listener); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index 18fb601736e..06e0a7c1eae 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -41,7 +41,6 @@ private SettingsResourceRegistry() { add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); - add(BulletCommentsSettingsFragment.class, R.xml.bullet_comments_settings); add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); } diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index c52d86f6a0f..83f2332ed87 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -40,7 +40,6 @@ import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; @@ -169,15 +168,6 @@ public static Single getKioskInfo(final int serviceId, Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getBulletCommentsInfo(final int serviceId, - final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.BULLET_COMMENTS, - Single.fromCallable(() -> - BulletCommentsInfo.getInfo(NewPipe.getService(serviceId), url))); - } - public static Single> getMoreKioskItems(final int serviceId, final String url, final Page nextPage) { diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index 7aa23b3cf0c..a268c7d6465 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -55,9 +55,8 @@ public enum Type { CHANNEL, CHANNEL_TAB, COMMENTS, - BULLET_COMMENTS, PLAYLIST, - KIOSK, + KIOSK } public static InfoCache getInstance() { diff --git a/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java b/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java deleted file mode 100644 index 4ac51e21d8b..00000000000 --- a/app/src/main/java/org/schabi/newpipe/views/BulletCommentsView.java +++ /dev/null @@ -1,384 +0,0 @@ -package org.schabi.newpipe.views; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Typeface; -import android.os.Build; -import android.util.AttributeSet; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.Gravity; -import android.view.animation.LinearInterpolator; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.constraintlayout.widget.ConstraintLayout; - -import androidx.preference.PreferenceManager; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.BulletCommentsPlayerBinding; -import org.schabi.newpipe.extractor.bulletComments.BulletCommentsInfoItem; - -import java.time.Duration; -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.PriorityQueue; -import java.util.stream.Collectors; - -public final class BulletCommentsView extends ConstraintLayout { - private final String TAG = "BulletCommentsView"; - private SharedPreferences prefs; - - /** - * Tuple of TextView and ObjectAnimator. - */ - private static class AnimatedTextView { - AnimatedTextView(final TextView textView, final ObjectAnimator animator) { - this.textView = textView; - this.animator = animator; - } - - public final TextView textView; - public final ObjectAnimator animator; - } - - public BulletCommentsView(final Context context) { - super(context); - setClipChildren(false); - init(context); - } - - public BulletCommentsView(final Context context, - final AttributeSet attrs) { - super(context, attrs); - setClipChildren(false); - init(context); - } - - public BulletCommentsView(final Context context, - final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - setClipChildren(false); - init(context); - } - - private void init(final Context context) { - final View layout = LayoutInflater.from(context) - .inflate(R.layout.bullet_comments_player, this); - prefs = PreferenceManager.getDefaultSharedPreferences(context); - commentsDuration = prefs.getInt( - context.getString(R.string.top_bottom_bullet_comments_duration_key), 8); - durationFactor = (float) prefs.getInt( - context.getString(R.string.regular_bullet_comments_duration_key), 8) - / (float) commentsDuration; - outlineRadius = prefs.getInt( - context.getString(R.string.bullet_comments_outline_radius_key), 2); - - final boolean limitMaxRows = prefs.getBoolean( - context.getString(R.string.enable_max_rows_customization_key), false); - if (limitMaxRows) { - maxRowsTop = prefs.getInt( - context.getString(R.string.max_bullet_comments_rows_top_key), 15); - maxRowsBottom = prefs.getInt( - context.getString(R.string.max_bullet_comments_rows_bottom_key), 15); - maxRowsRegular = prefs.getInt( - context.getString(R.string.max_bullet_comments_rows_regular_key), 15); - } - - font = prefs.getString(context.getString(R.string.bullet_comments_font_key), "default"); - opacity = prefs.getInt(context.getString(R.string.bullet_comments_opacity_key), 0xFF); - binding = BulletCommentsPlayerBinding.bind(this); - } - - private boolean layoutSet = false; - - private void setLayout() { - final int additionalWidth = additionalSpaceRelative * getWidth(); - binding.bottomRight.getLayoutParams().width = additionalWidth; - requestLayout(); - Log.i(TAG, "Additional width: " + additionalWidth - + ", container width: " + binding.bulletCommentsContainer.getWidth()); - } - - private BulletCommentsPlayerBinding binding; - private final int additionalSpaceRelative = 4; - - private final int commentsRowsCount = 11; - private int lastCalculatedCommentsRowsCount = 11; - private List rows = Collections.synchronizedList(new ArrayList()); - private List> rowsRegular = - Collections.synchronizedList(new ArrayList<>()); - private final double commentRelativeTextSize = 1 / 13.5; - private PriorityQueue bulletCommentsInfoItemRegularPool = - new PriorityQueue<>(); - private PriorityQueue bulletCommentsInfoItemFixedPool = - new PriorityQueue<>(); - - private int commentsDuration; - private float durationFactor; - private int outlineRadius; - private String font; - private int opacity; // 0~255, 0: hide - private final List animatedTextViews = new ArrayList<>(); - - private int maxRowsTop = 1000000; - private int maxRowsBottom = 1000000; - private int maxRowsRegular = 1000000; - - public void clearComments() { - Log.d(TAG, "clearComments() called, animatedViews=" + animatedTextViews.size()); - animatedTextViews.clear(); - if (binding != null) { - binding.bulletCommentsContainer.removeAllViews(); - } - } - - public void setPauseComments(final boolean pause) { - if (pause) { - pauseComments(); - } else { - resumeComments(); - } - } - - public void pauseComments() { - animatedTextViews.stream().forEach(s -> s.animator.pause()); - } - - public void resumeComments() { - animatedTextViews.stream().forEach(s -> s.animator.resume()); - } - - public void drawComments(@NonNull final BulletCommentsInfoItem[] items, - final Duration drawUntilPosition) { - Log.v(TAG, "drawComments() items=" + items.length - + " position=" + drawUntilPosition.toMillis() + "ms"); - if (binding == null || getWidth() == 0 || getHeight() == 0) { - Log.w(TAG, "drawComments() skipped: view not ready"); - return; - } - if (!layoutSet) { - setLayout(); - layoutSet = true; - } - bulletCommentsInfoItemRegularPool.addAll( - Arrays.asList(items).stream() - .filter(x -> x.getPosition() == BulletCommentsInfoItem.Position.REGULAR) - .collect(Collectors.toList())); - bulletCommentsInfoItemFixedPool.addAll( - Arrays.asList(items).stream() - .filter(x -> x.getPosition() != BulletCommentsInfoItem.Position.REGULAR) - .collect(Collectors.toList())); - final int height = getHeight(); - final int width = getWidth(); - final int calculatedCommentRowsCount = - height / Math.min(height, width) * commentsRowsCount; - if (calculatedCommentRowsCount != lastCalculatedCommentsRowsCount) { - lastCalculatedCommentsRowsCount = calculatedCommentRowsCount; - rows.clear(); - rowsRegular.clear(); - } - while (rowsRegular.size() < calculatedCommentRowsCount) { - rowsRegular.add(new AbstractMap.SimpleEntry<>(0L, 0)); - } - while (rows.size() < calculatedCommentRowsCount) { - rows.add(0L); - } - drawCommentsByPool(bulletCommentsInfoItemRegularPool, drawUntilPosition, - height, width, calculatedCommentRowsCount); - drawCommentsByPool(bulletCommentsInfoItemFixedPool, drawUntilPosition, - height, width, calculatedCommentRowsCount); - Log.v(TAG, "drawComments() done, containerChildCount=" - + binding.bulletCommentsContainer.getChildCount()); - } - - public int tryToDrawComment(final BulletCommentsInfoItem item, - final int calculatedCommentRowsCount, - final int width, - final boolean reallyDo) { - final long current = new Date().getTime(); - int row = -1; - final int comparedDuration = (int) (commentsDuration * 1000); - if (item.getPosition().equals(BulletCommentsInfoItem.Position.TOP) - || item.getPosition().equals(BulletCommentsInfoItem.Position.SUPERCHAT)) { - for (int i = 0; i < Math.min(maxRowsTop, calculatedCommentRowsCount); i++) { - final long last = rows.get(i); - if (current - last >= comparedDuration) { - if (reallyDo) { - rows.set(i, current); - } - row = i; - break; - } - } - } else if (item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR)) { - for (int i = 0; i < Math.min(maxRowsRegular, calculatedCommentRowsCount); i++) { - final long lastTime = rowsRegular.get(i).getKey(); - final long lastLength = rowsRegular.get(i).getValue(); - final long t = current - lastTime; - final double tAll = comparedDuration * durationFactor; - final double lx = (lastLength / 25.0 + 1) * width; - final double ly = (item.getCommentText().length() / 25.0 + 1) * width; - final double vx = lx / tAll; - final double vy = ly / tAll; - if ((vy - vx) * (tAll - t) < t * vx - (lastLength / 25.0) * width - && t * vx - (lastLength / 25.0) * width > 0) { - if (reallyDo) { - rowsRegular.set(i, - new AbstractMap.SimpleEntry<>(current, - item.getCommentText().length())); - } - row = i; - break; - } - } - } else { - for (int i = calculatedCommentRowsCount - 1; - i >= Math.max(0, calculatedCommentRowsCount - maxRowsBottom); i--) { - final long last = rows.get(i); - if (current - last >= comparedDuration) { - if (reallyDo) { - rows.set(i, current); - } - row = i; - break; - } - } - } - return row; - } - - private void drawCommentsByPool(final PriorityQueue pool, - final Duration drawUntilPosition, - final int height, - final int width, - final int calculatedCommentRowsCount) { - if (binding == null) { - return; - } - final Context context = binding.bulletCommentsContainer.getContext(); - int drawn = 0; - while (!pool.isEmpty() - && (drawUntilPosition.compareTo(Duration.ofSeconds(Long.MAX_VALUE)) == 0 - || pool.peek().getDuration().toMillis() < drawUntilPosition.toMillis())) { - final BulletCommentsInfoItem item = pool.peek(); - if (item.isLive() - && tryToDrawComment(item, calculatedCommentRowsCount, width, false) == -1) { - Log.v(TAG, "drawCommentsByPool() row collision, skipping item"); - pool.poll(); // skip this item instead of aborting all - continue; - } - pool.poll(); - final TextView textView = new TextView(context); - final Typeface fontToBeUsed; - switch (font) { - case "serif": - fontToBeUsed = Typeface.SERIF; - break; - case "monospace": - fontToBeUsed = Typeface.MONOSPACE; - break; - case "sans-serif": - fontToBeUsed = Typeface.SANS_SERIF; - break; - default: - fontToBeUsed = Typeface.DEFAULT; - break; - } - textView.setGravity(Gravity.CENTER); - int color = item.getArgbColor(); - if (opacity != 0xFF) { - color &= 0x00FFFFFF; - color |= ((opacity & 0xFF) << 24); - } - textView.setTextColor(color); - final String commentText = item.getCommentText(); - Log.v(TAG, "drawCommentsByPool() text=[" + commentText + "] color=" - + String.format("0x%08X", color) + " pos=" + item.getPosition()); - if (commentText.length() == 0) { - Log.v(TAG, "drawCommentsByPool() skipping empty text"); - continue; - } - textView.setText(commentText); - textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, - (float) (Math.min(height, width) * commentRelativeTextSize - * item.getRelativeFontSize())); - textView.setMaxLines(1); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - textView.setTypeface(Typeface.create(fontToBeUsed, Typeface.BOLD, - item.getPosition().equals(BulletCommentsInfoItem.Position.SUPERCHAT))); - } else { - textView.setTypeface(Typeface.create(fontToBeUsed, Typeface.BOLD)); - } - final Paint paint = textView.getPaint(); - int shadowColor = Color.BLACK & 0x00FFFFFF; - shadowColor |= ((opacity & 0xFF) << 24); - paint.setShadowLayer(outlineRadius, 0, 0, shadowColor); - textView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint); - - final int row = tryToDrawComment(item, calculatedCommentRowsCount, width, true); - if (row == -1) { - continue; - } - textView.setX(width); - textView.post(() -> { - final int textWidth = textView.getWidth(); - final int textHeight = textView.getHeight(); - final ObjectAnimator animator; - if (!item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR)) { - animator = ObjectAnimator.ofFloat( - textView, - View.TRANSLATION_X, - (float) ((width - textWidth) / 2.0), - (float) ((width - textWidth) / 2.0) - ); - } else { - animator = ObjectAnimator.ofFloat( - textView, - View.TRANSLATION_X, - width, - -textWidth - ); - } - textView.setY((float) (height * (0.5 + row) / calculatedCommentRowsCount - - textHeight / 2)); - - final AnimatedTextView animatedTextView = new AnimatedTextView( - textView, animator); - animatedTextViews.add(animatedTextView); - animator.setFrameDelay(1); - animator.setInterpolator(new LinearInterpolator()); - animator.setDuration(item.getLastingTime() != -1 - ? item.getLastingTime() - : (long) (commentsDuration * 1000 - * (item.getPosition().equals(BulletCommentsInfoItem.Position.REGULAR) - ? durationFactor : 1))); - animator.addListener(new AnimatorListenerAdapter() { - public void onAnimationEnd(final Animator animation) { - binding.bulletCommentsContainer.removeView(textView); - animatedTextViews.remove(animatedTextView); - } - }); - animator.start(); - }); - binding.bulletCommentsContainer.addView(textView); - drawn++; - } - if (drawn > 0) { - Log.v(TAG, "drawCommentsByPool() drawn=" + drawn); - } - } -} diff --git a/app/src/main/res/layout/bullet_comments_player.xml b/app/src/main/res/layout/bullet_comments_player.xml deleted file mode 100644 index a42a76925e8..00000000000 --- a/app/src/main/res/layout/bullet_comments_player.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 2b06d8fe9f1..2bf8f04bda2 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1514,14 +1514,4 @@ @string/image_quality_high_key - - top_bottom_bullet_comments_duration_key - regular_bullet_comments_duration_key - bullet_comments_outline_radius_key - bullet_comments_font_key - bullet_comments_opacity_key - enable_max_rows_customization_key - max_bullet_comments_rows_top_key - max_bullet_comments_rows_bottom_key - max_bullet_comments_rows_regular_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf9d65948bc..5c64232cf19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,18 +913,4 @@ Details Solution - - Bullet comments - Configure live chat overlay - Regular comments duration - Duration of scrolling comments in seconds - Top/bottom comments duration - Duration of fixed comments in seconds - Outline radius - Font - Opacity - Limit max rows - Max top rows - Max bottom rows - Max regular rows diff --git a/app/src/main/res/xml/bullet_comments_settings.xml b/app/src/main/res/xml/bullet_comments_settings.xml deleted file mode 100644 index 9db909539f8..00000000000 --- a/app/src/main/res/xml/bullet_comments_settings.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml index d08c4e6b732..5f96989f979 100644 --- a/app/src/main/res/xml/main_settings.xml +++ b/app/src/main/res/xml/main_settings.xml @@ -10,13 +10,6 @@ android:title="@string/settings_category_video_audio_title" app:iconSpaceReserved="false" /> - - Date: Fri, 24 Apr 2026 11:05:00 +0200 Subject: [PATCH 13/17] Add separate live chat tab alongside comments tab --- .../newpipe/fragments/detail/TabAdapter.java | 5 + .../fragments/detail/VideoDetailFragment.kt | 35 +++++- .../list/comments/LiveChatFragment.kt | 39 ++++++ .../components/video/comment/CommentInfo.kt | 9 +- .../video/comment/CommentSection.kt | 117 ++++++------------ .../video/comment/LiveChatSection.kt | 71 +++++++++++ .../newpipe/viewmodels/CommentsViewModel.kt | 53 +------- .../newpipe/viewmodels/LiveChatViewModel.kt | 54 ++++++++ app/src/main/res/values/strings.xml | 1 + 9 files changed, 252 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt create mode 100644 app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java index 1a11836d48b..a5aa1564470 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java @@ -40,6 +40,11 @@ public void addFragment(final Fragment fragment, final String title) { mFragmentTitleList.add(title); } + public void addFragment(final Fragment fragment, final String title, final int position) { + mFragmentList.add(position, fragment); + mFragmentTitleList.add(position, title); + } + public void clearAllItems() { mFragmentList.clear(); mFragmentTitleList.clear(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 2d149ec2344..00019731636 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -85,6 +85,7 @@ import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.fragments.EmptyFragment import org.schabi.newpipe.fragments.MainFragment import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance +import org.schabi.newpipe.fragments.list.comments.LiveChatFragment import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate @@ -323,7 +324,10 @@ class VideoDetailFragment : if (tabSettingsChanged) { tabSettingsChanged = false initTabs() - currentInfo?.let { updateTabs(it) } + currentInfo?.let { + updateTabs(it) + addLiveChatTabIfNeeded(it) + } } // Check if it was loading when the fragment was stopped/paused @@ -912,6 +916,33 @@ class VideoDetailFragment : } } + private fun addLiveChatTabIfNeeded(streamInfo: StreamInfo) { + if (!streamInfo.hasLiveChat()) { + return + } + val continuation = streamInfo.liveChatContinuation ?: return + if (continuation.isEmpty()) { + return + } + if (pageAdapter.getItemPositionByTitle(LIVE_CHAT_TAB_TAG) != -1) { + return + } + + val commentsPosition = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG) + val insertPosition = if (commentsPosition >= 0) commentsPosition + 1 else pageAdapter.count + + pageAdapter.addFragment( + LiveChatFragment.getInstance(serviceId, url, continuation), + LIVE_CHAT_TAB_TAG, + insertPosition + ) + tabIcons.add(insertPosition, R.drawable.ic_live_tv) + tabContentDescriptions.add(insertPosition, R.string.live_chat_tab_description) + pageAdapter.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + updateTabLayoutVisibility() + } + fun updateTabLayoutVisibility() { if (nullableBinding == null) { // If binding is null we do not need to and should not do anything with its object(s) @@ -1418,6 +1449,7 @@ class VideoDetailFragment : setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) updateTabs(info) + addLiveChatTabIfNeeded(info) binding.detailThumbnailPlayButton.animate(true, 200) binding.detailVideoTitleView.text = title @@ -2342,6 +2374,7 @@ class VideoDetailFragment : App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" private const val COMMENTS_TAB_TAG = "COMMENTS" + private const val LIVE_CHAT_TAB_TAG = "LIVE_CHAT" private const val RELATED_TAB_TAG = "NEXT VIDEO" private const val DESCRIPTION_TAB_TAG = "DESCRIPTION TAB" private const val EMPTY_TAB_TAG = "EMPTY TAB" diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt new file mode 100644 index 00000000000..d2032f50694 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/LiveChatFragment.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.fragments.list.comments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import org.schabi.newpipe.ui.components.video.comment.LiveChatSection +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.viewmodels.LiveChatViewModel + +class LiveChatFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + AppTheme { + Surface { + LiveChatSection() + } + } + } + + companion object { + @JvmStatic + fun getInstance(serviceId: Int, url: String?, liveChatContinuation: String) = LiveChatFragment().apply { + arguments = bundleOf( + KEY_SERVICE_ID to serviceId, + KEY_URL to url, + LiveChatViewModel.KEY_LIVE_CHAT_CONTINUATION to liveChatContinuation + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt index f8f9711dd19..6c81697fa85 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt @@ -3,17 +3,15 @@ package org.schabi.newpipe.ui.components.video.comment import androidx.compose.runtime.Immutable import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfo -import org.schabi.newpipe.extractor.comments.CommentsInfoItem @Immutable class CommentInfo( val serviceId: Int, val url: String, - val comments: List, + val comments: List, val nextPage: Page?, val commentCount: Int, - val isCommentsDisabled: Boolean, - val isLiveChat: Boolean + val isCommentsDisabled: Boolean ) { constructor(commentsInfo: CommentsInfo) : this( commentsInfo.serviceId, @@ -21,7 +19,6 @@ class CommentInfo( commentsInfo.relatedItems, commentsInfo.nextPage, commentsInfo.commentsCount, - commentsInfo.isCommentsDisabled, - commentsInfo.isLiveChat + commentsInfo.isCommentsDisabled ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 43057268b28..e488c765405 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.flowOf import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.extractor.stream.Description import org.schabi.newpipe.ui.components.common.ErrorPanel @@ -46,28 +44,18 @@ import org.schabi.newpipe.viewmodels.util.Resource @Composable fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { val state by commentsViewModel.uiState.collectAsStateWithLifecycle() - val liveChatItems by commentsViewModel.liveChatItems.collectAsStateWithLifecycle() - CommentSection(state, commentsViewModel.comments, liveChatItems) + CommentSection(state, commentsViewModel.comments) } @Composable private fun CommentSection( uiState: Resource, - commentsFlow: Flow>, - liveChatItems: List + commentsFlow: Flow> ) { val comments = commentsFlow.collectAsLazyPagingItems() val nestedScrollInterop = rememberNestedScrollInteropConnection() val state = rememberLazyListState() - // Auto-scroll to top when new live chat messages arrive - val isLiveChat = uiState is Resource.Success && uiState.data.isLiveChat - LaunchedEffect(liveChatItems.size) { - if (isLiveChat && liveChatItems.isNotEmpty()) { - state.scrollToItem(0) - } - } - LazyColumnThemedScrollbar(state = state) { LazyColumn( modifier = Modifier @@ -86,17 +74,16 @@ private fun CommentSection( val commentInfo = uiState.data val count = commentInfo.commentCount - if (commentInfo.isCommentsDisabled && !commentInfo.isLiveChat) { + if (commentInfo.isCommentsDisabled) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, modifier = Modifier .fillMaxWidth() .heightIn(min = 128.dp) - ) } - } else if (count == 0 && !commentInfo.isLiveChat) { + } else if (count == 0) { item { EmptyStateComposable( spec = EmptyStateSpec.NoComments, @@ -106,8 +93,7 @@ private fun CommentSection( ) } } else { - // Show title for regular comments, but not for live chat - if (count >= 0 && !commentInfo.isLiveChat) { + if (count >= 0) { item { Text( modifier = Modifier @@ -119,66 +105,47 @@ private fun CommentSection( } } - if (commentInfo.isLiveChat) { - // Live chat: render items directly without Paging 3 - if (liveChatItems.isEmpty()) { + when (val refresh = comments.loadState.refresh) { + is LoadState.Loading -> { item { - EmptyStateComposable( - spec = EmptyStateSpec.NoComments, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp) - ) - } - } else { - items(liveChatItems.size, key = { liveChatItems[it].commentId }) { - Comment(comment = liveChatItems[it]) {} + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) } } - } else { - // Normal comments via Paging 3 - when (val refresh = comments.loadState.refresh) { - is LoadState.Loading -> { - item { - LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + + is LoadState.Error -> { + val errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + + item { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + ErrorPanel( + errorInfo = errorInfo, + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) } } + } - is LoadState.Error -> { - val errorInfo = ErrorInfo( - throwable = refresh.error, - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) - + else -> { + if (comments.itemCount == 0) { item { - Box( + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, modifier = Modifier .fillMaxWidth() - ) { - ErrorPanel( - errorInfo = errorInfo, - onRetry = { comments.retry() }, - modifier = Modifier.align(Alignment.Center) - ) - } + .heightIn(min = 128.dp) + ) } - } - - else -> { - if (comments.itemCount == 0) { - item { - EmptyStateComposable( - spec = EmptyStateSpec.NoComments, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp) - ) - } - } else { - items(comments.itemCount) { - Comment(comment = comments[it]!!) {} - } + } else { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} } } } @@ -217,7 +184,7 @@ private fun CommentSection( private fun CommentSectionLoadingPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Loading, commentsFlow = flowOf(), liveChatItems = emptyList()) + CommentSection(uiState = Resource.Loading, commentsFlow = flowOf()) } } } @@ -233,7 +200,7 @@ private fun CommentSectionSuccessPreview() { Description.PLAIN_TEXT ), uploaderName = "Test", - replies = Page(""), + replies = org.schabi.newpipe.extractor.Page(""), replyCount = 10 ) ) + (2..10).map { @@ -253,12 +220,10 @@ private fun CommentSectionSuccessPreview() { comments = comments, nextPage = null, commentCount = 10, - isCommentsDisabled = false, - isLiveChat = false + isCommentsDisabled = false ) ), - commentsFlow = flowOf(PagingData.from(comments)), - liveChatItems = emptyList() + commentsFlow = flowOf(PagingData.from(comments)) ) } } @@ -270,7 +235,7 @@ private fun CommentSectionSuccessPreview() { private fun CommentSectionErrorPreview() { AppTheme { Surface { - CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf(), liveChatItems = emptyList()) + CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf()) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt new file mode 100644 index 00000000000..2c5f010890a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.viewmodels.LiveChatViewModel + +@Composable +fun LiveChatSection(liveChatViewModel: LiveChatViewModel = viewModel()) { + val liveChatItems by liveChatViewModel.liveChatItems.collectAsStateWithLifecycle() + val state = rememberLazyListState() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + + LaunchedEffect(liveChatItems.size) { + if (liveChatItems.isNotEmpty()) { + state.scrollToItem(0) + } + } + + LazyColumnThemedScrollbar(state = state) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInterop), + state = state + ) { + item { + Text( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = "Live Chat", + style = MaterialTheme.typography.titleMedium + ) + } + + if (liveChatItems.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + } else { + items(liveChatItems.size, key = { liveChatItems[it].commentId }) { + Comment(comment = liveChatItems[it]) {} + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt index 5965dab77be..7c5a7c64325 100644 --- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -9,21 +9,14 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.comments.CommentsInfo -import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.paging.CommentsSource import org.schabi.newpipe.ui.components.video.comment.CommentInfo import org.schabi.newpipe.util.KEY_URL @@ -42,51 +35,13 @@ class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) - // Separate flow for live chat items (not using Paging 3) - private val _liveChatItems = MutableStateFlow>(emptyList()) - val liveChatItems: StateFlow> = _liveChatItems - @OptIn(ExperimentalCoroutinesApi::class) - val comments: Flow> = uiState + val comments: Flow> = uiState .filterIsInstance>() .flatMapLatest { - val info = it.data - if (info.isLiveChat) { - _liveChatItems.value = info.comments - startLiveChatPolling(info) - // Return empty PagingData for live chat (items come from liveChatItems flow) - kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) - } else { - Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { - CommentsSource(info) - }.flow - } + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(it.data) + }.flow } .cachedIn(viewModelScope) - - private fun startLiveChatPolling(info: CommentInfo) { - var nextPage = info.nextPage - - viewModelScope.launch(Dispatchers.IO) { - while (isActive) { - delay(3000) - if (nextPage == null) { - continue - } - try { - val result = CommentsInfo.getMoreItems( - NewPipe.getService(info.serviceId), - info.url, - nextPage - ) - if (result.items.isNotEmpty()) { - _liveChatItems.value = result.items + _liveChatItems.value - } - nextPage = result.nextPage - } catch (e: Exception) { - // Silently ignore polling errors - } - } - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt new file mode 100644 index 00000000000..f278a70f722 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/LiveChatViewModel.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL + +class LiveChatViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val serviceId: Int = savedStateHandle[KEY_SERVICE_ID] ?: 0 + private val url: String = savedStateHandle[KEY_URL] ?: "" + private val liveChatContinuation: String = + savedStateHandle.get(KEY_LIVE_CHAT_CONTINUATION) ?: "" + + private val _liveChatItems = MutableStateFlow>(emptyList()) + val liveChatItems: StateFlow> = _liveChatItems + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + val service = NewPipe.getService(serviceId) + val extractor = service.getCommentsExtractor(url) + extractor.setLiveChatContinuation(liveChatContinuation) + val info = CommentsInfo.getInfo(extractor) + _liveChatItems.value = info.relatedItems + var nextPage = info.nextPage + while (isActive) { + delay(3000) + if (nextPage == null) continue + val result = CommentsInfo.getMoreItems(service, url, nextPage) + if (result.items.isNotEmpty()) { + _liveChatItems.value = result.items + _liveChatItems.value + } + nextPage = result.nextPage + } + } catch (e: Exception) { + // Ignore initialization errors + } + } + } + + companion object { + const val KEY_LIVE_CHAT_CONTINUATION = "live_chat_continuation" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c64232cf19..dada1b72ac1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -284,6 +284,7 @@ Likes Dislikes Comments + Live Chat Related items Description No results From cd2808791240ca2985ecde18a47367ba07961616 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 24 Apr 2026 11:30:00 +0200 Subject: [PATCH 14/17] Replace auto-scroll with manual scroll-to-top FAB in live chat --- .../video/comment/LiveChatSection.kt | 114 +++++++++++++----- 1 file changed, 84 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt index 2c5f010890a..0e4e8d4733a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt @@ -1,5 +1,8 @@ package org.schabi.newpipe.ui.components.video.comment +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -7,11 +10,19 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -19,6 +30,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar import org.schabi.newpipe.ui.components.common.LoadingIndicator import org.schabi.newpipe.viewmodels.LiveChatViewModel @@ -28,42 +40,84 @@ fun LiveChatSection(liveChatViewModel: LiveChatViewModel = viewModel()) { val liveChatItems by liveChatViewModel.liveChatItems.collectAsStateWithLifecycle() val state = rememberLazyListState() val nestedScrollInterop = rememberNestedScrollInteropConnection() + val coroutineScope = rememberCoroutineScope() - LaunchedEffect(liveChatItems.size) { - if (liveChatItems.isNotEmpty()) { - state.scrollToItem(0) - } + // Track whether user is at the top of the list + val isAtTop by remember { derivedStateOf { state.firstVisibleItemIndex == 0 } } + + // Track how many messages were seen while at the top + val lastSeenCount = remember { mutableStateOf(0) } + if (isAtTop && liveChatItems.isNotEmpty()) { + lastSeenCount.value = liveChatItems.size } - LazyColumnThemedScrollbar(state = state) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollInterop), - state = state - ) { - item { - Text( - modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp), - text = "Live Chat", - style = MaterialTheme.typography.titleMedium - ) - } + val unreadCount = liveChatItems.size - lastSeenCount.value - if (liveChatItems.isEmpty()) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumnThemedScrollbar(state = state) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInterop), + state = state + ) { item { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp), - contentAlignment = Alignment.Center - ) { - LoadingIndicator() + Text( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = "Live Chat", + style = MaterialTheme.typography.titleMedium + ) + } + + if (liveChatItems.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + } else { + items(liveChatItems.size, key = { liveChatItems[it].commentId }) { + Comment(comment = liveChatItems[it]) {} } } - } else { - items(liveChatItems.size, key = { liveChatItems[it].commentId }) { - Comment(comment = liveChatItems[it]) {} + } + } + + // Floating button to jump to newest messages + AnimatedVisibility( + visible = !isAtTop && unreadCount > 0, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + enter = fadeIn(), + exit = fadeOut() + ) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + state.scrollToItem(0) + } + } + ) { + BadgedBox( + badge = { + Text( + text = unreadCount.toString(), + modifier = Modifier.padding(4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Scroll to new messages" + ) } } } From 0f59bc46e61fa76ecca76fa2d57e7adb782ee181 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 24 Apr 2026 12:00:00 +0200 Subject: [PATCH 15/17] Fix live chat badge contrast and tab synchronization bug --- .../fragments/detail/VideoDetailFragment.kt | 12 +++++------- .../components/video/comment/LiveChatSection.kt | 16 ++++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 00019731636..83f009c8bd6 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -928,16 +928,14 @@ class VideoDetailFragment : return } - val commentsPosition = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG) - val insertPosition = if (commentsPosition >= 0) commentsPosition + 1 else pageAdapter.count - + // Append live chat tab at the end to avoid FragmentPagerAdapter + // position synchronization issues when inserting in the middle pageAdapter.addFragment( LiveChatFragment.getInstance(serviceId, url, continuation), - LIVE_CHAT_TAB_TAG, - insertPosition + LIVE_CHAT_TAB_TAG ) - tabIcons.add(insertPosition, R.drawable.ic_live_tv) - tabContentDescriptions.add(insertPosition, R.string.live_chat_tab_description) + tabIcons.add(R.drawable.ic_live_tv) + tabContentDescriptions.add(R.string.live_chat_tab_description) pageAdapter.notifyDataSetUpdate() updateTabIconsAndContentDescriptions() updateTabLayoutVisibility() diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt index 0e4e8d4733a..c8b3875af3a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/LiveChatSection.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -106,12 +107,15 @@ fun LiveChatSection(liveChatViewModel: LiveChatViewModel = viewModel()) { ) { BadgedBox( badge = { - Text( - text = unreadCount.toString(), - modifier = Modifier.padding(4.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary - ) + Badge( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) { + Text( + text = unreadCount.toString(), + style = MaterialTheme.typography.labelSmall + ) + } } ) { Icon( From 6ab95a937e7dde005a0c9fc4b5e092cddaf5f14c Mon Sep 17 00:00:00 2001 From: h Date: Fri, 24 Apr 2026 12:15:00 +0200 Subject: [PATCH 16/17] Fix tab sync: use title-hash IDs, live chat as 2nd tab, auto-select on live --- .../newpipe/fragments/detail/TabAdapter.java | 8 ++++++++ .../fragments/detail/VideoDetailFragment.kt | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java index a5aa1564470..7d3c4061a14 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java @@ -24,6 +24,14 @@ public TabAdapter(final FragmentManager fm) { this.fragmentManager = fm; } + @Override + public long getItemId(final int position) { + // Use the fragment title hash as ID instead of position. + // This allows inserting fragments in the middle without breaking + // FragmentPagerAdapter's internal fragment cache. + return mFragmentTitleList.get(position).hashCode(); + } + @NonNull @Override public Fragment getItem(final int position) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 83f009c8bd6..aa818ab3c3e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -928,17 +928,26 @@ class VideoDetailFragment : return } - // Append live chat tab at the end to avoid FragmentPagerAdapter - // position synchronization issues when inserting in the middle + val commentsPosition = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG) + val insertPosition = if (commentsPosition >= 0) commentsPosition + 1 else pageAdapter.count + pageAdapter.addFragment( LiveChatFragment.getInstance(serviceId, url, continuation), - LIVE_CHAT_TAB_TAG + LIVE_CHAT_TAB_TAG, + insertPosition ) - tabIcons.add(R.drawable.ic_live_tv) - tabContentDescriptions.add(R.string.live_chat_tab_description) + tabIcons.add(insertPosition, R.drawable.ic_live_tv) + tabContentDescriptions.add(insertPosition, R.string.live_chat_tab_description) pageAdapter.notifyDataSetUpdate() updateTabIconsAndContentDescriptions() updateTabLayoutVisibility() + + // Select live chat tab by default for live streams + if (streamInfo.streamType == StreamType.LIVE_STREAM || + streamInfo.streamType == StreamType.AUDIO_LIVE_STREAM + ) { + binding.viewPager.setCurrentItem(insertPosition, false) + } } fun updateTabLayoutVisibility() { From 7f7a0cfac1445e3b2dd8bf25b83d55511fcd5c73 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 29 Apr 2026 04:49:45 +0200 Subject: [PATCH 17/17] Use the extractor fork for testing the PR --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 777459e64e1..94371fb4578 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,7 +66,7 @@ teamnewpipe-nanojson = "e9d656ddb49a412a5a0a5d5ef20ca7ef09549996" # the corresponding commit hash, since JitPack sometimes deletes artifacts. # If there’s already a git hash, just add more of it to the end (or remove a letter) # to cause jitpack to regenerate the artifact. -teamnewpipe-newpipe-extractor = "1512cf3222b0c5d87a249e6ac231b98090c42623" +teamnewpipe-newpipe-extractor = "2430540339ed616503178f63050cb637e9486b6b" webkit = "1.15.0" work = "2.11.2" @@ -142,7 +142,7 @@ lisawray-groupie-core = { module = "com.github.lisawray.groupie:groupie", versio lisawray-groupie-viewbinding = { module = "com.github.lisawray.groupie:groupie-viewbinding", version.ref = "groupie" } livefront-bridge = { module = "com.github.livefront:bridge", version.ref = "bridge" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } -newpipe-extractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "teamnewpipe-newpipe-extractor" } +newpipe-extractor = { module = "com.github.Ecomont:NewPipeExtractor", version.ref = "teamnewpipe-newpipe-extractor" } newpipe-filepicker = { module = "com.github.TeamNewPipe:NoNonsense-FilePicker", version.ref = "teamnewpipe-filepicker" } newpipe-nanojson = { module = "com.github.TeamNewPipe:nanojson", version.ref = "teamnewpipe-nanojson" } noties-markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" }