mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-04-19 21:31:24 +00:00
fetch and merge
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.apache.commons.text.similarity;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A matching algorithm that is similar to the searching algorithms implemented in editors such
|
||||
* as Sublime Text, TextMate, Atom and others.
|
||||
*
|
||||
* <p>
|
||||
* One point is given for every matched character. Subsequent matches yield two bonus points.
|
||||
* A higher score indicates a higher similarity.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This code has been adapted from Apache Commons Lang 3.3.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* Note: This class was forked from
|
||||
* <a href="https://git.io/JyYJg">
|
||||
* apache/commons-text (8cfdafc) FuzzyScore.java
|
||||
* </a>
|
||||
*/
|
||||
public class FuzzyScore {
|
||||
|
||||
/**
|
||||
* Locale used to change the case of text.
|
||||
*/
|
||||
private final Locale locale;
|
||||
|
||||
|
||||
/**
|
||||
* This returns a {@link Locale}-specific {@link FuzzyScore}.
|
||||
*
|
||||
* @param locale The string matching logic is case insensitive.
|
||||
A {@link Locale} is necessary to normalize both Strings to lower case.
|
||||
* @throws IllegalArgumentException
|
||||
* This is thrown if the {@link Locale} parameter is {@code null}.
|
||||
*/
|
||||
public FuzzyScore(final Locale locale) {
|
||||
if (locale == null) {
|
||||
throw new IllegalArgumentException("Locale must not be null");
|
||||
}
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Fuzzy Score which indicates the similarity score between two
|
||||
* Strings.
|
||||
*
|
||||
* <pre>
|
||||
* score.fuzzyScore(null, null) = IllegalArgumentException
|
||||
* score.fuzzyScore("not null", null) = IllegalArgumentException
|
||||
* score.fuzzyScore(null, "not null") = IllegalArgumentException
|
||||
* score.fuzzyScore("", "") = 0
|
||||
* score.fuzzyScore("Workshop", "b") = 0
|
||||
* score.fuzzyScore("Room", "o") = 1
|
||||
* score.fuzzyScore("Workshop", "w") = 1
|
||||
* score.fuzzyScore("Workshop", "ws") = 2
|
||||
* score.fuzzyScore("Workshop", "wo") = 4
|
||||
* score.fuzzyScore("Apache Software Foundation", "asf") = 3
|
||||
* </pre>
|
||||
*
|
||||
* @param term a full term that should be matched against, must not be null
|
||||
* @param query the query that will be matched against a term, must not be
|
||||
* null
|
||||
* @return result score
|
||||
* @throws IllegalArgumentException if the term or query is {@code null}
|
||||
*/
|
||||
public Integer fuzzyScore(final CharSequence term, final CharSequence query) {
|
||||
if (term == null || query == null) {
|
||||
throw new IllegalArgumentException("CharSequences must not be null");
|
||||
}
|
||||
|
||||
// fuzzy logic is case insensitive. We normalize the Strings to lower
|
||||
// case right from the start. Turning characters to lower case
|
||||
// via Character.toLowerCase(char) is unfortunately insufficient
|
||||
// as it does not accept a locale.
|
||||
final String termLowerCase = term.toString().toLowerCase(locale);
|
||||
final String queryLowerCase = query.toString().toLowerCase(locale);
|
||||
|
||||
// the resulting score
|
||||
int score = 0;
|
||||
|
||||
// the position in the term which will be scanned next for potential
|
||||
// query character matches
|
||||
int termIndex = 0;
|
||||
|
||||
// index of the previously matched character in the term
|
||||
int previousMatchingCharacterIndex = Integer.MIN_VALUE;
|
||||
|
||||
for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) {
|
||||
final char queryChar = queryLowerCase.charAt(queryIndex);
|
||||
|
||||
boolean termCharacterMatchFound = false;
|
||||
for (; termIndex < termLowerCase.length()
|
||||
&& !termCharacterMatchFound; termIndex++) {
|
||||
final char termChar = termLowerCase.charAt(termIndex);
|
||||
|
||||
if (queryChar == termChar) {
|
||||
// simple character matches result in one point
|
||||
score++;
|
||||
|
||||
// subsequent character matches further improve
|
||||
// the score.
|
||||
if (previousMatchingCharacterIndex + 1 == termIndex) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
previousMatchingCharacterIndex = termIndex;
|
||||
|
||||
// we can leave the nested loop. Every character in the
|
||||
// query can match at most one character in the term.
|
||||
termCharacterMatchFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the locale.
|
||||
*
|
||||
* @return The locale
|
||||
*/
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@@ -61,11 +62,13 @@ public final class QueueItemMenuUtil {
|
||||
|
||||
return true;
|
||||
case R.id.menu_item_channel_details:
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
NavigationHelper.openChannelFragmentUsingIntent(context, item.getServiceId(),
|
||||
item.getUploaderUrl(), item.getUploader());
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent(
|
||||
context, item.getServiceId(), uploaderUrl, item.getUploader()
|
||||
));
|
||||
return true;
|
||||
case R.id.menu_item_share:
|
||||
shareText(context, item.getTitle(), item.getUrl(),
|
||||
|
||||
@@ -185,7 +185,11 @@ class AboutActivity : AppCompatActivity() {
|
||||
SoftwareComponent(
|
||||
"RxJava", "2016 - 2020", "RxJava Contributors",
|
||||
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
|
||||
)
|
||||
),
|
||||
SoftwareComponent(
|
||||
"SearchPreference", "2018", "ByteHamster",
|
||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
private const val POS_ABOUT = 0
|
||||
private const val POS_LICENSE = 1
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package org.schabi.newpipe.database.history.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH;
|
||||
|
||||
@Entity(tableName = SearchHistoryEntry.TABLE_NAME,
|
||||
indices = {@Index(value = SEARCH)})
|
||||
public class SearchHistoryEntry {
|
||||
public static final String ID = "id";
|
||||
public static final String TABLE_NAME = "search_history";
|
||||
public static final String SERVICE_ID = "service_id";
|
||||
public static final String CREATION_DATE = "creation_date";
|
||||
public static final String SEARCH = "search";
|
||||
|
||||
@ColumnInfo(name = ID)
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long id;
|
||||
|
||||
@ColumnInfo(name = CREATION_DATE)
|
||||
private OffsetDateTime creationDate;
|
||||
|
||||
@ColumnInfo(name = SERVICE_ID)
|
||||
private int serviceId;
|
||||
|
||||
@ColumnInfo(name = SEARCH)
|
||||
private String search;
|
||||
|
||||
public SearchHistoryEntry(final OffsetDateTime creationDate, final int serviceId,
|
||||
final String search) {
|
||||
this.serviceId = serviceId;
|
||||
this.creationDate = creationDate;
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(final long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreationDate() {
|
||||
return creationDate;
|
||||
}
|
||||
|
||||
public void setCreationDate(final OffsetDateTime creationDate) {
|
||||
this.creationDate = creationDate;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(final int serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getSearch() {
|
||||
return search;
|
||||
}
|
||||
|
||||
public void setSearch(final String search) {
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public boolean hasEqualValues(final SearchHistoryEntry otherEntry) {
|
||||
return getServiceId() == otherEntry.getServiceId()
|
||||
&& getSearch().equals(otherEntry.getSearch());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Entity(
|
||||
tableName = SearchHistoryEntry.TABLE_NAME,
|
||||
indices = [Index(value = [SearchHistoryEntry.SEARCH])]
|
||||
)
|
||||
data class SearchHistoryEntry(
|
||||
@field:ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?,
|
||||
@field:ColumnInfo(
|
||||
name = SERVICE_ID
|
||||
) var serviceId: Int,
|
||||
@field:ColumnInfo(name = SEARCH) var search: String?
|
||||
) {
|
||||
@ColumnInfo(name = ID)
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0
|
||||
|
||||
@Ignore
|
||||
fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean {
|
||||
return (
|
||||
serviceId == otherEntry.serviceId &&
|
||||
search == otherEntry.search
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID = "id"
|
||||
const val TABLE_NAME = "search_history"
|
||||
const val SERVICE_ID = "service_id"
|
||||
const val CREATION_DATE = "creation_date"
|
||||
const val SEARCH = "search"
|
||||
}
|
||||
}
|
||||
@@ -1098,6 +1098,11 @@ public final class VideoDetailFragment
|
||||
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
|
||||
if (isPlayerAvailable()) {
|
||||
// FIXME Workaround #7427
|
||||
player.setRecovery();
|
||||
}
|
||||
|
||||
if (!useExternalAudioPlayer) {
|
||||
openNormalBackgroundPlayer(append);
|
||||
} else {
|
||||
@@ -1114,6 +1119,9 @@ public final class VideoDetailFragment
|
||||
// See UI changes while remote playQueue changes
|
||||
if (!isPlayerAvailable()) {
|
||||
playerHolder.startService(false, this);
|
||||
} else {
|
||||
// FIXME Workaround #7427
|
||||
player.setRecovery();
|
||||
}
|
||||
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
@@ -1521,6 +1529,8 @@ public final class VideoDetailFragment
|
||||
animate(binding.detailThumbnailPlayButton, true, 200);
|
||||
binding.detailVideoTitleView.setText(title);
|
||||
|
||||
binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
|
||||
|
||||
if (!isEmpty(info.getSubChannelName())) {
|
||||
displayBothUploaderAndSubChannel(info);
|
||||
} else if (!isEmpty(info.getUploaderName())) {
|
||||
@@ -2206,12 +2216,20 @@ public final class VideoDetailFragment
|
||||
mainFragment.setDescendantFocusability(afterDescendants);
|
||||
toolbar.setDescendantFocusability(afterDescendants);
|
||||
((ViewGroup) requireView()).setDescendantFocusability(blockDescendants);
|
||||
mainFragment.requestFocus();
|
||||
// Only focus the mainFragment if the mainFragment (e.g. search-results)
|
||||
// or the toolbar (e.g. Textfield for search) don't have focus.
|
||||
// This was done to fix problems with the keyboard input, see also #7490
|
||||
if (!mainFragment.hasFocus() && !toolbar.hasFocus()) {
|
||||
mainFragment.requestFocus();
|
||||
}
|
||||
} else {
|
||||
mainFragment.setDescendantFocusability(blockDescendants);
|
||||
toolbar.setDescendantFocusability(blockDescendants);
|
||||
((ViewGroup) requireView()).setDescendantFocusability(afterDescendants);
|
||||
binding.detailThumbnailRootLayout.requestFocus();
|
||||
// Only focus the player if it not already has focus
|
||||
if (!binding.getRoot().hasFocus()) {
|
||||
binding.detailThumbnailRootLayout.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,10 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
ShareUtils.openUrlInBrowser(requireContext(), url);
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl());
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name, url,
|
||||
currentInfo.getThumbnailUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
|
||||
@@ -25,7 +25,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -34,7 +33,6 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
@@ -65,6 +63,7 @@ import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
@@ -670,31 +669,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showKeyboardSearch() called");
|
||||
}
|
||||
if (searchEditText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchEditText.requestFocus()) {
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.showSoftInput(searchEditText, InputMethodManager.SHOW_FORCED);
|
||||
}
|
||||
KeyboardUtil.showKeyboard(activity, searchEditText);
|
||||
}
|
||||
|
||||
private void hideKeyboardSearch() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "hideKeyboardSearch() called");
|
||||
}
|
||||
if (searchEditText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.hideSoftInputFromWindow(searchEditText.getWindowToken(),
|
||||
InputMethodManager.RESULT_UNCHANGED_SHOWN);
|
||||
|
||||
searchEditText.clearFocus();
|
||||
KeyboardUtil.hideKeyboard(activity, searchEditText);
|
||||
}
|
||||
|
||||
private void showDeleteSuggestionDialog(final SuggestionItem item) {
|
||||
|
||||
@@ -34,12 +34,14 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
|
||||
public final TextView itemTitleView;
|
||||
private final ImageView itemHeartView;
|
||||
private final ImageView itemPinnedView;
|
||||
|
||||
public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_comments_item, parent);
|
||||
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -55,5 +57,7 @@ public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
|
||||
itemTitleView.setText(item.getUploaderName());
|
||||
|
||||
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ fun View.animate(
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
isVisible = true
|
||||
|
||||
when (animationType) {
|
||||
AnimationType.ALPHA -> animateAlpha(enterOrExit, duration, delay, execOnEnd)
|
||||
AnimationType.SCALE_AND_ALPHA -> animateScaleAndAlpha(enterOrExit, duration, delay, execOnEnd)
|
||||
|
||||
@@ -271,7 +271,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
override fun onDestroyView() {
|
||||
// Ensure that all animations are canceled
|
||||
feedBinding.newItemsLoadedButton?.clearAnimation()
|
||||
tryGetNewItemsLoadedButton()?.clearAnimation()
|
||||
|
||||
feedBinding.itemsList.adapter = null
|
||||
_feedBinding = null
|
||||
|
||||
@@ -51,9 +51,6 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.PropertyValuesHolder;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
@@ -154,6 +151,7 @@ import org.schabi.newpipe.info_list.StreamSegmentAdapter;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.event.DisplayPortion;
|
||||
import org.schabi.newpipe.player.event.PlayerEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerGestureListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
@@ -188,6 +186,7 @@ import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.views.ExpandableSurfaceView;
|
||||
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -247,6 +246,7 @@ public final class Player implements
|
||||
public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis
|
||||
public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds
|
||||
public static final int DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds
|
||||
public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Other constants
|
||||
@@ -313,7 +313,6 @@ public final class Player implements
|
||||
|
||||
private PlayerBinding binding;
|
||||
|
||||
private ValueAnimator controlViewAnimator;
|
||||
private final Handler controlsVisibilityHandler = new Handler();
|
||||
|
||||
// fullscreen player
|
||||
@@ -365,6 +364,7 @@ public final class Player implements
|
||||
|
||||
private int maxGestureLength; // scaled
|
||||
private GestureDetectorCompat gestureDetector;
|
||||
private PlayerGestureListener playerGestureListener;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Listeners and disposables
|
||||
@@ -449,6 +449,8 @@ public final class Player implements
|
||||
initPlayer(true);
|
||||
}
|
||||
initListeners();
|
||||
|
||||
setupPlayerSeekOverlay();
|
||||
}
|
||||
|
||||
private void initViews(@NonNull final PlayerBinding playerBinding) {
|
||||
@@ -525,9 +527,9 @@ public final class Player implements
|
||||
binding.resizeTextView.setOnClickListener(this);
|
||||
binding.playbackLiveSync.setOnClickListener(this);
|
||||
|
||||
final PlayerGestureListener listener = new PlayerGestureListener(this, service);
|
||||
gestureDetector = new GestureDetectorCompat(context, listener);
|
||||
binding.getRoot().setOnTouchListener(listener);
|
||||
playerGestureListener = new PlayerGestureListener(this, service);
|
||||
gestureDetector = new GestureDetectorCompat(context, playerGestureListener);
|
||||
binding.getRoot().setOnTouchListener(playerGestureListener);
|
||||
|
||||
binding.queueButton.setOnClickListener(this);
|
||||
binding.segmentsButton.setOnClickListener(this);
|
||||
@@ -578,6 +580,68 @@ public final class Player implements
|
||||
v.getPaddingRight(),
|
||||
v.getPaddingBottom()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Fast-For/Backward overlay.
|
||||
*/
|
||||
private void setupPlayerSeekOverlay() {
|
||||
binding.fastSeekOverlay
|
||||
.seekSecondsSupplier(
|
||||
() -> (int) (retrieveSeekDurationFromPreferences(this) / 1000.0f))
|
||||
.performListener(new PlayerFastSeekOverlay.PerformListener() {
|
||||
|
||||
@Override
|
||||
public void onDoubleTap() {
|
||||
animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDoubleTapEnd() {
|
||||
animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FastSeekDirection getFastSeekDirection(
|
||||
@NonNull final DisplayPortion portion
|
||||
) {
|
||||
if (exoPlayerIsNull()) {
|
||||
// Abort seeking
|
||||
playerGestureListener.endMultiDoubleTap();
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
if (portion == DisplayPortion.LEFT) {
|
||||
// Check if it's possible to rewind
|
||||
// Small puffer to eliminate infinite rewind seeking
|
||||
if (simpleExoPlayer.getCurrentPosition() < 500L) {
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
return FastSeekDirection.BACKWARD;
|
||||
} else if (portion == DisplayPortion.RIGHT) {
|
||||
// Check if it's possible to fast-forward
|
||||
if (currentState == STATE_COMPLETED
|
||||
|| simpleExoPlayer.getCurrentPosition()
|
||||
>= simpleExoPlayer.getDuration()) {
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
return FastSeekDirection.FORWARD;
|
||||
}
|
||||
/* portion == DisplayPortion.MIDDLE */
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seek(final boolean forward) {
|
||||
playerGestureListener.keepInDoubleTapMode();
|
||||
if (forward) {
|
||||
fastForward();
|
||||
} else {
|
||||
fastRewind();
|
||||
}
|
||||
}
|
||||
});
|
||||
playerGestureListener.doubleTapControls(binding.fastSeekOverlay);
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -635,6 +699,7 @@ public final class Player implements
|
||||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||||
|
||||
/*
|
||||
* TODO As seen in #7427 this does not work:
|
||||
* There are 3 situations when playback shouldn't be started from scratch (zero timestamp):
|
||||
* 1. User pressed on a timestamp link and the same video should be rewound to the timestamp
|
||||
* 2. User changed a player from, for example. main to popup, or from audio to main, etc
|
||||
@@ -1795,71 +1860,6 @@ public final class Player implements
|
||||
return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a animation, and depending on goneOnEnd, will stay on the screen or be gone.
|
||||
*
|
||||
* @param drawableId the drawable that will be used to animate,
|
||||
* pass -1 to clear any animation that is visible
|
||||
* @param goneOnEnd will set the animation view to GONE on the end of the animation
|
||||
*/
|
||||
public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showAndAnimateControl() called with: "
|
||||
+ "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]");
|
||||
}
|
||||
if (controlViewAnimator != null && controlViewAnimator.isRunning()) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning");
|
||||
}
|
||||
controlViewAnimator.end();
|
||||
}
|
||||
|
||||
if (drawableId == -1) {
|
||||
if (binding.controlAnimationView.getVisibility() == View.VISIBLE) {
|
||||
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
|
||||
binding.controlAnimationView,
|
||||
PropertyValuesHolder.ofFloat(View.ALPHA, 1.0f, 0.0f),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1.0f),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1.0f)
|
||||
).setDuration(DEFAULT_CONTROLS_DURATION);
|
||||
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(final Animator animation) {
|
||||
binding.controlAnimationView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
controlViewAnimator.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final float scaleFrom = goneOnEnd ? 1f : 1f;
|
||||
final float scaleTo = goneOnEnd ? 1.8f : 1.4f;
|
||||
final float alphaFrom = goneOnEnd ? 1f : 0f;
|
||||
final float alphaTo = goneOnEnd ? 0f : 1f;
|
||||
|
||||
|
||||
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
|
||||
binding.controlAnimationView,
|
||||
PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo)
|
||||
);
|
||||
controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500);
|
||||
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(final Animator animation) {
|
||||
binding.controlAnimationView.setVisibility(goneOnEnd ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
binding.controlAnimationView.setVisibility(View.VISIBLE);
|
||||
binding.controlAnimationView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(context, drawableId));
|
||||
controlViewAnimator.start();
|
||||
}
|
||||
|
||||
public void showControlsThenHide() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showControlsThenHide() called");
|
||||
@@ -1904,6 +1904,7 @@ public final class Player implements
|
||||
}
|
||||
|
||||
private void showHideShadow(final boolean show, final long duration) {
|
||||
animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
}
|
||||
@@ -2047,7 +2048,7 @@ public final class Player implements
|
||||
if (currentState == STATE_BLOCKED) {
|
||||
changeState(STATE_BUFFERING);
|
||||
}
|
||||
simpleExoPlayer.setMediaSource(mediaSource);
|
||||
simpleExoPlayer.setMediaSource(mediaSource, false);
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
|
||||
@@ -2101,8 +2102,8 @@ public final class Player implements
|
||||
startProgressLoop();
|
||||
}
|
||||
|
||||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||||
animate(binding.playbackControlRoot, false, DEFAULT_CONTROLS_DURATION);
|
||||
// if we are e.g. switching players, hide controls
|
||||
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
||||
|
||||
binding.playbackSeekBar.setEnabled(false);
|
||||
binding.playbackSeekBar.getThumb()
|
||||
@@ -2129,8 +2130,6 @@ public final class Player implements
|
||||
|
||||
updateStreamRelatedViews();
|
||||
|
||||
showAndAnimateControl(-1, true);
|
||||
|
||||
binding.playbackSeekBar.setEnabled(true);
|
||||
binding.playbackSeekBar.getThumb()
|
||||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
|
||||
@@ -2178,18 +2177,21 @@ public final class Player implements
|
||||
stopProgressLoop();
|
||||
}
|
||||
|
||||
showControls(400);
|
||||
binding.loadingPanel.setVisibility(View.GONE);
|
||||
|
||||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||||
() -> {
|
||||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||||
animatePlayButtons(true, 200);
|
||||
if (!isQueueVisible) {
|
||||
binding.playPauseButton.requestFocus();
|
||||
}
|
||||
});
|
||||
// Don't let UI elements popup during double tap seeking. This state is entered sometimes
|
||||
// during seeking/loading. This if-else check ensures that the controls aren't popping up.
|
||||
if (!playerGestureListener.isDoubleTapping()) {
|
||||
showControls(400);
|
||||
binding.loadingPanel.setVisibility(View.GONE);
|
||||
|
||||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||||
() -> {
|
||||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||||
animatePlayButtons(true, 200);
|
||||
if (!isQueueVisible) {
|
||||
binding.playPauseButton.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
|
||||
|
||||
// Remove running notification when user does not want minimization to background or popup
|
||||
@@ -2207,7 +2209,6 @@ public final class Player implements
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onPausedSeek() called");
|
||||
}
|
||||
showAndAnimateControl(-1, true);
|
||||
|
||||
animatePlayButtons(false, 100);
|
||||
binding.getRoot().setKeepScreenOn(true);
|
||||
@@ -2516,23 +2517,11 @@ public final class Player implements
|
||||
Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error);
|
||||
|
||||
saveStreamProgressState();
|
||||
|
||||
// create error notification
|
||||
final ErrorInfo errorInfo;
|
||||
if (currentMetadata == null) {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred, currentMetadata is null");
|
||||
} else {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred while playing "
|
||||
+ currentMetadata.getMetadata().getUrl(),
|
||||
currentMetadata.getMetadata());
|
||||
}
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
boolean isCatchableException = false;
|
||||
|
||||
switch (error.type) {
|
||||
case ExoPlaybackException.TYPE_SOURCE:
|
||||
processSourceError(error.getSourceException());
|
||||
isCatchableException = processSourceError(error.getSourceException());
|
||||
break;
|
||||
case ExoPlaybackException.TYPE_UNEXPECTED:
|
||||
setRecovery();
|
||||
@@ -2545,22 +2534,60 @@ public final class Player implements
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCatchableException) {
|
||||
return;
|
||||
}
|
||||
|
||||
createErrorNotification(error);
|
||||
|
||||
if (fragmentListener != null) {
|
||||
fragmentListener.onPlayerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private void processSourceError(final IOException error) {
|
||||
if (exoPlayerIsNull() || playQueue == null) {
|
||||
return;
|
||||
private void createErrorNotification(@NonNull final ExoPlaybackException error) {
|
||||
final ErrorInfo errorInfo;
|
||||
if (currentMetadata == null) {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred, currentMetadata is null");
|
||||
} else {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred while playing "
|
||||
+ currentMetadata.getMetadata().getUrl(),
|
||||
currentMetadata.getMetadata());
|
||||
}
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an {@link IOException} returned by {@link ExoPlaybackException#getSourceException()}
|
||||
* for {@link ExoPlaybackException#TYPE_SOURCE} exceptions.
|
||||
*
|
||||
* <p>
|
||||
* This method sets the recovery position and sends an error message to the play queue if the
|
||||
* exception is not a {@link BehindLiveWindowException}.
|
||||
* </p>
|
||||
* @param error the source error which was thrown by ExoPlayer
|
||||
* @return whether the exception thrown is a {@link BehindLiveWindowException} ({@code false}
|
||||
* is always returned if ExoPlayer or the play queue is null)
|
||||
*/
|
||||
private boolean processSourceError(final IOException error) {
|
||||
if (exoPlayerIsNull() || playQueue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setRecovery();
|
||||
|
||||
if (error instanceof BehindLiveWindowException) {
|
||||
reloadPlayQueueManager();
|
||||
} else {
|
||||
playQueue.error();
|
||||
simpleExoPlayer.seekToDefaultPosition();
|
||||
simpleExoPlayer.prepare();
|
||||
// Inform the user that we are reloading the stream by switching to the buffering state
|
||||
onBuffering();
|
||||
return true;
|
||||
}
|
||||
|
||||
playQueue.error();
|
||||
return false;
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -2837,7 +2864,6 @@ public final class Player implements
|
||||
}
|
||||
seekBy(retrieveSeekDurationFromPreferences(this));
|
||||
triggerProgressUpdate();
|
||||
showAndAnimateControl(R.drawable.ic_fast_forward, true);
|
||||
}
|
||||
|
||||
public void fastRewind() {
|
||||
@@ -2846,7 +2872,6 @@ public final class Player implements
|
||||
}
|
||||
seekBy(-retrieveSeekDurationFromPreferences(this));
|
||||
triggerProgressUpdate();
|
||||
showAndAnimateControl(R.drawable.ic_fast_rewind, true);
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -4278,6 +4303,10 @@ public final class Player implements
|
||||
return binding.currentDisplaySeek;
|
||||
}
|
||||
|
||||
public PlayerFastSeekOverlay getFastSeekOverlay() {
|
||||
return binding.fastSeekOverlay;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public WindowManager.LayoutParams getPopupLayoutParams() {
|
||||
return popupLayoutParams;
|
||||
|
||||
@@ -411,7 +411,7 @@ abstract class BasePlayerGestureListener(
|
||||
var doubleTapControls: DoubleTapListener? = null
|
||||
private set
|
||||
|
||||
val isDoubleTapEnabled: Boolean
|
||||
private val isDoubleTapEnabled: Boolean
|
||||
get() = doubleTapDelay > 0
|
||||
|
||||
var isDoubleTapping = false
|
||||
@@ -459,10 +459,6 @@ abstract class BasePlayerGestureListener(
|
||||
doubleTapControls?.onDoubleTapFinished()
|
||||
}
|
||||
|
||||
fun enableMultiDoubleTap(enable: Boolean) = apply {
|
||||
doubleTapDelay = if (enable) DOUBLE_TAP_DELAY else 0
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
// ///////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -55,12 +55,10 @@ public class PlayerGestureListener
|
||||
player.hideControls(0, 0);
|
||||
}
|
||||
|
||||
if (portion == DisplayPortion.LEFT) {
|
||||
player.fastRewind();
|
||||
if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) {
|
||||
startMultiDoubleTap(event);
|
||||
} else if (portion == DisplayPortion.MIDDLE) {
|
||||
player.playPause();
|
||||
} else if (portion == DisplayPortion.RIGHT) {
|
||||
player.fastForward();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +230,10 @@ public class PlayerGestureListener
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onPopupResizingStart called");
|
||||
}
|
||||
player.showAndAnimateControl(-1, true);
|
||||
player.getLoadingPanel().setVisibility(View.GONE);
|
||||
|
||||
player.hideControls(0, 0);
|
||||
animate(player.getFastSeekOverlay(), false, 0);
|
||||
animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.google.android.exoplayer2.source.SingleSampleMediaSource;
|
||||
import com.google.android.exoplayer2.source.dash.DashMediaSource;
|
||||
import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource;
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
|
||||
import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker;
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource;
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
@@ -17,9 +18,18 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
public class PlayerDataSource {
|
||||
|
||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
||||
|
||||
/**
|
||||
* An approximately 4.3 times greater value than the
|
||||
* {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT default}
|
||||
* to ensure that (very) low latency livestreams which got stuck for a moment don't crash too
|
||||
* early.
|
||||
*/
|
||||
private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15;
|
||||
private static final int MANIFEST_MINIMUM_RETRY = 5;
|
||||
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
|
||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
||||
|
||||
private final DataSource.Factory cacheDataSourceFactory;
|
||||
private final DataSource.Factory cachelessDataSourceFactory;
|
||||
@@ -44,8 +54,13 @@ public class PlayerDataSource {
|
||||
public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() {
|
||||
return new HlsMediaSource.Factory(cachelessDataSourceFactory)
|
||||
.setAllowChunklessPreparation(true)
|
||||
.setLoadErrorHandlingPolicy(
|
||||
new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY));
|
||||
.setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(
|
||||
MANIFEST_MINIMUM_RETRY))
|
||||
.setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy,
|
||||
playlistParserFactory) ->
|
||||
new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy,
|
||||
playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)
|
||||
);
|
||||
}
|
||||
|
||||
public DashMediaSource.Factory getLiveDashMediaSourceFactory() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
@@ -15,14 +14,10 @@ import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
||||
private static final boolean CAPTIONING_SETTINGS_ACCESSIBLE =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
|
||||
|
||||
private String captionSettingsKey;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.appearance_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final String themeKey = getString(R.string.theme_key);
|
||||
// the key of the active theme when settings were opened (or recreated after theme change)
|
||||
@@ -51,16 +46,11 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
||||
} else {
|
||||
removePreference(nightThemeKey);
|
||||
}
|
||||
|
||||
captionSettingsKey = getString(R.string.caption_settings_key);
|
||||
if (!CAPTIONING_SETTINGS_ACCESSIBLE) {
|
||||
removePreference(captionSettingsKey);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceTreeClick(final Preference preference) {
|
||||
if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) {
|
||||
if (preference.getKey().equals(getString(R.string.caption_settings_key))) {
|
||||
try {
|
||||
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
|
||||
@@ -28,6 +28,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
protected void addPreferencesFromResourceRegistry() {
|
||||
addPreferencesFromResource(
|
||||
SettingsResourceRegistry.getInstance().getPreferencesResId(this.getClass()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -21,7 +24,6 @@ import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.localization.Localization;
|
||||
@@ -38,9 +40,6 @@ import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private static final String ZIP_MIME_TYPE = "application/zip";
|
||||
|
||||
@@ -70,7 +69,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
importExportDataPathKey = getString(R.string.import_export_data_path);
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
|
||||
addPreferencesFromResource(R.xml.content_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final Preference importDataPreference = requirePreference(R.string.import_data);
|
||||
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
|
||||
@@ -105,21 +104,6 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
.getPreferredContentCountry(requireContext());
|
||||
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
|
||||
final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key);
|
||||
clearCookiePref.setOnPreferenceClickListener(preference -> {
|
||||
defaultPreferences.edit()
|
||||
.putString(getString(R.string.recaptcha_cookies_key), "").apply();
|
||||
DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "");
|
||||
Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
clearCookiePref.setVisible(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) {
|
||||
clearCookiePref.setVisible(false);
|
||||
}
|
||||
|
||||
findPreference(getString(R.string.download_thumbnail_key)).setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
PicassoHelper.setShouldLoadImages((Boolean) newValue);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
private static final String DUMMY = "Dummy";
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final Preference allowHeapDumpingPreference
|
||||
= findPreference(getString(R.string.allow_heap_dumping_key));
|
||||
final Preference showMemoryLeaksPreference
|
||||
= findPreference(getString(R.string.show_memory_leaks_key));
|
||||
final Preference showImageIndicatorsPreference
|
||||
= findPreference(getString(R.string.show_image_indicators_key));
|
||||
final Preference crashTheAppPreference
|
||||
= findPreference(getString(R.string.crash_the_app_key));
|
||||
final Preference showErrorSnackbarPreference
|
||||
= findPreference(getString(R.string.show_error_snackbar_key));
|
||||
final Preference createErrorNotificationPreference
|
||||
= findPreference(getString(R.string.create_error_notification_key));
|
||||
|
||||
assert allowHeapDumpingPreference != null;
|
||||
assert showMemoryLeaksPreference != null;
|
||||
assert showImageIndicatorsPreference != null;
|
||||
assert crashTheAppPreference != null;
|
||||
assert showErrorSnackbarPreference != null;
|
||||
assert createErrorNotificationPreference != null;
|
||||
|
||||
final Optional<DebugSettingsBVDLeakCanaryAPI> optBVLeakCanary = getBVDLeakCanary();
|
||||
|
||||
allowHeapDumpingPreference.setEnabled(optBVLeakCanary.isPresent());
|
||||
showMemoryLeaksPreference.setEnabled(optBVLeakCanary.isPresent());
|
||||
|
||||
if (optBVLeakCanary.isPresent()) {
|
||||
final DebugSettingsBVDLeakCanaryAPI pdLeakCanary = optBVLeakCanary.get();
|
||||
|
||||
showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> {
|
||||
startActivity(pdLeakCanary.getNewLeakDisplayActivityIntent());
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available);
|
||||
showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available);
|
||||
}
|
||||
|
||||
showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
PicassoHelper.setIndicatorsEnabled((Boolean) newValue);
|
||||
return true;
|
||||
});
|
||||
|
||||
crashTheAppPreference.setOnPreferenceClickListener(preference -> {
|
||||
throw new RuntimeException(DUMMY);
|
||||
});
|
||||
|
||||
showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> {
|
||||
ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this,
|
||||
DUMMY, new RuntimeException(DUMMY));
|
||||
return true;
|
||||
});
|
||||
|
||||
createErrorNotificationPreference.setOnPreferenceClickListener(preference -> {
|
||||
ErrorUtil.createNotification(requireContext(),
|
||||
new ErrorInfo(new RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available.
|
||||
* @return An {@link Optional} which is empty if the implementation class couldn't be loaded.
|
||||
*/
|
||||
private Optional<DebugSettingsBVDLeakCanaryAPI> getBVDLeakCanary() {
|
||||
try {
|
||||
// Try to find the implementation of the LeakCanary API
|
||||
return Optional.of((DebugSettingsBVDLeakCanaryAPI)
|
||||
Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance());
|
||||
} catch (final Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build variant dependent (BVD) leak canary API for this fragment.
|
||||
* Why is LeakCanary not used directly? Because it can't be assured
|
||||
*/
|
||||
public interface DebugSettingsBVDLeakCanaryAPI {
|
||||
String IMPL_CLASS =
|
||||
"org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary";
|
||||
|
||||
Intent getNewLeakDisplayActivityIntent();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.download_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
downloadPathVideoPreference = getString(R.string.download_path_video_key);
|
||||
downloadPathAudioPreference = getString(R.string.download_path_audio_key);
|
||||
|
||||
@@ -8,9 +8,11 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.InfoCache;
|
||||
@@ -29,7 +31,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.history_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
cacheWipeKey = getString(R.string.metadata_cache_wipe_key);
|
||||
viewsHistoryClearKey = getString(R.string.clear_views_history_key);
|
||||
@@ -37,6 +39,21 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
||||
searchHistoryClearKey = getString(R.string.clear_search_history_key);
|
||||
recordManager = new HistoryRecordManager(getActivity());
|
||||
disposables = new CompositeDisposable();
|
||||
|
||||
final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key);
|
||||
clearCookiePref.setOnPreferenceClickListener(preference -> {
|
||||
defaultPreferences.edit()
|
||||
.putString(getString(R.string.recaptcha_cookies_key), "").apply();
|
||||
DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "");
|
||||
Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
clearCookiePref.setEnabled(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) {
|
||||
clearCookiePref.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.preference.Preference;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.CheckForNewAppVersion;
|
||||
@@ -12,16 +15,58 @@ import org.schabi.newpipe.R;
|
||||
public class MainSettingsFragment extends BasePreferenceFragment {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
private SettingsActivity settingsActivity;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.main_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
|
||||
|
||||
// Check if the app is updatable
|
||||
if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
|
||||
final Preference update
|
||||
= findPreference(getString(R.string.update_pref_screen_key));
|
||||
getPreferenceScreen().removePreference(update);
|
||||
getPreferenceScreen().removePreference(
|
||||
findPreference(getString(R.string.update_pref_screen_key)));
|
||||
|
||||
defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply();
|
||||
}
|
||||
|
||||
// Hide debug preferences in RELEASE build variant
|
||||
if (!DEBUG) {
|
||||
getPreferenceScreen().removePreference(
|
||||
findPreference(getString(R.string.debug_pref_screen_key)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(
|
||||
@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater
|
||||
) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
// -- Link settings activity and register menu --
|
||||
settingsActivity = (SettingsActivity) getActivity();
|
||||
|
||||
inflater.inflate(R.menu.menu_settings_main_fragment, menu);
|
||||
|
||||
final MenuItem menuSearchItem = menu.getItem(0);
|
||||
|
||||
settingsActivity.setMenuSearchItem(menuSearchItem);
|
||||
|
||||
menuSearchItem.setOnMenuItemClickListener(ev -> {
|
||||
settingsActivity.setSearchActive(true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
// Unlink activity so that we don't get memory problems
|
||||
if (settingsActivity != null) {
|
||||
settingsActivity.setMenuSearchItem(null);
|
||||
settingsActivity = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.schabi.newpipe.R
|
||||
|
||||
class NotificationSettingsFragment : BasePreferenceFragment() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.notification_settings)
|
||||
addPreferencesFromResourceRegistry()
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -51,16 +50,11 @@ public class SelectKioskFragment extends DialogFragment {
|
||||
private SelectKioskAdapter selectKioskAdapter = null;
|
||||
|
||||
private OnSelectedListener onSelectedListener = null;
|
||||
private OnCancelListener onCancelListener = null;
|
||||
|
||||
public void setOnSelectedListener(final OnSelectedListener listener) {
|
||||
onSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnCancelListener(final OnCancelListener listener) {
|
||||
onCancelListener = listener;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -91,14 +85,6 @@ public class SelectKioskFragment extends DialogFragment {
|
||||
// Handle actions
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCancel(@NonNull final DialogInterface dialogInterface) {
|
||||
super.onCancel(dialogInterface);
|
||||
if (onCancelListener != null) {
|
||||
onCancelListener.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void clickedItem(final SelectKioskAdapter.Entry entry) {
|
||||
if (onSelectedListener != null) {
|
||||
onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName);
|
||||
@@ -114,10 +100,6 @@ public class SelectKioskFragment extends DialogFragment {
|
||||
void onKioskSelected(int serviceId, String kioskId, String kioskName);
|
||||
}
|
||||
|
||||
public interface OnCancelListener {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectKioskAdapter
|
||||
extends RecyclerView.Adapter<SelectKioskAdapter.SelectKioskItemHolder> {
|
||||
private final List<Entry> kioskList = new Vector<>();
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import com.jakewharton.rxbinding4.widget.RxTextView;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.CheckForNewAppVersion;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.SettingsLayoutBinding;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceParser;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 31.08.15.
|
||||
@@ -38,21 +64,54 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity
|
||||
implements BasePreferenceFragment.OnPreferenceStartFragmentCallback {
|
||||
public class SettingsActivity extends AppCompatActivity implements
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
PreferenceSearchResultListener {
|
||||
private static final String TAG = "SettingsActivity";
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
@IdRes
|
||||
private static final int FRAGMENT_HOLDER_ID = R.id.settings_fragment_holder;
|
||||
|
||||
private PreferenceSearchFragment searchFragment;
|
||||
|
||||
@Nullable
|
||||
private MenuItem menuSearchItem;
|
||||
|
||||
private View searchContainer;
|
||||
private EditText searchEditText;
|
||||
|
||||
// State
|
||||
@State
|
||||
String searchText;
|
||||
@State
|
||||
boolean wasSearchActive;
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceBundle) {
|
||||
setTheme(ThemeHelper.getSettingsThemeStyle(this));
|
||||
assureCorrectAppLanguage(this);
|
||||
|
||||
super.onCreate(savedInstanceBundle);
|
||||
Icepick.restoreInstanceState(this, savedInstanceBundle);
|
||||
final boolean restored = savedInstanceBundle != null;
|
||||
|
||||
final SettingsLayoutBinding settingsLayoutBinding =
|
||||
SettingsLayoutBinding.inflate(getLayoutInflater());
|
||||
setContentView(settingsLayoutBinding.getRoot());
|
||||
initSearch(settingsLayoutBinding, restored);
|
||||
|
||||
setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar);
|
||||
|
||||
if (savedInstanceBundle == null) {
|
||||
if (restored) {
|
||||
// Restore state
|
||||
if (this.wasSearchActive) {
|
||||
setSearchActive(true);
|
||||
if (!TextUtils.isEmpty(this.searchText)) {
|
||||
this.searchEditText.setText(this.searchText);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.settings_fragment_holder, new MainSettingsFragment())
|
||||
.commit();
|
||||
@@ -63,6 +122,12 @@ public class SettingsActivity extends AppCompatActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
Icepick.saveInstanceState(this, outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(final Menu menu) {
|
||||
final ActionBar actionBar = getSupportActionBar();
|
||||
@@ -74,10 +139,25 @@ public class SettingsActivity extends AppCompatActivity
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (isSearchActive()) {
|
||||
setSearchActive(false);
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
final int id = item.getItemId();
|
||||
if (id == android.R.id.home) {
|
||||
// Check if the search is active and if so: Close it
|
||||
if (isSearchActive()) {
|
||||
setSearchActive(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
|
||||
finish();
|
||||
} else {
|
||||
@@ -91,14 +171,221 @@ public class SettingsActivity extends AppCompatActivity
|
||||
@Override
|
||||
public boolean onPreferenceStartFragment(final PreferenceFragmentCompat caller,
|
||||
final Preference preference) {
|
||||
final Fragment fragment = Fragment
|
||||
.instantiate(this, preference.getFragment(), preference.getExtras());
|
||||
showSettingsFragment(instantiateFragment(preference.getFragment()));
|
||||
return true;
|
||||
}
|
||||
|
||||
private Fragment instantiateFragment(@NonNull final String className) {
|
||||
return getSupportFragmentManager()
|
||||
.getFragmentFactory()
|
||||
.instantiate(this.getClassLoader(), className);
|
||||
}
|
||||
|
||||
private void showSettingsFragment(final Fragment fragment) {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out,
|
||||
R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
.replace(R.id.settings_fragment_holder, fragment)
|
||||
.replace(FRAGMENT_HOLDER_ID, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
setMenuSearchItem(null);
|
||||
searchFragment = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Search
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
//region Search
|
||||
|
||||
private void initSearch(
|
||||
final SettingsLayoutBinding settingsLayoutBinding,
|
||||
final boolean restored
|
||||
) {
|
||||
searchContainer =
|
||||
settingsLayoutBinding.settingsToolbarLayout.toolbar
|
||||
.findViewById(R.id.toolbar_search_container);
|
||||
|
||||
// Configure input field for search
|
||||
searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text);
|
||||
RxTextView.textChanges(searchEditText)
|
||||
// Wait some time after the last input before actually searching
|
||||
.debounce(200, TimeUnit.MILLISECONDS)
|
||||
.subscribe(v -> runOnUiThread(this::onSearchChanged));
|
||||
|
||||
// Configure clear button
|
||||
searchContainer.findViewById(R.id.toolbar_search_clear)
|
||||
.setOnClickListener(ev -> resetSearchText());
|
||||
|
||||
ensureSearchRepresentsApplicationState();
|
||||
|
||||
// Build search configuration using SettingsResourceRegistry
|
||||
final PreferenceSearchConfiguration config = new PreferenceSearchConfiguration();
|
||||
|
||||
|
||||
// Build search items
|
||||
final PreferenceParser parser = new PreferenceParser(getApplicationContext(), config);
|
||||
final PreferenceSearcher searcher = new PreferenceSearcher(config);
|
||||
|
||||
// Find all searchable SettingsResourceRegistry fragments
|
||||
SettingsResourceRegistry.getInstance().getAllEntries().stream()
|
||||
.filter(SettingsResourceRegistry.SettingRegistryEntry::isSearchable)
|
||||
// Get the resId
|
||||
.map(SettingsResourceRegistry.SettingRegistryEntry::getPreferencesResId)
|
||||
// Parse
|
||||
.map(parser::parse)
|
||||
// Add it to the searcher
|
||||
.forEach(searcher::add);
|
||||
|
||||
if (restored) {
|
||||
searchFragment = (PreferenceSearchFragment) getSupportFragmentManager()
|
||||
.findFragmentByTag(PreferenceSearchFragment.NAME);
|
||||
if (searchFragment != null) {
|
||||
// Hide/Remove the search fragment otherwise we get an exception
|
||||
// when adding it (because it's already present)
|
||||
hideSearchFragment();
|
||||
}
|
||||
}
|
||||
if (searchFragment == null) {
|
||||
searchFragment = new PreferenceSearchFragment();
|
||||
}
|
||||
searchFragment.setSearcher(searcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the search shows the correct/available search results.
|
||||
* <br/>
|
||||
* Some features are e.g. only available for debug builds, these should not
|
||||
* be found when searching inside a release.
|
||||
*/
|
||||
private void ensureSearchRepresentsApplicationState() {
|
||||
// Check if the update settings are available
|
||||
if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getEntryByPreferencesResId(R.xml.update_settings)
|
||||
.setSearchable(false);
|
||||
}
|
||||
|
||||
// Hide debug preferences in RELEASE build variant
|
||||
if (DEBUG) {
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getEntryByPreferencesResId(R.xml.debug_settings)
|
||||
.setSearchable(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMenuSearchItem(final MenuItem menuSearchItem) {
|
||||
this.menuSearchItem = menuSearchItem;
|
||||
|
||||
// Ensure that the item is in the correct state when adding it. This is due to
|
||||
// Android's lifecycle (the Activity is recreated before the Fragment that registers this)
|
||||
if (menuSearchItem != null) {
|
||||
menuSearchItem.setVisible(!isSearchActive());
|
||||
}
|
||||
}
|
||||
|
||||
public void setSearchActive(final boolean active) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "setSearchActive called active=" + active);
|
||||
}
|
||||
|
||||
// Ignore if search is already in correct state
|
||||
if (isSearchActive() == active) {
|
||||
return;
|
||||
}
|
||||
|
||||
wasSearchActive = active;
|
||||
|
||||
searchContainer.setVisibility(active ? View.VISIBLE : View.GONE);
|
||||
if (menuSearchItem != null) {
|
||||
menuSearchItem.setVisible(!active);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME)
|
||||
.addToBackStack(PreferenceSearchFragment.NAME)
|
||||
.commit();
|
||||
|
||||
KeyboardUtil.showKeyboard(this, searchEditText);
|
||||
} else if (searchFragment != null) {
|
||||
hideSearchFragment();
|
||||
getSupportFragmentManager()
|
||||
.popBackStack(
|
||||
PreferenceSearchFragment.NAME,
|
||||
FragmentManager.POP_BACK_STACK_INCLUSIVE);
|
||||
|
||||
KeyboardUtil.hideKeyboard(this, searchEditText);
|
||||
}
|
||||
|
||||
resetSearchText();
|
||||
}
|
||||
|
||||
private void hideSearchFragment() {
|
||||
getSupportFragmentManager().beginTransaction().remove(searchFragment).commit();
|
||||
}
|
||||
|
||||
private void resetSearchText() {
|
||||
searchEditText.setText("");
|
||||
}
|
||||
|
||||
private boolean isSearchActive() {
|
||||
return searchContainer.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
private void onSearchChanged() {
|
||||
if (!isSearchActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchFragment != null) {
|
||||
searchText = this.searchEditText.getText().toString();
|
||||
searchFragment.updateSearchResults(searchText);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchResultClicked(@NonNull final PreferenceSearchItem result) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSearchResultClicked called result=" + result);
|
||||
}
|
||||
|
||||
// Hide the search
|
||||
setSearchActive(false);
|
||||
|
||||
// -- Highlight the result --
|
||||
// Find out which fragment class we need
|
||||
final Class<? extends Fragment> targetedFragmentClass =
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getFragmentClass(result.getSearchIndexItemResId());
|
||||
|
||||
if (targetedFragmentClass == null) {
|
||||
// This should never happen
|
||||
Log.w(TAG, "Unable to locate fragment class for resId="
|
||||
+ result.getSearchIndexItemResId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the currentFragment is the one which contains the result
|
||||
Fragment currentFragment =
|
||||
getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID);
|
||||
if (!targetedFragmentClass.equals(currentFragment.getClass())) {
|
||||
// If it's not the correct one display the correct one
|
||||
currentFragment = instantiateFragment(targetedFragmentClass.getName());
|
||||
showSettingsFragment(currentFragment);
|
||||
}
|
||||
|
||||
// Run the highlighting
|
||||
if (currentFragment instanceof PreferenceFragmentCompat) {
|
||||
PreferenceSearchResultHighlighter
|
||||
.highlight(result, (PreferenceFragmentCompat) currentFragment);
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.XmlRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A registry that contains information about SettingsFragments.
|
||||
* <br/>
|
||||
* includes:
|
||||
* <ul>
|
||||
* <li>Class of the SettingsFragment</li>
|
||||
* <li>XML-Resource</li>
|
||||
* <li>...</li>
|
||||
* </ul>
|
||||
*
|
||||
* E.g. used by the preference search.
|
||||
*/
|
||||
public final class SettingsResourceRegistry {
|
||||
|
||||
private static final SettingsResourceRegistry INSTANCE = new SettingsResourceRegistry();
|
||||
|
||||
private final Set<SettingRegistryEntry> registeredEntries = new HashSet<>();
|
||||
|
||||
private SettingsResourceRegistry() {
|
||||
add(MainSettingsFragment.class, R.xml.main_settings).setSearchable(false);
|
||||
|
||||
add(AppearanceSettingsFragment.class, R.xml.appearance_settings);
|
||||
add(ContentSettingsFragment.class, R.xml.content_settings);
|
||||
add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false);
|
||||
add(DownloadSettingsFragment.class, R.xml.download_settings);
|
||||
add(HistorySettingsFragment.class, R.xml.history_settings);
|
||||
add(NotificationSettingsFragment.class, R.xml.notification_settings);
|
||||
add(UpdateSettingsFragment.class, R.xml.update_settings);
|
||||
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
|
||||
}
|
||||
|
||||
private SettingRegistryEntry add(
|
||||
@NonNull final Class<? extends Fragment> fragmentClass,
|
||||
@XmlRes final int preferencesResId
|
||||
) {
|
||||
final SettingRegistryEntry entry =
|
||||
new SettingRegistryEntry(fragmentClass, preferencesResId);
|
||||
this.registeredEntries.add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public SettingRegistryEntry getEntryByFragmentClass(
|
||||
final Class<? extends Fragment> fragmentClass
|
||||
) {
|
||||
Objects.requireNonNull(fragmentClass);
|
||||
return registeredEntries.stream()
|
||||
.filter(e -> Objects.equals(e.getFragmentClass(), fragmentClass))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public SettingRegistryEntry getEntryByPreferencesResId(@XmlRes final int preferencesResId) {
|
||||
return registeredEntries.stream()
|
||||
.filter(e -> Objects.equals(e.getPreferencesResId(), preferencesResId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public int getPreferencesResId(@NonNull final Class<? extends Fragment> fragmentClass) {
|
||||
final SettingRegistryEntry entry = getEntryByFragmentClass(fragmentClass);
|
||||
if (entry == null) {
|
||||
return -1;
|
||||
}
|
||||
return entry.getPreferencesResId();
|
||||
}
|
||||
|
||||
public Class<? extends Fragment> getFragmentClass(@XmlRes final int preferencesResId) {
|
||||
final SettingRegistryEntry entry = getEntryByPreferencesResId(preferencesResId);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
return entry.getFragmentClass();
|
||||
}
|
||||
|
||||
public Set<SettingRegistryEntry> getAllEntries() {
|
||||
return new HashSet<>(registeredEntries);
|
||||
}
|
||||
|
||||
public static SettingsResourceRegistry getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
|
||||
public static class SettingRegistryEntry {
|
||||
@NonNull
|
||||
private final Class<? extends Fragment> fragmentClass;
|
||||
@XmlRes
|
||||
private final int preferencesResId;
|
||||
|
||||
private boolean searchable = true;
|
||||
|
||||
public SettingRegistryEntry(
|
||||
@NonNull final Class<? extends Fragment> fragmentClass,
|
||||
@XmlRes final int preferencesResId
|
||||
) {
|
||||
this.fragmentClass = Objects.requireNonNull(fragmentClass);
|
||||
this.preferencesResId = preferencesResId;
|
||||
}
|
||||
|
||||
@SuppressWarnings("HiddenField")
|
||||
public SettingRegistryEntry setSearchable(final boolean searchable) {
|
||||
this.searchable = searchable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Class<? extends Fragment> getFragmentClass() {
|
||||
return fragmentClass;
|
||||
}
|
||||
|
||||
public int getPreferencesResId() {
|
||||
return preferencesResId;
|
||||
}
|
||||
|
||||
public boolean isSearchable() {
|
||||
return searchable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final SettingRegistryEntry that = (SettingRegistryEntry) o;
|
||||
return getPreferencesResId() == that.getPreferencesResId()
|
||||
&& getFragmentClass().equals(that.getFragmentClass());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getFragmentClass(), getPreferencesResId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.update_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
findPreference(getString(R.string.update_app_key))
|
||||
.setOnPreferenceChangeListener(updatePreferenceChange);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.video_audio_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
updateSeekOptions();
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.text.similarity.FuzzyScore;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PreferenceFuzzySearchFunction
|
||||
implements PreferenceSearchConfiguration.PreferenceSearchFunction {
|
||||
|
||||
private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT);
|
||||
|
||||
@Override
|
||||
public Stream<PreferenceSearchItem> search(
|
||||
final Stream<PreferenceSearchItem> allAvailable,
|
||||
final String keyword
|
||||
) {
|
||||
final int maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score
|
||||
|
||||
return allAvailable
|
||||
// General search
|
||||
// Check all fields if anyone contains something that kind of matches the keyword
|
||||
.map(item -> new FuzzySearchGeneralDTO(item, keyword))
|
||||
.filter(dto -> dto.getScore() / maxScore >= 0.3f)
|
||||
.map(FuzzySearchGeneralDTO::getItem)
|
||||
// Specific search - Used for determining order of search results
|
||||
// Calculate a score based on specific search fields
|
||||
.map(item -> new FuzzySearchSpecificDTO(item, keyword))
|
||||
.sorted(Comparator.comparing(FuzzySearchSpecificDTO::getScore).reversed())
|
||||
.map(FuzzySearchSpecificDTO::getItem)
|
||||
// Limit the amount of search results
|
||||
.limit(20);
|
||||
}
|
||||
|
||||
static class FuzzySearchGeneralDTO {
|
||||
private final PreferenceSearchItem item;
|
||||
private final float score;
|
||||
|
||||
FuzzySearchGeneralDTO(
|
||||
final PreferenceSearchItem item,
|
||||
final String keyword) {
|
||||
this.item = item;
|
||||
this.score = FUZZY_SCORE.fuzzyScore(
|
||||
TextUtils.join(";", item.getAllRelevantSearchFields()),
|
||||
keyword);
|
||||
}
|
||||
|
||||
public PreferenceSearchItem getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
public float getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
static class FuzzySearchSpecificDTO {
|
||||
private static final Map<Function<PreferenceSearchItem, String>, Float> WEIGHT_MAP = Map.of(
|
||||
// The user will most likely look for the title -> prioritize it
|
||||
PreferenceSearchItem::getTitle, 1.5f,
|
||||
// The summary is also important as it usually contains a larger desc
|
||||
// Example: Searching for '4k' → 'show higher resolution' is shown
|
||||
PreferenceSearchItem::getSummary, 1f,
|
||||
// Entries are also important as they provide all known/possible values
|
||||
// Example: Searching where the resolution can be changed to 720p
|
||||
PreferenceSearchItem::getEntries, 1f
|
||||
);
|
||||
|
||||
private final PreferenceSearchItem item;
|
||||
private final float score;
|
||||
|
||||
FuzzySearchSpecificDTO(
|
||||
final PreferenceSearchItem item,
|
||||
final String keyword) {
|
||||
this.item = item;
|
||||
|
||||
float attributeScoreSum = 0;
|
||||
int countOfAttributesWithScore = 0;
|
||||
for (final Map.Entry<Function<PreferenceSearchItem, String>, Float> we
|
||||
: WEIGHT_MAP.entrySet()) {
|
||||
final String valueToProcess = we.getKey().apply(item);
|
||||
if (valueToProcess.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
attributeScoreSum +=
|
||||
FUZZY_SCORE.fuzzyScore(valueToProcess, keyword) * we.getValue();
|
||||
countOfAttributesWithScore++;
|
||||
}
|
||||
|
||||
if (countOfAttributesWithScore != 0) {
|
||||
this.score = attributeScoreSum / countOfAttributesWithScore;
|
||||
} else {
|
||||
this.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public PreferenceSearchItem getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
public float getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.XmlRes;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Parses the corresponding preference-file(s).
|
||||
*/
|
||||
public class PreferenceParser {
|
||||
private static final String TAG = "PreferenceParser";
|
||||
|
||||
private static final String NS_ANDROID = "http://schemas.android.com/apk/res/android";
|
||||
private static final String NS_SEARCH = "http://schemas.android.com/apk/preferencesearch";
|
||||
|
||||
private final Context context;
|
||||
private final Map<String, ?> allPreferences;
|
||||
private final PreferenceSearchConfiguration searchConfiguration;
|
||||
|
||||
public PreferenceParser(
|
||||
final Context context,
|
||||
final PreferenceSearchConfiguration searchConfiguration
|
||||
) {
|
||||
this.context = context;
|
||||
this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll();
|
||||
this.searchConfiguration = searchConfiguration;
|
||||
}
|
||||
|
||||
public List<PreferenceSearchItem> parse(
|
||||
@XmlRes final int resId
|
||||
) {
|
||||
final List<PreferenceSearchItem> results = new ArrayList<>();
|
||||
final XmlPullParser xpp = context.getResources().getXml(resId);
|
||||
|
||||
try {
|
||||
xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
|
||||
xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true);
|
||||
|
||||
final List<String> breadcrumbs = new ArrayList<>();
|
||||
while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.getEventType() == XmlPullParser.START_TAG) {
|
||||
final PreferenceSearchItem result = parseSearchResult(
|
||||
xpp,
|
||||
joinBreadcrumbs(breadcrumbs),
|
||||
resId
|
||||
);
|
||||
|
||||
if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName())
|
||||
&& result.hasData()
|
||||
&& !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) {
|
||||
results.add(result);
|
||||
}
|
||||
if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) {
|
||||
// This code adds breadcrumbs for certain containers (e.g. PreferenceScreen)
|
||||
// Example: Video and Audio > Player
|
||||
breadcrumbs.add(result.getTitle() == null ? "" : result.getTitle());
|
||||
}
|
||||
} else if (xpp.getEventType() == XmlPullParser.END_TAG
|
||||
&& searchConfiguration.getParserContainerElements()
|
||||
.contains(xpp.getName())) {
|
||||
breadcrumbs.remove(breadcrumbs.size() - 1);
|
||||
}
|
||||
|
||||
xpp.next();
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Failed to parse resid=" + resId, e);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private String joinBreadcrumbs(final List<String> breadcrumbs) {
|
||||
return breadcrumbs.stream()
|
||||
.filter(crumb -> !TextUtils.isEmpty(crumb))
|
||||
.collect(Collectors.joining(" > "));
|
||||
}
|
||||
|
||||
private String getAttribute(
|
||||
final XmlPullParser xpp,
|
||||
@NonNull final String attribute
|
||||
) {
|
||||
final String nsSearchAttr = getAttribute(xpp, NS_SEARCH, attribute);
|
||||
if (nsSearchAttr != null) {
|
||||
return nsSearchAttr;
|
||||
}
|
||||
return getAttribute(xpp, NS_ANDROID, attribute);
|
||||
}
|
||||
|
||||
private String getAttribute(
|
||||
final XmlPullParser xpp,
|
||||
@NonNull final String namespace,
|
||||
@NonNull final String attribute
|
||||
) {
|
||||
return xpp.getAttributeValue(namespace, attribute);
|
||||
}
|
||||
|
||||
private PreferenceSearchItem parseSearchResult(
|
||||
final XmlPullParser xpp,
|
||||
final String breadcrumbs,
|
||||
@XmlRes final int searchIndexItemResId
|
||||
) {
|
||||
final String key = readString(getAttribute(xpp, "key"));
|
||||
final String[] entries = readStringArray(getAttribute(xpp, "entries"));
|
||||
final String[] entryValues = readStringArray(getAttribute(xpp, "entryValues"));
|
||||
|
||||
return new PreferenceSearchItem(
|
||||
key,
|
||||
tryFillInPreferenceValue(
|
||||
readString(getAttribute(xpp, "title")),
|
||||
key,
|
||||
entries,
|
||||
entryValues),
|
||||
tryFillInPreferenceValue(
|
||||
readString(getAttribute(xpp, "summary")),
|
||||
key,
|
||||
entries,
|
||||
entryValues),
|
||||
TextUtils.join(",", entries),
|
||||
breadcrumbs,
|
||||
searchIndexItemResId
|
||||
);
|
||||
}
|
||||
|
||||
private String[] readStringArray(@Nullable final String s) {
|
||||
if (s == null) {
|
||||
return new String[0];
|
||||
}
|
||||
if (s.startsWith("@")) {
|
||||
try {
|
||||
return context.getResources().getStringArray(Integer.parseInt(s.substring(1)));
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Unable to readStringArray from '" + s + "'", e);
|
||||
}
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
private String readString(@Nullable final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (s.startsWith("@")) {
|
||||
try {
|
||||
return context.getString(Integer.parseInt(s.substring(1)));
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Unable to readString from '" + s + "'", e);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private String tryFillInPreferenceValue(
|
||||
@Nullable final String s,
|
||||
@Nullable final String key,
|
||||
final String[] entries,
|
||||
final String[] entryValues
|
||||
) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (key == null) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Resolve value
|
||||
Object prefValue = allPreferences.get(key);
|
||||
if (prefValue == null) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/*
|
||||
* Resolve ListPreference values
|
||||
*
|
||||
* entryValues = Values/Keys that are saved
|
||||
* entries = Actual human readable names
|
||||
*/
|
||||
if (entries.length > 0 && entryValues.length == entries.length) {
|
||||
final int entryIndex = Arrays.asList(entryValues).indexOf(prefValue);
|
||||
if (entryIndex != -1) {
|
||||
prefValue = entries[entryIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return String.format(s, prefValue.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.databinding.SettingsPreferencesearchListItemResultBinding;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
class PreferenceSearchAdapter
|
||||
extends RecyclerView.Adapter<PreferenceSearchAdapter.PreferenceViewHolder> {
|
||||
private List<PreferenceSearchItem> dataset = new ArrayList<>();
|
||||
private Consumer<PreferenceSearchItem> onItemClickListener;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public PreferenceViewHolder onCreateViewHolder(
|
||||
@NonNull final ViewGroup parent,
|
||||
final int viewType
|
||||
) {
|
||||
return new PreferenceViewHolder(
|
||||
SettingsPreferencesearchListItemResultBinding.inflate(
|
||||
LayoutInflater.from(parent.getContext()),
|
||||
parent,
|
||||
false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(
|
||||
@NonNull final PreferenceViewHolder holder,
|
||||
final int position
|
||||
) {
|
||||
final PreferenceSearchItem item = dataset.get(position);
|
||||
|
||||
holder.binding.title.setText(item.getTitle());
|
||||
|
||||
if (TextUtils.isEmpty(item.getSummary())) {
|
||||
holder.binding.summary.setVisibility(View.GONE);
|
||||
} else {
|
||||
holder.binding.summary.setVisibility(View.VISIBLE);
|
||||
holder.binding.summary.setText(item.getSummary());
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(item.getBreadcrumbs())) {
|
||||
holder.binding.breadcrumbs.setVisibility(View.GONE);
|
||||
} else {
|
||||
holder.binding.breadcrumbs.setVisibility(View.VISIBLE);
|
||||
holder.binding.breadcrumbs.setText(item.getBreadcrumbs());
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onItemClickListener != null) {
|
||||
onItemClickListener.accept(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setContent(final List<PreferenceSearchItem> items) {
|
||||
dataset = new ArrayList<>(items);
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return dataset.size();
|
||||
}
|
||||
|
||||
void setOnItemClickListener(final Consumer<PreferenceSearchItem> onItemClickListener) {
|
||||
this.onItemClickListener = onItemClickListener;
|
||||
}
|
||||
|
||||
static class PreferenceViewHolder extends RecyclerView.ViewHolder {
|
||||
final SettingsPreferencesearchListItemResultBinding binding;
|
||||
|
||||
PreferenceViewHolder(final SettingsPreferencesearchListItemResultBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.preference.PreferenceCategory;
|
||||
import androidx.preference.PreferenceScreen;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PreferenceSearchConfiguration {
|
||||
private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction();
|
||||
|
||||
private final List<String> parserIgnoreElements = Arrays.asList(
|
||||
PreferenceCategory.class.getSimpleName());
|
||||
private final List<String> parserContainerElements = Arrays.asList(
|
||||
PreferenceCategory.class.getSimpleName(),
|
||||
PreferenceScreen.class.getSimpleName());
|
||||
|
||||
|
||||
public void setSearcher(final PreferenceSearchFunction searcher) {
|
||||
this.searcher = Objects.requireNonNull(searcher);
|
||||
}
|
||||
|
||||
public PreferenceSearchFunction getSearcher() {
|
||||
return searcher;
|
||||
}
|
||||
|
||||
public List<String> getParserIgnoreElements() {
|
||||
return parserIgnoreElements;
|
||||
}
|
||||
|
||||
public List<String> getParserContainerElements() {
|
||||
return parserContainerElements;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PreferenceSearchFunction {
|
||||
Stream<PreferenceSearchItem> search(
|
||||
Stream<PreferenceSearchItem> allAvailable,
|
||||
String keyword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Displays the search results.
|
||||
*/
|
||||
public class PreferenceSearchFragment extends Fragment {
|
||||
public static final String NAME = PreferenceSearchFragment.class.getSimpleName();
|
||||
|
||||
private PreferenceSearcher searcher;
|
||||
|
||||
private SettingsPreferencesearchFragmentBinding binding;
|
||||
private PreferenceSearchAdapter adapter;
|
||||
|
||||
public void setSearcher(final PreferenceSearcher searcher) {
|
||||
this.searcher = searcher;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState
|
||||
) {
|
||||
binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false);
|
||||
|
||||
binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
|
||||
adapter = new PreferenceSearchAdapter();
|
||||
adapter.setOnItemClickListener(this::onItemClicked);
|
||||
binding.searchResults.setAdapter(adapter);
|
||||
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
public void updateSearchResults(final String keyword) {
|
||||
if (adapter == null || searcher == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<PreferenceSearchItem> results =
|
||||
!TextUtils.isEmpty(keyword)
|
||||
? searcher.searchFor(keyword)
|
||||
: new ArrayList<>();
|
||||
|
||||
adapter.setContent(new ArrayList<>(results));
|
||||
|
||||
setEmptyViewShown(results.isEmpty());
|
||||
}
|
||||
|
||||
private void setEmptyViewShown(final boolean shown) {
|
||||
binding.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE);
|
||||
binding.searchResults.setVisibility(shown ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
public void onItemClicked(final PreferenceSearchItem item) {
|
||||
if (!(getActivity() instanceof PreferenceSearchResultListener)) {
|
||||
throw new ClassCastException(
|
||||
getActivity().toString() + " must implement SearchPreferenceResultListener");
|
||||
}
|
||||
|
||||
((PreferenceSearchResultListener) getActivity()).onSearchResultClicked(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.XmlRes;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a preference-item inside the search.
|
||||
*/
|
||||
public class PreferenceSearchItem {
|
||||
/**
|
||||
* Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
@NonNull
|
||||
private final String key;
|
||||
/**
|
||||
* Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String title;
|
||||
/**
|
||||
* Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String summary;
|
||||
/**
|
||||
* Possible entries of the setting, e.g. 480p,720p,...
|
||||
*/
|
||||
@NonNull
|
||||
private final String entries;
|
||||
/**
|
||||
* Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
||||
*/
|
||||
@NonNull
|
||||
private final String breadcrumbs;
|
||||
/**
|
||||
* The xml-resource where this item was found/built from.
|
||||
*/
|
||||
@XmlRes
|
||||
private final int searchIndexItemResId;
|
||||
|
||||
public PreferenceSearchItem(
|
||||
@NonNull final String key,
|
||||
@NonNull final String title,
|
||||
@NonNull final String summary,
|
||||
@NonNull final String entries,
|
||||
@NonNull final String breadcrumbs,
|
||||
@XmlRes final int searchIndexItemResId
|
||||
) {
|
||||
this.key = Objects.requireNonNull(key);
|
||||
this.title = Objects.requireNonNull(title);
|
||||
this.summary = Objects.requireNonNull(summary);
|
||||
this.entries = Objects.requireNonNull(entries);
|
||||
this.breadcrumbs = Objects.requireNonNull(breadcrumbs);
|
||||
this.searchIndexItemResId = searchIndexItemResId;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public String getEntries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public String getBreadcrumbs() {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
public int getSearchIndexItemResId() {
|
||||
return searchIndexItemResId;
|
||||
}
|
||||
|
||||
boolean hasData() {
|
||||
return !key.isEmpty() && !title.isEmpty();
|
||||
}
|
||||
|
||||
public List<String> getAllRelevantSearchFields() {
|
||||
return Arrays.asList(
|
||||
getTitle(),
|
||||
getSummary(),
|
||||
getEntries(),
|
||||
getBreadcrumbs());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PreferenceItem: " + title + " " + summary + " " + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.RippleDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
import androidx.preference.PreferenceGroup;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
|
||||
public final class PreferenceSearchResultHighlighter {
|
||||
private static final String TAG = "PrefSearchResHighlter";
|
||||
|
||||
private PreferenceSearchResultHighlighter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the specified preference.
|
||||
* <br/>
|
||||
* Note: This function is Thread independent (can be called from outside of the main thread).
|
||||
*
|
||||
* @param item The item to highlight
|
||||
* @param prefsFragment The fragment where the items is located on
|
||||
*/
|
||||
public static void highlight(
|
||||
final PreferenceSearchItem item,
|
||||
final PreferenceFragmentCompat prefsFragment
|
||||
) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment));
|
||||
}
|
||||
|
||||
private static void doHighlight(
|
||||
final PreferenceSearchItem item,
|
||||
final PreferenceFragmentCompat prefsFragment
|
||||
) {
|
||||
final Preference prefResult = prefsFragment.findPreference(item.getKey());
|
||||
|
||||
if (prefResult == null) {
|
||||
Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
final RecyclerView recyclerView = prefsFragment.getListView();
|
||||
final RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
|
||||
if (adapter instanceof PreferenceGroup.PreferencePositionCallback) {
|
||||
final int position = ((PreferenceGroup.PreferencePositionCallback) adapter)
|
||||
.getPreferenceAdapterPosition(prefResult);
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
recyclerView.scrollToPosition(position);
|
||||
recyclerView.postDelayed(() -> {
|
||||
final RecyclerView.ViewHolder holder =
|
||||
recyclerView.findViewHolderForAdapterPosition(position);
|
||||
if (holder != null) {
|
||||
final Drawable background = holder.itemView.getBackground();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& background instanceof RippleDrawable) {
|
||||
showRippleAnimation((RippleDrawable) background);
|
||||
return;
|
||||
}
|
||||
}
|
||||
highlightFallback(prefsFragment, prefResult);
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
highlightFallback(prefsFragment, prefResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative highlighting (shows an → arrow in front of the setting)if ripple does not work.
|
||||
*
|
||||
* @param prefsFragment
|
||||
* @param prefResult
|
||||
*/
|
||||
private static void highlightFallback(
|
||||
final PreferenceFragmentCompat prefsFragment,
|
||||
final Preference prefResult
|
||||
) {
|
||||
// Get primary color from text for highlight icon
|
||||
final TypedValue typedValue = new TypedValue();
|
||||
final Resources.Theme theme = prefsFragment.getActivity().getTheme();
|
||||
theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true);
|
||||
final TypedArray arr = prefsFragment.getActivity()
|
||||
.obtainStyledAttributes(
|
||||
typedValue.data,
|
||||
new int[]{android.R.attr.textColorPrimary});
|
||||
final int color = arr.getColor(0, 0xffE53935);
|
||||
arr.recycle();
|
||||
|
||||
// Show highlight icon
|
||||
final Drawable oldIcon = prefResult.getIcon();
|
||||
final boolean oldSpaceReserved = prefResult.isIconSpaceReserved();
|
||||
final Drawable highlightIcon =
|
||||
AppCompatResources.getDrawable(
|
||||
prefsFragment.requireContext(),
|
||||
R.drawable.ic_play_arrow);
|
||||
highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
|
||||
prefResult.setIcon(highlightIcon);
|
||||
|
||||
prefsFragment.scrollToPreference(prefResult);
|
||||
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
prefResult.setIcon(oldIcon);
|
||||
prefResult.setIconSpaceReserved(oldSpaceReserved);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private static void showRippleAnimation(final RippleDrawable rippleDrawable) {
|
||||
rippleDrawable.setState(
|
||||
new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled});
|
||||
new Handler(Looper.getMainLooper())
|
||||
.postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface PreferenceSearchResultListener {
|
||||
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class PreferenceSearcher {
|
||||
private final List<PreferenceSearchItem> allEntries = new ArrayList<>();
|
||||
|
||||
private final PreferenceSearchConfiguration configuration;
|
||||
|
||||
public PreferenceSearcher(final PreferenceSearchConfiguration configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public void add(final List<PreferenceSearchItem> items) {
|
||||
allEntries.addAll(items);
|
||||
}
|
||||
|
||||
List<PreferenceSearchItem> searchFor(final String keyword) {
|
||||
if (TextUtils.isEmpty(keyword)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return configuration.getSearcher()
|
||||
.search(allEntries.stream(), keyword)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
allEntries.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Contains classes for searching inside the preferences.
|
||||
* <br/>
|
||||
* This code is based on
|
||||
* <a href="https://github.com/ByteHamster/SearchPreference">ByteHamster/SearchPreference</a>
|
||||
* (MIT license) but was heavily modified/refactored for our use.
|
||||
*
|
||||
* @author litetex
|
||||
*/
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
@@ -44,8 +44,6 @@ import java.util.List;
|
||||
import static org.schabi.newpipe.settings.tabs.Tab.typeFrom;
|
||||
|
||||
public class ChooseTabsFragment extends Fragment {
|
||||
private static final int MENU_ITEM_RESTORE_ID = 123456;
|
||||
|
||||
private TabsManager tabsManager;
|
||||
|
||||
private final List<Tab> tabList = new ArrayList<>();
|
||||
@@ -110,21 +108,14 @@ public class ChooseTabsFragment extends Fragment {
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE,
|
||||
R.string.restore_defaults);
|
||||
final MenuItem restoreItem = menu.add(R.string.restore_defaults);
|
||||
restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(),
|
||||
R.drawable.ic_settings_backup_restore));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
if (item.getItemId() == MENU_ITEM_RESTORE_ID) {
|
||||
restoreItem.setOnMenuItemClickListener(ev -> {
|
||||
restoreDefaults();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
});
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
43
app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java
Normal file
43
app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
/**
|
||||
* Utility class for the Android keyboard.
|
||||
* <p>
|
||||
* See also <a href="https://stackoverflow.com/q/1109022">https://stackoverflow.com/q/1109022</a>
|
||||
* </p>
|
||||
*/
|
||||
public final class KeyboardUtil {
|
||||
private KeyboardUtil() {
|
||||
}
|
||||
|
||||
public static void showKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editText.requestFocus()) {
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
|
||||
}
|
||||
}
|
||||
|
||||
public static void hideKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.hideSoftInputFromWindow(editText.getWindowToken(),
|
||||
InputMethodManager.RESULT_UNCHANGED_SHOWN);
|
||||
|
||||
editText.clearFocus();
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,8 @@ public final class NavigationHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
if (PlayerHolder.getInstance().getType() != PlayerType.POPUP) {
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.POPUP.ordinal());
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
@@ -168,10 +167,9 @@ public final class NavigationHelper {
|
||||
public static void playOnBackgroundPlayer(final Context context,
|
||||
final PlayQueue queue,
|
||||
final boolean resumePlayback) {
|
||||
if (PlayerHolder.getInstance().getType() != MainPlayer.PlayerType.AUDIO) {
|
||||
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
}
|
||||
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.AUDIO.ordinal());
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Utility class for putting the uploader url into the database - when required.
|
||||
*/
|
||||
public final class SaveUploaderUrlHelper {
|
||||
private SaveUploaderUrlHelper() {
|
||||
}
|
||||
|
||||
// Public functions which call the function that does
|
||||
// the actual work with the correct parameters
|
||||
public static void saveUploaderUrlIfNeeded(@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
saveUploaderUrlIfNeeded(fragment.requireContext(),
|
||||
infoItem.getServiceId(),
|
||||
infoItem.getUrl(),
|
||||
infoItem.getUploaderUrl(),
|
||||
callback);
|
||||
}
|
||||
public static void saveUploaderUrlIfNeeded(@NonNull final Context context,
|
||||
@NonNull final PlayQueueItem queueItem,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
saveUploaderUrlIfNeeded(context,
|
||||
queueItem.getServiceId(),
|
||||
queueItem.getUrl(),
|
||||
queueItem.getUploaderUrl(),
|
||||
callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and saves the uploaderUrl if it is empty (meaning that it does
|
||||
* not exist in the video item). The callback is called with either the
|
||||
* fetched uploaderUrl, or the already saved uploaderUrl, but it is always
|
||||
* called with a valid uploaderUrl that can be used to show channel details.
|
||||
*
|
||||
* @param context Context
|
||||
* @param serviceId The serviceId of the item
|
||||
* @param url The item url
|
||||
* @param uploaderUrl The uploaderUrl of the item, if null or empty, it
|
||||
* will be fetched using the item url.
|
||||
* @param callback The callback that returns the fetched or existing
|
||||
* uploaderUrl
|
||||
*/
|
||||
private static void saveUploaderUrlIfNeeded(@NonNull final Context context,
|
||||
final int serviceId,
|
||||
@NonNull final String url,
|
||||
// Only used if not null or empty
|
||||
@Nullable final String uploaderUrl,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
if (isNullOrEmpty(uploaderUrl)) {
|
||||
Toast.makeText(context, R.string.loading_channel_details,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
NewPipeDatabase.getInstance(context).streamDAO()
|
||||
.setUploaderUrl(serviceId, url, result.getUploaderUrl())
|
||||
.subscribeOn(Schedulers.io()).subscribe();
|
||||
callback.onCallback(result.getUploaderUrl());
|
||||
}, throwable -> ErrorUtil.createNotification(context,
|
||||
new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
|
||||
"Could not load channel details")
|
||||
));
|
||||
} else {
|
||||
callback.onCallback(uploaderUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public interface SaveUploaderUrlCallback {
|
||||
void onCallback(@NonNull String uploaderUrl);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
@@ -21,40 +20,19 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
public enum StreamDialogEntry {
|
||||
//////////////////////////////////////
|
||||
// enum values with DEFAULT actions //
|
||||
//////////////////////////////////////
|
||||
|
||||
show_channel_details(R.string.show_channel_details, (fragment, item) -> {
|
||||
if (isNullOrEmpty(item.getUploaderUrl())) {
|
||||
final int serviceId = item.getServiceId();
|
||||
final String url = item.getUrl();
|
||||
Toast.makeText(fragment.getContext(), R.string.loading_channel_details,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
NewPipeDatabase.getInstance(fragment.requireContext()).streamDAO()
|
||||
.setUploaderUrl(serviceId, url, result.getUploaderUrl())
|
||||
.subscribeOn(Schedulers.io()).subscribe();
|
||||
openChannelFragment(fragment, item, result.getUploaderUrl());
|
||||
}, throwable -> Toast.makeText(
|
||||
// TODO: Open the Error Activity
|
||||
fragment.getContext(),
|
||||
R.string.error_show_channel_details,
|
||||
Toast.LENGTH_SHORT
|
||||
).show());
|
||||
} else {
|
||||
openChannelFragment(fragment, item, item.getUploaderUrl());
|
||||
}
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(fragment, item,
|
||||
uploaderUrl -> openChannelFragment(fragment, item, uploaderUrl));
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -63,20 +41,24 @@ public enum StreamDialogEntry {
|
||||
* Info: Add this entry within showStreamDialog.
|
||||
*/
|
||||
enqueue(R.string.enqueue_stream, (fragment, item) -> {
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), new SinglePlayQueue(item));
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), fullItem));
|
||||
}),
|
||||
|
||||
enqueue_next(R.string.enqueue_next_stream, (fragment, item) -> {
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), new SinglePlayQueue(item));
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), fullItem));
|
||||
}),
|
||||
|
||||
start_here_on_background(R.string.start_here_on_background, (fragment, item) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(fragment.getContext(),
|
||||
new SinglePlayQueue(item), true)),
|
||||
start_here_on_background(R.string.start_here_on_background, (fragment, item) -> {
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.playOnBackgroundPlayer(fragment.getContext(), fullItem, true));
|
||||
}),
|
||||
|
||||
start_here_on_popup(R.string.start_here_on_popup, (fragment, item) ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(),
|
||||
new SinglePlayQueue(item), true)),
|
||||
start_here_on_popup(R.string.start_here_on_popup, (fragment, item) -> {
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(), fullItem, true));
|
||||
}),
|
||||
|
||||
set_as_playlist_thumbnail(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
|
||||
}), // has to be set manually
|
||||
@@ -218,4 +200,39 @@ public enum StreamDialogEntry {
|
||||
fragment.requireActivity().getSupportFragmentManager(),
|
||||
item.getServiceId(), uploaderUrl, item.getUploaderName());
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// helper functions //
|
||||
/////////////////////////////////////////////
|
||||
|
||||
private static void fetchItemInfoIfSparse(final Fragment fragment,
|
||||
final StreamInfoItem item,
|
||||
final Consumer<SinglePlayQueue> callback) {
|
||||
if (!(item.getStreamType() == StreamType.LIVE_STREAM
|
||||
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM)
|
||||
&& item.getDuration() < 0) {
|
||||
// Sparse item: fetched by fast fetch
|
||||
ExtractorHelper.getStreamInfo(
|
||||
item.getServiceId(),
|
||||
item.getUrl(),
|
||||
false
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
final HistoryRecordManager recordManager =
|
||||
new HistoryRecordManager(fragment.getContext());
|
||||
recordManager.saveStreamState(result, 0)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError(throwable -> Log.e("StreamDialogEntry",
|
||||
throwable.toString()))
|
||||
.subscribe();
|
||||
|
||||
callback.accept(new SinglePlayQueue(result));
|
||||
}, throwable -> Log.e("StreamDialogEntry", throwable.toString()));
|
||||
} else {
|
||||
callback.accept(new SinglePlayQueue(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
|
||||
class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) {
|
||||
|
||||
private var backgroundPaint = Paint()
|
||||
|
||||
private var widthPx = 0
|
||||
private var heightPx = 0
|
||||
|
||||
// Background
|
||||
|
||||
private var shapePath = Path()
|
||||
private var arcSize: Float = 80f
|
||||
private var isLeft = true
|
||||
|
||||
init {
|
||||
requireNotNull(context) { "Context is null." }
|
||||
|
||||
backgroundPaint.apply {
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
color = 0x30000000
|
||||
}
|
||||
|
||||
val dm = context.resources.displayMetrics
|
||||
widthPx = dm.widthPixels
|
||||
heightPx = dm.heightPixels
|
||||
|
||||
updatePathShape()
|
||||
}
|
||||
|
||||
fun updateArcSize(baseView: View) {
|
||||
val newArcSize = baseView.height / 11.4f
|
||||
if (arcSize != newArcSize) {
|
||||
arcSize = newArcSize
|
||||
updatePathShape()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePosition(newIsLeft: Boolean) {
|
||||
if (isLeft != newIsLeft) {
|
||||
isLeft = newIsLeft
|
||||
updatePathShape()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePathShape() {
|
||||
val halfWidth = widthPx * 0.5f
|
||||
|
||||
shapePath.reset()
|
||||
|
||||
val w = if (isLeft) 0f else widthPx.toFloat()
|
||||
val f = if (isLeft) 1 else -1
|
||||
|
||||
shapePath.moveTo(w, 0f)
|
||||
shapePath.lineTo(f * (halfWidth - arcSize) + w, 0f)
|
||||
shapePath.quadTo(
|
||||
f * (halfWidth + arcSize) + w,
|
||||
heightPx.toFloat() / 2,
|
||||
f * (halfWidth - arcSize) + w,
|
||||
heightPx.toFloat()
|
||||
)
|
||||
shapePath.lineTo(w, heightPx.toFloat())
|
||||
|
||||
shapePath.close()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
widthPx = w
|
||||
heightPx = h
|
||||
updatePathShape()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
canvas?.clipPath(shapePath)
|
||||
canvas?.drawPath(shapePath, backgroundPaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.END
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.player.event.DisplayPortion
|
||||
import org.schabi.newpipe.player.event.DoubleTapListener
|
||||
|
||||
class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) :
|
||||
ConstraintLayout(context, attrs), DoubleTapListener {
|
||||
|
||||
private var secondsView: SecondsView
|
||||
private var circleClipTapView: CircleClipTapView
|
||||
private var rootConstraintLayout: ConstraintLayout
|
||||
|
||||
private var wasForwarding: Boolean = false
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.player_fast_seek_overlay, this, true)
|
||||
|
||||
secondsView = findViewById(R.id.seconds_view)
|
||||
circleClipTapView = findViewById(R.id.circle_clip_tap_view)
|
||||
rootConstraintLayout = findViewById(R.id.root_constraint_layout)
|
||||
|
||||
addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||
circleClipTapView.updateArcSize(view)
|
||||
}
|
||||
}
|
||||
|
||||
private var performListener: PerformListener? = null
|
||||
|
||||
fun performListener(listener: PerformListener) = apply {
|
||||
performListener = listener
|
||||
}
|
||||
|
||||
private var seekSecondsSupplier: () -> Int = { 0 }
|
||||
|
||||
fun seekSecondsSupplier(supplier: () -> Int) = apply {
|
||||
seekSecondsSupplier = supplier
|
||||
}
|
||||
|
||||
// Indicates whether this (double) tap is the first of a series
|
||||
// Decides whether to call performListener.onAnimationStart or not
|
||||
private var initTap: Boolean = false
|
||||
|
||||
override fun onDoubleTapStarted(portion: DisplayPortion) {
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "onDoubleTapStarted called with portion = [$portion]")
|
||||
|
||||
initTap = false
|
||||
|
||||
secondsView.stopAnimation()
|
||||
}
|
||||
|
||||
override fun onDoubleTapProgressDown(portion: DisplayPortion) {
|
||||
val shouldForward: Boolean =
|
||||
performListener?.getFastSeekDirection(portion)?.directionAsBoolean ?: return
|
||||
|
||||
if (DEBUG)
|
||||
Log.d(
|
||||
TAG,
|
||||
"onDoubleTapProgressDown called with " +
|
||||
"shouldForward = [$shouldForward], " +
|
||||
"wasForwarding = [$wasForwarding], " +
|
||||
"initTap = [$initTap], "
|
||||
)
|
||||
|
||||
/*
|
||||
* Check if a initial tap occurred or if direction was switched
|
||||
*/
|
||||
if (!initTap || wasForwarding != shouldForward) {
|
||||
// Reset seconds and update position
|
||||
secondsView.seconds = 0
|
||||
changeConstraints(shouldForward)
|
||||
circleClipTapView.updatePosition(!shouldForward)
|
||||
secondsView.setForwarding(shouldForward)
|
||||
|
||||
wasForwarding = shouldForward
|
||||
|
||||
if (!initTap) {
|
||||
initTap = true
|
||||
}
|
||||
}
|
||||
|
||||
performListener?.onDoubleTap()
|
||||
|
||||
secondsView.seconds += seekSecondsSupplier.invoke()
|
||||
performListener?.seek(forward = shouldForward)
|
||||
}
|
||||
|
||||
override fun onDoubleTapFinished() {
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]")
|
||||
|
||||
if (initTap) performListener?.onDoubleTapEnd()
|
||||
initTap = false
|
||||
|
||||
secondsView.stopAnimation()
|
||||
}
|
||||
|
||||
private fun changeConstraints(forward: Boolean) {
|
||||
val constraintSet = ConstraintSet()
|
||||
with(constraintSet) {
|
||||
clone(rootConstraintLayout)
|
||||
clear(secondsView.id, if (forward) START else END)
|
||||
connect(
|
||||
secondsView.id, if (forward) END else START,
|
||||
PARENT_ID, if (forward) END else START
|
||||
)
|
||||
secondsView.startAnimation()
|
||||
applyTo(rootConstraintLayout)
|
||||
}
|
||||
}
|
||||
|
||||
interface PerformListener {
|
||||
fun onDoubleTap()
|
||||
fun onDoubleTapEnd()
|
||||
/**
|
||||
* Determines if the playback should forward/rewind or do nothing.
|
||||
*/
|
||||
@NonNull
|
||||
fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection
|
||||
fun seek(forward: Boolean)
|
||||
|
||||
enum class FastSeekDirection(val directionAsBoolean: Boolean?) {
|
||||
NONE(null),
|
||||
FORWARD(true),
|
||||
BACKWARD(false);
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlayerFastSeekOverlay"
|
||||
private val DEBUG = MainActivity.DEBUG
|
||||
}
|
||||
}
|
||||
181
app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
Normal file
181
app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
|
||||
class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
|
||||
|
||||
companion object {
|
||||
const val ICON_ANIMATION_DURATION = 750L
|
||||
}
|
||||
|
||||
var cycleDuration: Long = ICON_ANIMATION_DURATION
|
||||
set(value) {
|
||||
firstAnimator.duration = value / 5
|
||||
secondAnimator.duration = value / 5
|
||||
thirdAnimator.duration = value / 5
|
||||
fourthAnimator.duration = value / 5
|
||||
fifthAnimator.duration = value / 5
|
||||
field = value
|
||||
}
|
||||
|
||||
var seconds: Int = 0
|
||||
set(value) {
|
||||
binding.tvSeconds.text = context.resources.getQuantityString(
|
||||
R.plurals.seconds, value, value
|
||||
)
|
||||
field = value
|
||||
}
|
||||
|
||||
// Done as a field so that we don't have to compute on each tab if animations are enabled
|
||||
private val animationsEnabled = DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)
|
||||
|
||||
val binding = PlayerFastSeekSecondsViewBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
fun setForwarding(isForward: Boolean) {
|
||||
binding.triangleContainer.rotation = if (isForward) 0f else 180f
|
||||
}
|
||||
|
||||
fun startAnimation() {
|
||||
stopAnimation()
|
||||
|
||||
if (animationsEnabled) {
|
||||
firstAnimator.start()
|
||||
} else {
|
||||
// If no animations are enable show the arrow(s) without animation
|
||||
showWithoutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAnimation() {
|
||||
firstAnimator.cancel()
|
||||
secondAnimator.cancel()
|
||||
thirdAnimator.cancel()
|
||||
fourthAnimator.cancel()
|
||||
fifthAnimator.cancel()
|
||||
|
||||
reset()
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
}
|
||||
|
||||
private fun showWithoutAnimation() {
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 1f
|
||||
}
|
||||
|
||||
private val firstAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon1.alpha = it
|
||||
},
|
||||
{
|
||||
secondAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val secondAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon2.alpha = it
|
||||
},
|
||||
{
|
||||
thirdAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val thirdAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon1.alpha = 1f - binding.icon3.alpha
|
||||
binding.icon3.alpha = it
|
||||
},
|
||||
{
|
||||
fourthAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val fourthAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 1f
|
||||
},
|
||||
{
|
||||
binding.icon2.alpha = 1f - it
|
||||
},
|
||||
{
|
||||
fifthAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val fifthAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 1f
|
||||
},
|
||||
{
|
||||
binding.icon3.alpha = 1f - it
|
||||
},
|
||||
{
|
||||
firstAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private inner class CustomValueAnimator(
|
||||
start: () -> Unit,
|
||||
update: (value: Float) -> Unit,
|
||||
end: () -> Unit
|
||||
) : ValueAnimator() {
|
||||
|
||||
init {
|
||||
duration = cycleDuration / 5
|
||||
setFloatValues(0f, 1f)
|
||||
|
||||
addUpdateListener { update(it.animatedValue as Float) }
|
||||
addListener(object : AnimatorListener {
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
start()
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
end()
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) = Unit
|
||||
|
||||
override fun onAnimationRepeat(animation: Animator?) = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user