Ellipsize playlist description if it is longer than 5 lines

The description can be expanded / collapsed via a "show more" / "show less" button.
This commit is contained in:
TobiGr 2023-06-20 15:56:43 +02:00 committed by Stypox
parent 6c99557553
commit 65eb631711
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
5 changed files with 249 additions and 118 deletions

View file

@ -3,7 +3,7 @@ package org.schabi.newpipe.fragments.list.playlist;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.content.Context;
import android.os.Bundle;
@ -19,7 +19,6 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.text.HtmlCompat;
import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel;
@ -52,10 +51,10 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.text.TextLinkifier;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
import java.util.ArrayList;
import java.util.List;
@ -329,13 +328,24 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
final Description description = result.getDescription();
if (description != null && description != Description.EMPTY_DESCRIPTION
&& !isBlank(description.getContent())) {
TextLinkifier.fromDescription(headerBinding.playlistDescription,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
result.getService(), result.getUrl(),
disposables, SET_LINK_MOVEMENT_METHOD);
headerBinding.playlistDescription.setVisibility(View.VISIBLE);
final TextEllipsizer ellipsizer = new TextEllipsizer(
headerBinding.playlistDescription, 5, getServiceById(result.getServiceId()));
ellipsizer.setStateChangeListener(isEllipsized ->
headerBinding.playlistDescriptionReadMore.setText(
Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less
));
ellipsizer.setOnContentChanged(canBeEllipsized -> {
headerBinding.playlistDescriptionReadMore.setVisibility(
Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE);
if (Boolean.TRUE.equals(canBeEllipsized)) {
ellipsizer.ellipsize();
}
});
ellipsizer.setContent(description);
headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle());
} else {
headerBinding.playlistDescription.setVisibility(View.GONE);
headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE);
}
if (!result.getErrors().isEmpty()) {

View file

@ -1,10 +1,7 @@
package org.schabi.newpipe.info_list.holder;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.graphics.Paint;
import android.text.Layout;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.view.View;
@ -15,42 +12,28 @@ import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import org.schabi.newpipe.util.text.TextEllipsizer;
public class CommentInfoItemHolder extends InfoItemHolder {
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000;
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final Paint paintAtContentSize;
private final float ellipsisWidthPx;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
@ -61,13 +44,8 @@ public class CommentInfoItemHolder extends InfoItemHolder {
private final ImageView itemPinnedView;
private final Button repliesButton;
private final CompositeDisposable disposables = new CompositeDisposable();
@Nullable
private Description commentText;
@Nullable
private StreamingService streamService;
@Nullable
private String streamUrl;
@NonNull
private final TextEllipsizer textEllipsizer;
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
@ -88,9 +66,14 @@ public class CommentInfoItemHolder extends InfoItemHolder {
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
textEllipsizer.setStateChangeListener(isEllipsized -> {
if (Boolean.TRUE.equals(isEllipsized)) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
@Override
@ -139,16 +122,16 @@ public class CommentInfoItemHolder extends InfoItemHolder {
// setup comment content and click listeners to expand/ellipsize it
streamService = getServiceById(item.getServiceId());
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
textEllipsizer.setStreamUrl(item.getUrl());
textEllipsizer.setContent(item.getCommentText());
textEllipsizer.ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
itemView.setOnClickListener(view -> {
toggleEllipsize();
textEllipsizer.toggle();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
@ -202,76 +185,4 @@ public class CommentInfoItemHolder extends InfoItemHolder {
denyLinkFocus();
}
}
private void ellipsize() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false;
final CharSequence charSeqText = itemContentView.getText();
if (charSeqText != null && itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = charSeqText.toString();
final Layout layout = itemContentView.getLayout();
final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1);
final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
itemContentView.setText(newVal);
hasEllipsis = true;
}
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
private void toggleEllipsize() {
final CharSequence text = itemContentView.getText();
if (!isEmpty(text) && text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
expand();
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
}
private void expand() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> determineMovementMethod());
}
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
disposables.clear();
if (commentText != null) {
TextLinkifier.fromDescription(itemContentView, commentText,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
onCompletion);
}
}
}

View file

@ -0,0 +1,195 @@
package org.schabi.newpipe.util.text;
import android.graphics.Paint;
import android.text.Layout;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
/**
* <p>Class to ellipsize text inside a {@link TextView}.</p>
* This class provides all utils to automatically ellipsize and expand a text
*/
public final class TextEllipsizer {
private static final int EXPANDED_LINES = Integer.MAX_VALUE;
private static final String ELLIPSIS = "";
@NonNull private final CompositeDisposable disposable = new CompositeDisposable();
@NonNull private final TextView view;
private final int maxLines;
@NonNull private Description content;
@Nullable private StreamingService streamingService;
@Nullable private String streamUrl;
private boolean isEllipsized = false;
@Nullable private Boolean caBeEllipsized = null;
@NonNull private final Paint paintAtContentSize = new Paint();
private final float ellipsisWidthPx;
@Nullable private Consumer<Boolean> stateChangeListener = null;
@Nullable private Consumer<Boolean> onContentChanged;
public TextEllipsizer(@NonNull final TextView view,
final int maxLines,
@Nullable final StreamingService streamingService) {
this.view = view;
this.maxLines = maxLines;
this.streamingService = streamingService;
paintAtContentSize.setTextSize(view.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public void setOnContentChanged(@Nullable final Consumer<Boolean> onContentChanged) {
this.onContentChanged = onContentChanged;
}
public void setContent(@NonNull final Description content) {
this.content = content;
caBeEllipsized = null;
linkifyContentView(v -> {
final int currentMaxLines = view.getMaxLines();
view.setMaxLines(EXPANDED_LINES);
caBeEllipsized = view.getLineCount() > maxLines;
view.setMaxLines(currentMaxLines);
if (onContentChanged != null) {
onContentChanged.accept(caBeEllipsized);
}
});
}
public void setStreamUrl(@Nullable final String streamUrl) {
this.streamUrl = streamUrl;
}
public void setStreamingService(@NonNull final StreamingService streamingService) {
this.streamingService = streamingService;
}
/**
* Expand the {@link TextEllipsizer#content} to its full length.
*/
public void expand() {
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> isEllipsized = false);
}
/**
* Shorten the {@link TextEllipsizer#content} to the given number of
* {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code }'
* if the text was shorted.
*/
public void ellipsize() {
// expand text to see whether it is necessary to ellipsize the text
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> {
final CharSequence charSeqText = view.getText();
if (charSeqText != null && view.getLineCount() > maxLines) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = charSeqText.toString();
final Layout layout = view.getLayout();
final float lineWidth = layout.getLineWidth(maxLines - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(maxLines - 1);
final int lineEnd = layout.getLineEnd(maxLines - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
view.setText(newVal);
isEllipsized = true;
} else {
isEllipsized = false;
}
view.setMaxLines(maxLines);
});
}
/**
* Toggle the view between the ellipsed and expanded state.
*/
public void toggle() {
if (isEllipsized) {
expand();
} else {
ellipsize();
}
}
/**
* Whether the {@link view} can be ellipsized.
* This is only the case when the {@link content} has more lines
* than allowed via {@link maxLines}.
* @return {@code true} if the {@link content} has more lines than allowed via {@link maxLines}
* and thus can be shortened, {@code false} if the {@code content} fits into the {@link view}
* without being shortened and {@code null} if the initialization is not completed yet.
*/
@Nullable
public Boolean canBeEllipsized() {
return caBeEllipsized;
}
private void linkifyContentView(final Consumer<View> consumer) {
final boolean oldState = isEllipsized;
disposable.clear();
TextLinkifier.fromDescription(view, content,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
v -> {
consumer.accept(v);
notifyStateChangeListener(oldState);
});
}
/**
* Add a listener which is called when the given content is changed,
* either from <em>ellipsized</em> to <em>full</em> or vice versa.
* @param listener The listener to be called.
* The Boolean parameter is the new state.
* <em>Ellipsized</em> content is represented as {@code true},
* normal or <em>full</em> content by {@code false}.
*/
public void setStateChangeListener(final Consumer<Boolean> listener) {
this.stateChangeListener = listener;
}
public void removeStateChangeListener() {
this.stateChangeListener = null;
}
private void notifyStateChangeListener(final boolean oldState) {
if (oldState != isEllipsized && stateChangeListener != null) {
stateChangeListener.accept(isEllipsized);
}
}
}

View file

@ -87,12 +87,25 @@
android:layout_below="@id/playlist_meta"
android:paddingHorizontal="@dimen/video_item_search_padding"
android:paddingTop="6dp"
tools:text="This is a multiline playlist description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" />
android:maxLines="5"
tools:text="This is a multiline playlist description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/playlist_description_read_more"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/playlist_description"
android:gravity="end"
android:paddingHorizontal="@dimen/video_item_search_padding"
android:paddingTop="6dp"
android:text="@string/show_more"
android:layout_marginBottom="6dp"
android:textColor="?attr/colorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/playlist_description">
android:layout_below="@id/playlist_description_read_more">
<include
android:id="@+id/playlist_control"

View file

@ -842,4 +842,6 @@
<item quantity="one">%s reply</item>
<item quantity="other">%s replies</item>
</plurals>
<string name="show_more">Show more</string>
<string name="show_less">Show less</string>
</resources>