mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2024-12-24 09:00:31 +00:00
commit
04ab753b26
@ -11,7 +11,10 @@
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:banner="@mipmap/newpipe_tv_banner"
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@ -26,6 +29,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
package com.google.android.material.appbar;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.widget.OverScroller;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
@ -12,11 +14,45 @@ import java.lang.reflect.Field;
|
||||
|
||||
// See https://stackoverflow.com/questions/56849221#57997489
|
||||
public final class FlingBehavior extends AppBarLayout.Behavior {
|
||||
private final Rect focusScrollRect = new Rect();
|
||||
|
||||
public FlingBehavior(final Context context, final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onRequestChildRectangleOnScreen(
|
||||
@NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child,
|
||||
@NonNull final Rect rectangle, final boolean immediate) {
|
||||
|
||||
focusScrollRect.set(rectangle);
|
||||
|
||||
coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect);
|
||||
|
||||
int height = coordinatorLayout.getHeight();
|
||||
|
||||
if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) {
|
||||
// the child is too big to fit inside ourselves completely, ignore request
|
||||
return false;
|
||||
}
|
||||
|
||||
int dy;
|
||||
|
||||
if (focusScrollRect.bottom > height) {
|
||||
dy = focusScrollRect.top;
|
||||
} else if (focusScrollRect.top < 0) {
|
||||
// scrolling up
|
||||
dy = -(height - focusScrollRect.bottom);
|
||||
} else {
|
||||
// nothing to do
|
||||
return false;
|
||||
}
|
||||
|
||||
int consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0);
|
||||
|
||||
return consumed == dy;
|
||||
}
|
||||
|
||||
public boolean onInterceptTouchEvent(final CoordinatorLayout parent, final AppBarLayout child,
|
||||
final MotionEvent ev) {
|
||||
switch (ev.getActionMasked()) {
|
||||
|
@ -65,6 +65,7 @@ import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
@ -74,6 +75,7 @@ import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.TLSSocketFactoryCompat;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -142,6 +144,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception e) {
|
||||
ErrorActivity.reportUiError(this, e);
|
||||
}
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupDrawer() throws Exception {
|
||||
@ -526,6 +532,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.d(TAG, "onBackPressed() called");
|
||||
}
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
View drawerPanel = findViewById(R.id.navigation);
|
||||
if (drawer.isDrawerOpen(drawerPanel)) {
|
||||
drawer.closeDrawers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
|
||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||
// delegate the back press to it
|
||||
|
@ -45,10 +45,12 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
||||
|
||||
import java.io.Serializable;
|
||||
@ -341,6 +343,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||
selectedPreviously = selectedRadioPosition;
|
||||
|
||||
alertDialog.show();
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
FocusOverlayView.setupFocusObserver(alertDialog);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
|
||||
|
@ -13,7 +13,9 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import us.shandian.giga.service.DownloadManagerService;
|
||||
import us.shandian.giga.ui.fragment.MissionsFragment;
|
||||
@ -54,6 +56,10 @@ public class DownloadActivity extends AppCompatActivity {
|
||||
getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
|
||||
}
|
||||
});
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFragments() {
|
||||
|
@ -11,7 +11,6 @@ import android.preference.PreferenceManager;
|
||||
import android.text.Html;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.util.Linkify;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
@ -73,6 +72,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
@ -86,6 +86,7 @@ import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
import org.schabi.newpipe.views.LargeTextMovementMethod;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
@ -471,10 +472,13 @@ public class VideoDetailFragment extends BaseStateFragment<StreamInfo>
|
||||
if (videoDescriptionRootLayout.getVisibility() == View.VISIBLE) {
|
||||
videoTitleTextView.setMaxLines(1);
|
||||
videoDescriptionRootLayout.setVisibility(View.GONE);
|
||||
videoDescriptionView.setFocusable(false);
|
||||
videoTitleToggleArrow.setImageResource(R.drawable.arrow_down);
|
||||
} else {
|
||||
videoTitleTextView.setMaxLines(10);
|
||||
videoDescriptionRootLayout.setVisibility(View.VISIBLE);
|
||||
videoDescriptionView.setFocusable(true);
|
||||
videoDescriptionView.setMovementMethod(new LargeTextMovementMethod());
|
||||
videoTitleToggleArrow.setImageResource(R.drawable.arrow_up);
|
||||
}
|
||||
}
|
||||
@ -511,7 +515,6 @@ public class VideoDetailFragment extends BaseStateFragment<StreamInfo>
|
||||
videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout);
|
||||
videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view);
|
||||
videoDescriptionView = rootView.findViewById(R.id.detail_description_view);
|
||||
videoDescriptionView.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
|
||||
thumbsUpTextView = rootView.findViewById(R.id.detail_thumbs_up_count_view);
|
||||
thumbsUpImageView = rootView.findViewById(R.id.detail_thumbs_up_img_view);
|
||||
@ -533,6 +536,18 @@ public class VideoDetailFragment extends BaseStateFragment<StreamInfo>
|
||||
relatedStreamsLayout = rootView.findViewById(R.id.relatedStreamsLayout);
|
||||
|
||||
setHeightThumbnail();
|
||||
|
||||
thumbnailBackgroundButton.requestFocus();
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
// remove ripple effects from detail controls
|
||||
final int transparent = getResources().getColor(R.color.transparent_background_color);
|
||||
detailControlsAddToPlaylist.setBackgroundColor(transparent);
|
||||
detailControlsBackground.setBackgroundColor(transparent);
|
||||
detailControlsPopup.setBackgroundColor(transparent);
|
||||
detailControlsDownload.setBackgroundColor(transparent);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -16,7 +16,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
@ -35,6 +34,7 @@ import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
@ -56,6 +56,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
|
||||
protected InfoListAdapter infoListAdapter;
|
||||
protected RecyclerView itemsList;
|
||||
private int focusedPosition = -1;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
@ -129,20 +130,53 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
return "." + infoListAdapter.getItemsList().size() + ".list";
|
||||
}
|
||||
|
||||
private int getFocusedPosition() {
|
||||
View focusedItem = itemsList.getFocusedChild();
|
||||
if (focusedItem != null) {
|
||||
RecyclerView.ViewHolder itemHolder = itemsList.findContainingViewHolder(focusedItem);
|
||||
if (itemHolder != null) {
|
||||
return itemHolder.getAdapterPosition();
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(final Queue<Object> objectsToSave) {
|
||||
if (useDefaultStateSaving) {
|
||||
objectsToSave.add(infoListAdapter.getItemsList());
|
||||
if (!useDefaultStateSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
objectsToSave.add(infoListAdapter.getItemsList());
|
||||
objectsToSave.add(getFocusedPosition());
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||
if (useDefaultStateSaving) {
|
||||
infoListAdapter.getItemsList().clear();
|
||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||
if (!useDefaultStateSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
infoListAdapter.getItemsList().clear();
|
||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||
restoreFocus((Integer) savedObjects.poll());
|
||||
}
|
||||
|
||||
private void restoreFocus(final Integer position) {
|
||||
if (position == null || position < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
itemsList.post(() -> {
|
||||
RecyclerView.ViewHolder focusedHolder =
|
||||
itemsList.findViewHolderForAdapterPosition(position);
|
||||
|
||||
if (focusedHolder != null) {
|
||||
focusedHolder.itemView.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -162,6 +196,18 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
focusedPosition = getFocusedPosition();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
restoreFocus(focusedPosition);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@ -175,7 +221,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
|
||||
protected RecyclerView.LayoutManager getListLayoutManager() {
|
||||
return new LinearLayoutManager(activity);
|
||||
return new SuperScrollLayoutManager(activity);
|
||||
}
|
||||
|
||||
protected RecyclerView.LayoutManager getGridLayoutManager() {
|
||||
|
@ -10,6 +10,7 @@ import androidx.annotation.NonNull;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.views.NewPipeRecyclerView;
|
||||
|
||||
import java.util.Queue;
|
||||
|
||||
@ -149,9 +150,13 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
if (currentWorker != null) {
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
forbidDownwardFocusScroll();
|
||||
|
||||
currentWorker = loadMoreItemsLogic()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doFinally(this::allowDownwardFocusScroll)
|
||||
.subscribe((@io.reactivex.annotations.NonNull
|
||||
ListExtractor.InfoItemsPage InfoItemsPage) -> {
|
||||
isLoading.set(false);
|
||||
@ -162,6 +167,18 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
});
|
||||
}
|
||||
|
||||
private void forbidDownwardFocusScroll() {
|
||||
if (itemsList instanceof NewPipeRecyclerView) {
|
||||
((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void allowDownwardFocusScroll() {
|
||||
if (itemsList instanceof NewPipeRecyclerView) {
|
||||
((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
||||
super.handleNextItems(result);
|
||||
|
@ -41,6 +41,7 @@ import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.search.SearchExtractor;
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.fragments.BackPressable;
|
||||
import org.schabi.newpipe.fragments.list.BaseListFragment;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
@ -49,7 +50,6 @@ import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.util.AnimationUtils;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.FireTvUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
@ -511,7 +511,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
if (isSuggestionsEnabled && errorPanelRoot.getVisibility() != View.VISIBLE) {
|
||||
showSuggestionsPanel();
|
||||
}
|
||||
if (FireTvUtils.isFireTv()) {
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
showKeyboardSearch();
|
||||
}
|
||||
});
|
||||
|
@ -3,8 +3,9 @@ package org.schabi.newpipe.info_list.holder;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.URLSpan;
|
||||
import android.text.util.Linkify;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@ -18,6 +19,7 @@ import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.CommentTextOnTouchListener;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
@ -91,21 +93,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||
|
||||
itemThumbnailView.setOnClickListener(view -> {
|
||||
if (StringUtil.isBlank(item.getAuthorEndpoint())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
|
||||
NavigationHelper.openChannelFragment(
|
||||
activity.getSupportFragmentManager(),
|
||||
item.getServiceId(),
|
||||
item.getAuthorEndpoint(),
|
||||
item.getAuthorName());
|
||||
} catch (Exception e) {
|
||||
ErrorActivity.reportUiError((AppCompatActivity) itemBuilder.getContext(), e);
|
||||
}
|
||||
});
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
streamUrl = item.getUrl();
|
||||
|
||||
@ -140,22 +128,65 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
});
|
||||
|
||||
|
||||
itemView.setOnLongClickListener(new View.OnLongClickListener() {
|
||||
@Override
|
||||
public boolean onLongClick(final View view) {
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (!AndroidTvUtils.isTv()) {
|
||||
ClipboardManager clipboardManager = (ClipboardManager) itemBuilder.getContext()
|
||||
.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, commentText));
|
||||
Toast.makeText(itemBuilder.getContext(), R.string.msg_copied, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
return true;
|
||||
|
||||
} else {
|
||||
openCommentAuthor(item);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void openCommentAuthor(final CommentsInfoItem item) {
|
||||
if (StringUtil.isBlank(item.getAuthorEndpoint())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
|
||||
NavigationHelper.openChannelFragment(
|
||||
activity.getSupportFragmentManager(),
|
||||
item.getServiceId(),
|
||||
item.getAuthorEndpoint(),
|
||||
item.getAuthorName());
|
||||
} catch (Exception e) {
|
||||
ErrorActivity.reportUiError((AppCompatActivity) itemBuilder.getContext(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void allowLinkFocus() {
|
||||
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
private void denyLinkFocus() {
|
||||
itemContentView.setMovementMethod(null);
|
||||
}
|
||||
|
||||
private boolean shouldFocusLinks() {
|
||||
if (itemView.isInTouchMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
URLSpan[] urls = itemContentView.getUrls();
|
||||
|
||||
return urls != null && urls.length != 0;
|
||||
}
|
||||
|
||||
private void determineLinkFocus() {
|
||||
if (shouldFocusLinks()) {
|
||||
allowLinkFocus();
|
||||
} else {
|
||||
denyLinkFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void ellipsize() {
|
||||
boolean hasEllipsis = false;
|
||||
|
||||
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||
int endOfLastLine = itemContentView.getLayout().getLineEnd(COMMENT_DEFAULT_LINES - 1);
|
||||
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
|
||||
@ -164,8 +195,16 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
String newVal = itemContentView.getText().subSequence(0, end) + " …";
|
||||
itemContentView.setText(newVal);
|
||||
hasEllipsis = true;
|
||||
}
|
||||
|
||||
linkify();
|
||||
|
||||
if (hasEllipsis) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineLinkFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleEllipsize() {
|
||||
@ -182,11 +221,11 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||
itemContentView.setText(commentText);
|
||||
linkify();
|
||||
determineLinkFocus();
|
||||
}
|
||||
|
||||
private void linkify() {
|
||||
Linkify.addLinks(itemContentView, Linkify.WEB_URLS);
|
||||
Linkify.addLinks(itemContentView, PATTERN, null, null, timestampLink);
|
||||
itemContentView.setMovementMethod(null);
|
||||
}
|
||||
}
|
||||
|
@ -78,10 +78,8 @@ import org.schabi.newpipe.util.SerializedCache;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.CompositeDisposable;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import io.reactivex.disposables.SerialDisposable;
|
||||
@ -90,6 +88,8 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL
|
||||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION;
|
||||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK;
|
||||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT;
|
||||
import static io.reactivex.android.schedulers.AndroidSchedulers.mainThread;
|
||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
|
||||
/**
|
||||
* Base for the players, joining the common properties.
|
||||
@ -304,7 +304,7 @@ public abstract class BasePlayer implements
|
||||
final PlayQueueItem item = queue.getItem();
|
||||
if (item != null && item.getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
|
||||
stateLoader = recordManager.loadStreamState(item)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.observeOn(mainThread())
|
||||
.doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed,
|
||||
playbackPitch, playbackSkipSilence, true, isMuted))
|
||||
.subscribe(
|
||||
@ -655,8 +655,8 @@ public abstract class BasePlayer implements
|
||||
}
|
||||
|
||||
private Disposable getProgressReactor() {
|
||||
return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, mainThread())
|
||||
.observeOn(mainThread())
|
||||
.subscribe(ignored -> triggerProgressUpdate(),
|
||||
error -> Log.e(TAG, "Progress update failure: ", error));
|
||||
}
|
||||
@ -1261,7 +1261,7 @@ public abstract class BasePlayer implements
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
||||
final Disposable stateSaver = recordManager.saveStreamState(info, progress)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.observeOn(mainThread())
|
||||
.doOnError((e) -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
@ -1281,7 +1281,7 @@ public abstract class BasePlayer implements
|
||||
if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
||||
final Disposable stateSaver = queueItem.getStream()
|
||||
.flatMapCompletable(info -> recordManager.saveStreamState(info, 0))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.observeOn(mainThread())
|
||||
.doOnError((e) -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* Copyright 2019 Eltex ltd <eltex@eltex-co.ru>
|
||||
* MainVideoPlayer.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
@ -39,6 +40,7 @@ import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.DisplayCutout;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
@ -79,6 +81,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback;
|
||||
import org.schabi.newpipe.player.resolver.MediaSourceTag;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
|
||||
import org.schabi.newpipe.util.AnimationUtils;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
@ -86,6 +89,7 @@ import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
@ -94,6 +98,7 @@ import java.util.UUID;
|
||||
import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING;
|
||||
import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION;
|
||||
import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME;
|
||||
import static org.schabi.newpipe.player.VideoPlayer.DPAD_CONTROLS_HIDE_TIME;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.Type.SCALE_AND_ALPHA;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.Type.SLIDE_AND_ALPHA;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
|
||||
@ -150,6 +155,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
|
||||
hideSystemUi();
|
||||
setContentView(R.layout.activity_main_player);
|
||||
|
||||
playerImpl = new VideoPlayerImpl(this);
|
||||
playerImpl.setup(findViewById(android.R.id.content));
|
||||
|
||||
@ -170,17 +176,24 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
public void onChange(final boolean selfChange) {
|
||||
super.onChange(selfChange);
|
||||
if (globalScreenOrientationLocked()) {
|
||||
final String orientKey = getString(R.string.last_orientation_landscape_key);
|
||||
|
||||
final boolean lastOrientationWasLandscape = defaultPreferences
|
||||
.getBoolean(getString(R.string.last_orientation_landscape_key), false);
|
||||
.getBoolean(orientKey, AndroidTvUtils.isTv());
|
||||
setLandscape(lastOrientationWasLandscape);
|
||||
} else {
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getContentResolver().registerContentObserver(
|
||||
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION),
|
||||
false, rotationObserver);
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -204,6 +217,48 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(final int keyCode, final KeyEvent event) {
|
||||
switch (event.getKeyCode()) {
|
||||
default:
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
if (AndroidTvUtils.isTv() && playerImpl.isControlsVisible()) {
|
||||
playerImpl.hideControls(0, 0);
|
||||
hideSystemUi();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
View playerRoot = playerImpl.getRootView();
|
||||
View controls = playerImpl.getControlsRoot();
|
||||
if (playerRoot.hasFocus() && !controls.hasFocus()) {
|
||||
// do not interfere with focus in playlist etc.
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!playerImpl.isControlsVisible()) {
|
||||
playerImpl.playPauseButton.requestFocus();
|
||||
playerImpl.showControlsThenHide();
|
||||
showSystemUi();
|
||||
return true;
|
||||
} else {
|
||||
playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
if (DEBUG) {
|
||||
@ -213,8 +268,10 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
super.onResume();
|
||||
|
||||
if (globalScreenOrientationLocked()) {
|
||||
final String orientKey = getString(R.string.last_orientation_landscape_key);
|
||||
|
||||
boolean lastOrientationWasLandscape = defaultPreferences
|
||||
.getBoolean(getString(R.string.last_orientation_landscape_key), false);
|
||||
.getBoolean(orientKey, AndroidTvUtils.isTv());
|
||||
setLandscape(lastOrientationWasLandscape);
|
||||
}
|
||||
|
||||
@ -795,7 +852,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
getControlsVisibilityHandler().removeCallbacksAndMessages(null);
|
||||
animateView(getControlsRoot(), true, DEFAULT_CONTROLS_DURATION, 0, () -> {
|
||||
if (getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible()) {
|
||||
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
|
||||
safeHideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -946,6 +1003,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> {
|
||||
playPauseButton.setImageResource(R.drawable.ic_pause_white);
|
||||
animatePlayButtons(true, 200);
|
||||
playPauseButton.requestFocus();
|
||||
animateView(closeButton, false, DEFAULT_CONTROLS_DURATION);
|
||||
});
|
||||
|
||||
@ -958,6 +1016,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> {
|
||||
playPauseButton.setImageResource(R.drawable.ic_play_arrow_white);
|
||||
animatePlayButtons(true, 200);
|
||||
playPauseButton.requestFocus();
|
||||
animateView(closeButton, false, DEFAULT_CONTROLS_DURATION);
|
||||
});
|
||||
|
||||
@ -1031,6 +1090,21 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
super.showControls(duration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void safeHideControls(final long duration, final long delay) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "safeHideControls() called with: delay = [" + delay + "]");
|
||||
}
|
||||
|
||||
View controlsRoot = getControlsRoot();
|
||||
if (controlsRoot.isInTouchMode()) {
|
||||
getControlsVisibilityHandler().removeCallbacksAndMessages(null);
|
||||
getControlsVisibilityHandler().postDelayed(() ->
|
||||
animateView(controlsRoot, false, duration, 0,
|
||||
MainVideoPlayer.this::hideSystemUi), delay);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideControls(final long duration, final long delay) {
|
||||
if (DEBUG) {
|
||||
@ -1223,9 +1297,11 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||
if (playerImpl.isControlsVisible()) {
|
||||
playerImpl.hideControls(150, 0);
|
||||
} else {
|
||||
playerImpl.playPauseButton.requestFocus();
|
||||
playerImpl.showControlsThenHide();
|
||||
showSystemUi();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -99,6 +99,8 @@ public abstract class VideoPlayer extends BasePlayer
|
||||
|
||||
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
|
||||
|
||||
protected static final int RENDERER_UNAVAILABLE = -1;
|
||||
|
||||
@NonNull
|
||||
@ -966,8 +968,13 @@ public abstract class VideoPlayer extends BasePlayer
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showControlsThenHide() called");
|
||||
}
|
||||
animateView(controlsRoot, true, DEFAULT_CONTROLS_DURATION, 0, () ->
|
||||
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME));
|
||||
|
||||
final int hideTime = controlsRoot.isInTouchMode()
|
||||
? DEFAULT_CONTROLS_HIDE_TIME
|
||||
: DPAD_CONTROLS_HIDE_TIME;
|
||||
|
||||
animateView(controlsRoot, true, DEFAULT_CONTROLS_DURATION, 0,
|
||||
() -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime));
|
||||
}
|
||||
|
||||
public void showControls(final long duration) {
|
||||
@ -978,6 +985,17 @@ public abstract class VideoPlayer extends BasePlayer
|
||||
animateView(controlsRoot, true, duration);
|
||||
}
|
||||
|
||||
public void safeHideControls(final long duration, final long delay) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "safeHideControls() called with: delay = [" + delay + "]");
|
||||
}
|
||||
if (rootView.isInTouchMode()) {
|
||||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||||
controlsVisibilityHandler.postDelayed(
|
||||
() -> animateView(controlsRoot, false, duration), delay);
|
||||
}
|
||||
}
|
||||
|
||||
public void hideControls(final long duration, final long delay) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "hideControls() called with: delay = [" + delay + "]");
|
||||
|
@ -355,8 +355,8 @@ public class MediaSourceManager {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private Observable<Long> getEdgeIntervalSignal() {
|
||||
return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
return Observable.interval(progressUpdateIntervalMillis,
|
||||
TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.filter(ignored ->
|
||||
playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis));
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
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;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.Preference;
|
||||
@ -60,7 +62,11 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
||||
@Override
|
||||
public boolean onPreferenceTreeClick(final Preference preference) {
|
||||
if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) {
|
||||
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
|
||||
try {
|
||||
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Toast.makeText(getActivity(), R.string.general_error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
return super.onPreferenceTreeClick(preference);
|
||||
|
@ -13,7 +13,9 @@ import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
@ -59,6 +61,10 @@ public class SettingsActivity extends AppCompatActivity
|
||||
.replace(R.id.fragment_holder, new MainSettingsFragment())
|
||||
.commit();
|
||||
}
|
||||
|
||||
if (AndroidTvUtils.isTv()) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -0,0 +1,33 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
import org.schabi.newpipe.App;
|
||||
|
||||
public final class AndroidTvUtils {
|
||||
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
|
||||
|
||||
private AndroidTvUtils() { }
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
public static boolean isTv() {
|
||||
PackageManager pm = App.getApp().getPackageManager();
|
||||
|
||||
return pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV)
|
||||
|| pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK);
|
||||
}
|
||||
|
||||
public static boolean isConfirmKey(final int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_SPACE:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
|
||||
public final class FireTvUtils {
|
||||
private FireTvUtils() { }
|
||||
|
||||
public static boolean isFireTv() {
|
||||
return App.getApp().getPackageManager().hasSystemFeature("amazon.hardware.fire_tv");
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.util;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
@ -103,7 +104,10 @@ public final class PermissionHelper {
|
||||
Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + context.getPackageName()));
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(i);
|
||||
try {
|
||||
context.startActivity(i);
|
||||
} catch (ActivityNotFoundException ignored) {
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
|
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) Eltex ltd 2019 <eltex@eltex-co.ru>
|
||||
* FocusAwareCoordinator.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
public final class FocusAwareCoordinator extends CoordinatorLayout {
|
||||
private final Rect childFocus = new Rect();
|
||||
|
||||
public FocusAwareCoordinator(@NonNull final Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public FocusAwareCoordinator(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public FocusAwareCoordinator(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs, final int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestChildFocus(final View child, final View focused) {
|
||||
super.requestChildFocus(child, focused);
|
||||
|
||||
if (!isInTouchMode()) {
|
||||
if (focused.getHeight() >= getHeight()) {
|
||||
focused.getFocusedRect(childFocus);
|
||||
|
||||
((ViewGroup) child).offsetDescendantRectToMyCoords(focused, childFocus);
|
||||
} else {
|
||||
focused.getHitRect(childFocus);
|
||||
|
||||
((ViewGroup) child).offsetDescendantRectToMyCoords((View) focused.getParent(),
|
||||
childFocus);
|
||||
}
|
||||
|
||||
requestChildRectangleOnScreen(child, childFocus, false);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) Eltex ltd 2019 <eltex@eltex-co.ru>
|
||||
* FocusAwareDrawerLayout.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public final class FocusAwareDrawerLayout extends DrawerLayout {
|
||||
public FocusAwareDrawerLayout(@NonNull final Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public FocusAwareDrawerLayout(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public FocusAwareDrawerLayout(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs,
|
||||
final int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onRequestFocusInDescendants(final int direction,
|
||||
final Rect previouslyFocusedRect) {
|
||||
// SDK implementation of this method picks whatever visible View takes the focus first
|
||||
// without regard to addFocusables. If the open drawer is temporarily empty, the focus
|
||||
// escapes outside of it, which can be confusing
|
||||
|
||||
boolean hasOpenPanels = false;
|
||||
|
||||
for (int i = 0; i < getChildCount(); ++i) {
|
||||
View child = getChildAt(i);
|
||||
|
||||
DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams();
|
||||
|
||||
if (lp.gravity != 0 && isDrawerVisible(child)) {
|
||||
hasOpenPanels = true;
|
||||
|
||||
if (child.requestFocus(direction, previouslyFocusedRect)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOpenPanels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addFocusables(final ArrayList<View> views, final int direction,
|
||||
final int focusableMode) {
|
||||
boolean hasOpenPanels = false;
|
||||
View content = null;
|
||||
|
||||
for (int i = 0; i < getChildCount(); ++i) {
|
||||
View child = getChildAt(i);
|
||||
|
||||
DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams();
|
||||
|
||||
if (lp.gravity == 0) {
|
||||
content = child;
|
||||
} else {
|
||||
if (isDrawerVisible(child)) {
|
||||
hasOpenPanels = true;
|
||||
child.addFocusables(views, direction, focusableMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content != null && !hasOpenPanels) {
|
||||
content.addFocusables(views, direction, focusableMode);
|
||||
}
|
||||
}
|
||||
|
||||
// this override isn't strictly necessary, but it is helpful when DrawerLayout isn't
|
||||
// the topmost view in hierarchy (such as when system or builtin appcompat ActionBar is used)
|
||||
@Override
|
||||
@SuppressLint("RtlHardcoded")
|
||||
public void openDrawer(@NonNull final View drawerView, final boolean animate) {
|
||||
super.openDrawer(drawerView, animate);
|
||||
|
||||
drawerView.requestFocus(FOCUS_FORWARD);
|
||||
}
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) Eltex ltd 2019 <eltex@eltex-co.ru>
|
||||
* FocusAwareDrawerLayout.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.SeekBar;
|
||||
|
||||
import androidx.appcompat.widget.AppCompatSeekBar;
|
||||
import org.schabi.newpipe.util.AndroidTvUtils;
|
||||
|
||||
/**
|
||||
* SeekBar, adapted for directional navigation. It emulates touch-related callbacks
|
||||
* (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to
|
||||
* work with it.
|
||||
*/
|
||||
public final class FocusAwareSeekBar extends AppCompatSeekBar {
|
||||
private NestedListener listener;
|
||||
|
||||
private ViewTreeObserver treeObserver;
|
||||
|
||||
public FocusAwareSeekBar(final Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public FocusAwareSeekBar(final Context context, final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public FocusAwareSeekBar(final Context context, final AttributeSet attrs,
|
||||
final int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnSeekBarChangeListener(final OnSeekBarChangeListener l) {
|
||||
this.listener = l == null ? null : new NestedListener(l);
|
||||
|
||||
super.setOnSeekBarChangeListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(final int keyCode, final KeyEvent event) {
|
||||
if (!isInTouchMode() && AndroidTvUtils.isConfirmKey(keyCode)) {
|
||||
releaseTrack();
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFocusChanged(final boolean gainFocus, final int direction,
|
||||
final Rect previouslyFocusedRect) {
|
||||
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
|
||||
|
||||
if (!isInTouchMode() && !gainFocus) {
|
||||
releaseTrack();
|
||||
}
|
||||
}
|
||||
|
||||
private final ViewTreeObserver.OnTouchModeChangeListener touchModeListener = isInTouchMode -> {
|
||||
if (isInTouchMode) {
|
||||
releaseTrack();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
|
||||
treeObserver = getViewTreeObserver();
|
||||
treeObserver.addOnTouchModeChangeListener(touchModeListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
if (treeObserver == null || !treeObserver.isAlive()) {
|
||||
treeObserver = getViewTreeObserver();
|
||||
}
|
||||
|
||||
treeObserver.removeOnTouchModeChangeListener(touchModeListener);
|
||||
treeObserver = null;
|
||||
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
private void releaseTrack() {
|
||||
if (listener != null && listener.isSeeking) {
|
||||
listener.onStopTrackingTouch(this);
|
||||
}
|
||||
}
|
||||
|
||||
private final class NestedListener implements OnSeekBarChangeListener {
|
||||
private final OnSeekBarChangeListener delegate;
|
||||
|
||||
boolean isSeeking;
|
||||
|
||||
private NestedListener(final OnSeekBarChangeListener delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
||||
final boolean fromUser) {
|
||||
if (!seekBar.isInTouchMode() && !isSeeking && fromUser) {
|
||||
isSeeking = true;
|
||||
|
||||
onStartTrackingTouch(seekBar);
|
||||
}
|
||||
|
||||
delegate.onProgressChanged(seekBar, progress, fromUser);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
||||
isSeeking = true;
|
||||
|
||||
delegate.onStartTrackingTouch(seekBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
||||
isSeeking = false;
|
||||
|
||||
delegate.onStopTrackingTouch(seekBar);
|
||||
}
|
||||
}
|
||||
}
|
293
app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java
Normal file
293
app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java
Normal file
@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright 2019 Alexander Rvachev <rvacheva@nxt.ru>
|
||||
* FocusOverlayView.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.Window;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.view.WindowCallbackWrapper;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
public final class FocusOverlayView extends Drawable implements
|
||||
ViewTreeObserver.OnGlobalFocusChangeListener,
|
||||
ViewTreeObserver.OnDrawListener,
|
||||
ViewTreeObserver.OnGlobalLayoutListener,
|
||||
ViewTreeObserver.OnScrollChangedListener, ViewTreeObserver.OnTouchModeChangeListener {
|
||||
|
||||
private boolean isInTouchMode;
|
||||
|
||||
private final Rect focusRect = new Rect();
|
||||
|
||||
private final Paint rectPaint = new Paint();
|
||||
|
||||
private final Handler animator = new Handler(Looper.getMainLooper()) {
|
||||
@Override
|
||||
public void handleMessage(final Message msg) {
|
||||
updateRect();
|
||||
}
|
||||
};
|
||||
|
||||
private WeakReference<View> focused;
|
||||
|
||||
public FocusOverlayView(final Context context) {
|
||||
rectPaint.setStyle(Paint.Style.STROKE);
|
||||
rectPaint.setStrokeWidth(2);
|
||||
rectPaint.setColor(context.getResources().getColor(R.color.white));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGlobalFocusChanged(final View oldFocus, final View newFocus) {
|
||||
int l = focusRect.left;
|
||||
int r = focusRect.right;
|
||||
int t = focusRect.top;
|
||||
int b = focusRect.bottom;
|
||||
|
||||
if (newFocus != null && newFocus.getWidth() > 0 && newFocus.getHeight() > 0) {
|
||||
newFocus.getGlobalVisibleRect(focusRect);
|
||||
|
||||
focused = new WeakReference<>(newFocus);
|
||||
} else {
|
||||
focusRect.setEmpty();
|
||||
|
||||
focused = null;
|
||||
}
|
||||
|
||||
if (l != focusRect.left || r != focusRect.right
|
||||
|| t != focusRect.top || b != focusRect.bottom) {
|
||||
invalidateSelf();
|
||||
}
|
||||
|
||||
focused = new WeakReference<>(newFocus);
|
||||
|
||||
animator.sendEmptyMessageDelayed(0, 1000);
|
||||
}
|
||||
|
||||
private void updateRect() {
|
||||
if (focused == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
View focusedView = this.focused.get();
|
||||
|
||||
int l = focusRect.left;
|
||||
int r = focusRect.right;
|
||||
int t = focusRect.top;
|
||||
int b = focusRect.bottom;
|
||||
|
||||
if (focusedView != null) {
|
||||
focusedView.getGlobalVisibleRect(focusRect);
|
||||
} else {
|
||||
focusRect.setEmpty();
|
||||
}
|
||||
|
||||
if (l != focusRect.left || r != focusRect.right
|
||||
|| t != focusRect.top || b != focusRect.bottom) {
|
||||
invalidateSelf();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDraw() {
|
||||
updateRect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrollChanged() {
|
||||
updateRect();
|
||||
|
||||
animator.removeMessages(0);
|
||||
animator.sendEmptyMessageDelayed(0, 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGlobalLayout() {
|
||||
updateRect();
|
||||
|
||||
animator.sendEmptyMessageDelayed(0, 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchModeChanged(final boolean inTouchMode) {
|
||||
this.isInTouchMode = inTouchMode;
|
||||
|
||||
if (inTouchMode) {
|
||||
updateRect();
|
||||
} else {
|
||||
invalidateSelf();
|
||||
}
|
||||
}
|
||||
|
||||
public void setCurrentFocus(final View newFocus) {
|
||||
if (newFocus == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isInTouchMode = newFocus.isInTouchMode();
|
||||
|
||||
onGlobalFocusChanged(null, newFocus);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull final Canvas canvas) {
|
||||
if (!isInTouchMode && focusRect.width() != 0) {
|
||||
canvas.drawRect(focusRect, rectPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity() {
|
||||
return PixelFormat.TRANSPARENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(final int alpha) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(final ColorFilter colorFilter) {
|
||||
}
|
||||
|
||||
public static void setupFocusObserver(final Dialog dialog) {
|
||||
Rect displayRect = new Rect();
|
||||
|
||||
Window window = dialog.getWindow();
|
||||
assert window != null;
|
||||
|
||||
View decor = window.getDecorView();
|
||||
decor.getWindowVisibleDisplayFrame(displayRect);
|
||||
|
||||
FocusOverlayView overlay = new FocusOverlayView(dialog.getContext());
|
||||
overlay.setBounds(0, 0, displayRect.width(), displayRect.height());
|
||||
|
||||
setupOverlay(window, overlay);
|
||||
}
|
||||
|
||||
public static void setupFocusObserver(final Activity activity) {
|
||||
Rect displayRect = new Rect();
|
||||
|
||||
Window window = activity.getWindow();
|
||||
View decor = window.getDecorView();
|
||||
decor.getWindowVisibleDisplayFrame(displayRect);
|
||||
|
||||
FocusOverlayView overlay = new FocusOverlayView(activity);
|
||||
overlay.setBounds(0, 0, displayRect.width(), displayRect.height());
|
||||
|
||||
setupOverlay(window, overlay);
|
||||
}
|
||||
|
||||
private static void setupOverlay(final Window window, final FocusOverlayView overlay) {
|
||||
ViewGroup decor = (ViewGroup) window.getDecorView();
|
||||
decor.getOverlay().add(overlay);
|
||||
|
||||
fixFocusHierarchy(decor);
|
||||
|
||||
ViewTreeObserver observer = decor.getViewTreeObserver();
|
||||
observer.addOnScrollChangedListener(overlay);
|
||||
observer.addOnGlobalFocusChangeListener(overlay);
|
||||
observer.addOnGlobalLayoutListener(overlay);
|
||||
observer.addOnTouchModeChangeListener(overlay);
|
||||
|
||||
overlay.setCurrentFocus(decor.getFocusedChild());
|
||||
|
||||
// Some key presses don't actually move focus, but still result in movement on screen.
|
||||
// For example, MovementMethod of TextView may cause requestRectangleOnScreen() due to
|
||||
// some "focusable" spans, which in turn causes CoordinatorLayout to "scroll" it's children.
|
||||
// Unfortunately many such forms of "scrolling" do not count as scrolling for purpose
|
||||
// of dispatching ViewTreeObserver callbacks, so we have to intercept them by directly
|
||||
// receiving keys from Window.
|
||||
window.setCallback(new WindowCallbackWrapper(window.getCallback()) {
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(final KeyEvent event) {
|
||||
boolean res = super.dispatchKeyEvent(event);
|
||||
overlay.onKey(event);
|
||||
return res;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onKey(final KeyEvent event) {
|
||||
if (event.getAction() != KeyEvent.ACTION_DOWN) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateRect();
|
||||
|
||||
animator.sendEmptyMessageDelayed(0, 100);
|
||||
}
|
||||
|
||||
private static void fixFocusHierarchy(final View decor) {
|
||||
// During Android 8 development some dumb ass decided, that action bar has to be
|
||||
// a keyboard focus cluster. Unfortunately, keyboard clusters do not work for primary
|
||||
// auditory of key navigation — Android TV users (Android TV remotes do not have
|
||||
// keyboard META key for moving between clusters). We have to fix this unfortunate accident
|
||||
// While we are at it, let's deal with touchscreenBlocksFocus too.
|
||||
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(decor instanceof ViewGroup)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearFocusObstacles((ViewGroup) decor);
|
||||
}
|
||||
|
||||
@RequiresApi(api = 26)
|
||||
private static void clearFocusObstacles(final ViewGroup viewGroup) {
|
||||
viewGroup.setTouchscreenBlocksFocus(false);
|
||||
|
||||
if (viewGroup.isKeyboardNavigationCluster()) {
|
||||
viewGroup.setKeyboardNavigationCluster(false);
|
||||
|
||||
return; // clusters aren't supposed to nest
|
||||
}
|
||||
|
||||
int childCount = viewGroup.getChildCount();
|
||||
|
||||
for (int i = 0; i < childCount; ++i) {
|
||||
View view = viewGroup.getChildAt(i);
|
||||
|
||||
if (view instanceof ViewGroup) {
|
||||
clearFocusObstacles((ViewGroup) view);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Copyright 2019 Alexander Rvachev <rvacheva@nxt.ru>
|
||||
* FocusOverlayView.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.text.Layout;
|
||||
import android.text.Selection;
|
||||
import android.text.Spannable;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class LargeTextMovementMethod extends LinkMovementMethod {
|
||||
private final Rect visibleRect = new Rect();
|
||||
|
||||
private int direction;
|
||||
|
||||
@Override
|
||||
public void onTakeFocus(final TextView view, final Spannable text, final int dir) {
|
||||
Selection.removeSelection(text);
|
||||
|
||||
super.onTakeFocus(view, text, dir);
|
||||
|
||||
this.direction = dirToRelative(dir);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean handleMovementKey(final TextView widget,
|
||||
final Spannable buffer,
|
||||
final int keyCode,
|
||||
final int movementMetaState,
|
||||
final KeyEvent event) {
|
||||
if (!doHandleMovement(widget, buffer, keyCode, movementMetaState, event)) {
|
||||
// clear selection to make sure, that it does not confuse focus handling code
|
||||
Selection.removeSelection(buffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean doHandleMovement(final TextView widget,
|
||||
final Spannable buffer,
|
||||
final int keyCode,
|
||||
final int movementMetaState,
|
||||
final KeyEvent event) {
|
||||
int newDir = keyToDir(keyCode);
|
||||
|
||||
if (direction != 0 && newDir != direction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.direction = 0;
|
||||
|
||||
ViewGroup root = findScrollableParent(widget);
|
||||
|
||||
widget.getHitRect(visibleRect);
|
||||
|
||||
root.offsetDescendantRectToMyCoords((View) widget.getParent(), visibleRect);
|
||||
|
||||
return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean up(final TextView widget, final Spannable buffer) {
|
||||
if (gotoPrev(widget, buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.up(widget, buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean left(final TextView widget, final Spannable buffer) {
|
||||
if (gotoPrev(widget, buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.left(widget, buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean right(final TextView widget, final Spannable buffer) {
|
||||
if (gotoNext(widget, buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.right(widget, buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean down(final TextView widget, final Spannable buffer) {
|
||||
if (gotoNext(widget, buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.down(widget, buffer);
|
||||
}
|
||||
|
||||
private boolean gotoPrev(final TextView view, final Spannable buffer) {
|
||||
Layout layout = view.getLayout();
|
||||
if (layout == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
View root = findScrollableParent(view);
|
||||
|
||||
int rootHeight = root.getHeight();
|
||||
|
||||
if (visibleRect.top >= 0) {
|
||||
// we fit entirely into the viewport, no need for fancy footwork
|
||||
return false;
|
||||
}
|
||||
|
||||
int topExtra = -visibleRect.top;
|
||||
|
||||
int firstVisibleLineNumber = layout.getLineForVertical(topExtra);
|
||||
|
||||
// when deciding whether to pass "focus" to span, account for one more line
|
||||
// this ensures, that focus is never passed to spans partially outside scroll window
|
||||
int visibleStart = firstVisibleLineNumber == 0
|
||||
? 0
|
||||
: layout.getLineStart(firstVisibleLineNumber - 1);
|
||||
|
||||
ClickableSpan[] candidates = buffer.getSpans(
|
||||
visibleStart, buffer.length(), ClickableSpan.class);
|
||||
|
||||
if (candidates.length != 0) {
|
||||
int a = Selection.getSelectionStart(buffer);
|
||||
int b = Selection.getSelectionEnd(buffer);
|
||||
|
||||
int selStart = Math.min(a, b);
|
||||
int selEnd = Math.max(a, b);
|
||||
|
||||
int bestStart = -1;
|
||||
int bestEnd = -1;
|
||||
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
int start = buffer.getSpanStart(candidates[i]);
|
||||
int end = buffer.getSpanEnd(candidates[i]);
|
||||
|
||||
if ((end < selEnd || selStart == selEnd) && start >= visibleStart) {
|
||||
if (end > bestEnd) {
|
||||
bestStart = buffer.getSpanStart(candidates[i]);
|
||||
bestEnd = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestStart >= 0) {
|
||||
Selection.setSelection(buffer, bestEnd, bestStart);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
float fourLines = view.getTextSize() * 4;
|
||||
|
||||
visibleRect.left = 0;
|
||||
visibleRect.right = view.getWidth();
|
||||
visibleRect.top = Math.max(0, (int) (topExtra - fourLines));
|
||||
visibleRect.bottom = visibleRect.top + rootHeight;
|
||||
|
||||
return view.requestRectangleOnScreen(visibleRect);
|
||||
}
|
||||
|
||||
private boolean gotoNext(final TextView view, final Spannable buffer) {
|
||||
Layout layout = view.getLayout();
|
||||
if (layout == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
View root = findScrollableParent(view);
|
||||
|
||||
int rootHeight = root.getHeight();
|
||||
|
||||
if (visibleRect.bottom <= rootHeight) {
|
||||
// we fit entirely into the viewport, no need for fancy footwork
|
||||
return false;
|
||||
}
|
||||
|
||||
int bottomExtra = visibleRect.bottom - rootHeight;
|
||||
|
||||
int visibleBottomBorder = view.getHeight() - bottomExtra;
|
||||
|
||||
int lineCount = layout.getLineCount();
|
||||
|
||||
int lastVisibleLineNumber = layout.getLineForVertical(visibleBottomBorder);
|
||||
|
||||
// when deciding whether to pass "focus" to span, account for one more line
|
||||
// this ensures, that focus is never passed to spans partially outside scroll window
|
||||
int visibleEnd = lastVisibleLineNumber == lineCount - 1
|
||||
? buffer.length()
|
||||
: layout.getLineEnd(lastVisibleLineNumber - 1);
|
||||
|
||||
ClickableSpan[] candidates = buffer.getSpans(0, visibleEnd, ClickableSpan.class);
|
||||
|
||||
if (candidates.length != 0) {
|
||||
int a = Selection.getSelectionStart(buffer);
|
||||
int b = Selection.getSelectionEnd(buffer);
|
||||
|
||||
int selStart = Math.min(a, b);
|
||||
int selEnd = Math.max(a, b);
|
||||
|
||||
int bestStart = Integer.MAX_VALUE;
|
||||
int bestEnd = Integer.MAX_VALUE;
|
||||
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
int start = buffer.getSpanStart(candidates[i]);
|
||||
int end = buffer.getSpanEnd(candidates[i]);
|
||||
|
||||
if ((start > selStart || selStart == selEnd) && end <= visibleEnd) {
|
||||
if (start < bestStart) {
|
||||
bestStart = start;
|
||||
bestEnd = buffer.getSpanEnd(candidates[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestEnd < Integer.MAX_VALUE) {
|
||||
// cool, we have managed to find next link without having to adjust self within view
|
||||
Selection.setSelection(buffer, bestStart, bestEnd);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// there are no links within visible area, but still some text past visible area
|
||||
// scroll visible area further in required direction
|
||||
float fourLines = view.getTextSize() * 4;
|
||||
|
||||
visibleRect.left = 0;
|
||||
visibleRect.right = view.getWidth();
|
||||
visibleRect.bottom = Math.min((int) (visibleBottomBorder + fourLines), view.getHeight());
|
||||
visibleRect.top = visibleRect.bottom - rootHeight;
|
||||
|
||||
return view.requestRectangleOnScreen(visibleRect);
|
||||
}
|
||||
|
||||
private ViewGroup findScrollableParent(final View view) {
|
||||
View current = view;
|
||||
|
||||
ViewParent parent;
|
||||
do {
|
||||
parent = current.getParent();
|
||||
|
||||
if (parent == current || !(parent instanceof View)) {
|
||||
return (ViewGroup) view.getRootView();
|
||||
}
|
||||
|
||||
current = (View) parent;
|
||||
|
||||
if (current.isScrollContainer()) {
|
||||
return (ViewGroup) current;
|
||||
}
|
||||
}
|
||||
while (true);
|
||||
}
|
||||
|
||||
private static int dirToRelative(final int dir) {
|
||||
switch (dir) {
|
||||
case View.FOCUS_DOWN:
|
||||
case View.FOCUS_RIGHT:
|
||||
return View.FOCUS_FORWARD;
|
||||
case View.FOCUS_UP:
|
||||
case View.FOCUS_LEFT:
|
||||
return View.FOCUS_BACKWARD;
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
private int keyToDir(final int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
return View.FOCUS_BACKWARD;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
return View.FOCUS_FORWARD;
|
||||
}
|
||||
|
||||
return View.FOCUS_FORWARD;
|
||||
}
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (C) Eltex ltd 2019 <eltex@eltex-co.ru>
|
||||
* NewPipeRecyclerView.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.FocusFinder;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
public class NewPipeRecyclerView extends RecyclerView {
|
||||
private static final String TAG = "NewPipeRecyclerView";
|
||||
|
||||
private Rect focusRect = new Rect();
|
||||
private Rect tempFocus = new Rect();
|
||||
|
||||
private boolean allowDpadScroll = true;
|
||||
|
||||
public NewPipeRecyclerView(@NonNull final Context context) {
|
||||
super(context);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
public NewPipeRecyclerView(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
public NewPipeRecyclerView(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs, final int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
setFocusable(true);
|
||||
|
||||
setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
|
||||
}
|
||||
|
||||
public void setFocusScrollAllowed(final boolean allowed) {
|
||||
this.allowDpadScroll = allowed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View focusSearch(final View focused, final int direction) {
|
||||
// RecyclerView has buggy focusSearch(), that calls into Adapter several times,
|
||||
// but ultimately fails to produce correct results in many cases. To add insult to injury,
|
||||
// it's focusSearch() hard-codes several behaviors, incompatible with widely accepted focus
|
||||
// handling practices: RecyclerView does not allow Adapter to give focus to itself (!!) and
|
||||
// always checks, that returned View is located in "correct" direction (which prevents us
|
||||
// from temporarily giving focus to special hidden View).
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void removeDetachedView(final View child, final boolean animate) {
|
||||
if (child.hasFocus()) {
|
||||
// If the focused child is being removed (can happen during very fast scrolling),
|
||||
// temporarily give focus to ourselves. This will usually result in another child
|
||||
// gaining focus (which one does not really matter, because at that point scrolling
|
||||
// is FAST, and that child will soon be off-screen too)
|
||||
requestFocus();
|
||||
}
|
||||
|
||||
super.removeDetachedView(child, animate);
|
||||
}
|
||||
|
||||
// we override focusSearch to always return null, so all moves moves lead to
|
||||
// dispatchUnhandledMove(). As added advantage, we can fully swallow some kinds of moves
|
||||
// (such as downward movement, that happens when loading additional contents is in progress
|
||||
|
||||
@Override
|
||||
public boolean dispatchUnhandledMove(final View focused, final int direction) {
|
||||
tempFocus.setEmpty();
|
||||
|
||||
// save focus rect before further manipulation (both focusSearch() and scrollBy()
|
||||
// can mess with focused View by moving it off-screen and detaching)
|
||||
|
||||
if (focused != null) {
|
||||
View focusedItem = findContainingItemView(focused);
|
||||
if (focusedItem != null) {
|
||||
focusedItem.getHitRect(focusRect);
|
||||
}
|
||||
}
|
||||
|
||||
// call focusSearch() to initiate layout, but disregard returned View for now
|
||||
View adapterResult = super.focusSearch(focused, direction);
|
||||
if (adapterResult != null && !isOutside(adapterResult)) {
|
||||
adapterResult.requestFocus(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arrowScroll(direction)) {
|
||||
// if RecyclerView can not yield focus, but there is still some scrolling space in
|
||||
// indicated, direction, scroll some fixed amount in that direction
|
||||
// (the same logic in ScrollView)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (focused != this && direction == FOCUS_DOWN && !allowDpadScroll) {
|
||||
Log.i(TAG, "Consuming downward scroll: content load in progress");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tryFocusFinder(direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (adapterResult != null) {
|
||||
adapterResult.requestFocus(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.dispatchUnhandledMove(focused, direction);
|
||||
}
|
||||
|
||||
private boolean tryFocusFinder(final int direction) {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
// Android 9 implemented bunch of handy changes to focus, that render code below less
|
||||
// useful, and also broke findNextFocusFromRect in way, that render this hack useless
|
||||
return false;
|
||||
}
|
||||
|
||||
FocusFinder finder = FocusFinder.getInstance();
|
||||
|
||||
// try to use FocusFinder instead of adapter
|
||||
ViewGroup root = (ViewGroup) getRootView();
|
||||
|
||||
tempFocus.set(focusRect);
|
||||
|
||||
root.offsetDescendantRectToMyCoords(this, tempFocus);
|
||||
|
||||
View focusFinderResult = finder.findNextFocusFromRect(root, tempFocus, direction);
|
||||
if (focusFinderResult != null && !isOutside(focusFinderResult)) {
|
||||
focusFinderResult.requestFocus(direction);
|
||||
return true;
|
||||
}
|
||||
|
||||
// look for focus in our ancestors, increasing search scope with each failure
|
||||
// this provides much better locality than using FocusFinder with root
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
|
||||
while (parent != root) {
|
||||
tempFocus.set(focusRect);
|
||||
|
||||
parent.offsetDescendantRectToMyCoords(this, tempFocus);
|
||||
|
||||
View candidate = finder.findNextFocusFromRect(parent, tempFocus, direction);
|
||||
if (candidate != null && candidate.requestFocus(direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
parent = (ViewGroup) parent.getParent();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean arrowScroll(final int direction) {
|
||||
switch (direction) {
|
||||
case FOCUS_DOWN:
|
||||
if (!canScrollVertically(1)) {
|
||||
return false;
|
||||
}
|
||||
scrollBy(0, 100);
|
||||
break;
|
||||
case FOCUS_UP:
|
||||
if (!canScrollVertically(-1)) {
|
||||
return false;
|
||||
}
|
||||
scrollBy(0, -100);
|
||||
break;
|
||||
case FOCUS_LEFT:
|
||||
if (!canScrollHorizontally(-1)) {
|
||||
return false;
|
||||
}
|
||||
scrollBy(-100, 0);
|
||||
break;
|
||||
case FOCUS_RIGHT:
|
||||
if (!canScrollHorizontally(-1)) {
|
||||
return false;
|
||||
}
|
||||
scrollBy(100, 0);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isOutside(final View view) {
|
||||
return findContainingItemView(view) == null;
|
||||
}
|
||||
}
|
@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) Eltex ltd 2019 <eltex@eltex-co.ru>
|
||||
* SuperScrollLayoutManager.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public final class SuperScrollLayoutManager extends LinearLayoutManager {
|
||||
private final Rect handy = new Rect();
|
||||
|
||||
private final ArrayList<View> focusables = new ArrayList<>();
|
||||
|
||||
public SuperScrollLayoutManager(final Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestChildRectangleOnScreen(@NonNull final RecyclerView parent,
|
||||
@NonNull final View child,
|
||||
@NonNull final Rect rect,
|
||||
final boolean immediate,
|
||||
final boolean focusedChildVisible) {
|
||||
if (!parent.isInTouchMode()) {
|
||||
// only activate when in directional navigation mode (Android TV etc) — fine grained
|
||||
// touch scrolling is better served by nested scroll system
|
||||
|
||||
if (!focusedChildVisible || getFocusedChild() == child) {
|
||||
handy.set(rect);
|
||||
|
||||
parent.offsetDescendantRectToMyCoords(child, handy);
|
||||
|
||||
parent.requestRectangleOnScreen(handy, immediate);
|
||||
}
|
||||
}
|
||||
|
||||
return super.requestChildRectangleOnScreen(parent, child, rect, immediate,
|
||||
focusedChildVisible);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onInterceptFocusSearch(@NonNull final View focused, final int direction) {
|
||||
View focusedItem = findContainingItemView(focused);
|
||||
if (focusedItem == null) {
|
||||
return super.onInterceptFocusSearch(focused, direction);
|
||||
}
|
||||
|
||||
int listDirection = getAbsoluteDirection(direction);
|
||||
if (listDirection == 0) {
|
||||
return super.onInterceptFocusSearch(focused, direction);
|
||||
}
|
||||
|
||||
// FocusFinder has an oddity: it considers size of Views more important
|
||||
// than closeness to source View. This means, that big Views far away from current item
|
||||
// are preferred to smaller sub-View of closer item. Setting focusability of closer item
|
||||
// to FOCUS_AFTER_DESCENDANTS does not solve this, because ViewGroup#addFocusables omits
|
||||
// such parent itself from list, if any of children are focusable.
|
||||
// Fortunately we can intercept focus search and implement our own logic, based purely
|
||||
// on position along the LinearLayoutManager axis
|
||||
|
||||
ViewGroup recycler = (ViewGroup) focusedItem.getParent();
|
||||
|
||||
int sourcePosition = getPosition(focusedItem);
|
||||
if (sourcePosition == 0 && listDirection < 0) {
|
||||
return super.onInterceptFocusSearch(focused, direction);
|
||||
}
|
||||
|
||||
View preferred = null;
|
||||
|
||||
int distance = Integer.MAX_VALUE;
|
||||
|
||||
focusables.clear();
|
||||
|
||||
recycler.addFocusables(focusables, direction, recycler.isInTouchMode()
|
||||
? View.FOCUSABLES_TOUCH_MODE
|
||||
: View.FOCUSABLES_ALL);
|
||||
|
||||
try {
|
||||
for (View view : focusables) {
|
||||
if (view == focused || view == recycler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (view == focusedItem) {
|
||||
// do not pass focus back to the item View itself - it makes no sense
|
||||
// (we can still pass focus to it's children however)
|
||||
continue;
|
||||
}
|
||||
|
||||
int candidate = getDistance(sourcePosition, view, listDirection);
|
||||
if (candidate < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate < distance) {
|
||||
distance = candidate;
|
||||
preferred = view;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
focusables.clear();
|
||||
}
|
||||
|
||||
return preferred;
|
||||
}
|
||||
|
||||
private int getAbsoluteDirection(final int direction) {
|
||||
switch (direction) {
|
||||
default:
|
||||
break;
|
||||
case View.FOCUS_FORWARD:
|
||||
return 1;
|
||||
case View.FOCUS_BACKWARD:
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (getOrientation() == RecyclerView.HORIZONTAL) {
|
||||
switch (direction) {
|
||||
default:
|
||||
break;
|
||||
case View.FOCUS_LEFT:
|
||||
return getReverseLayout() ? 1 : -1;
|
||||
case View.FOCUS_RIGHT:
|
||||
return getReverseLayout() ? -1 : 1;
|
||||
}
|
||||
} else {
|
||||
switch (direction) {
|
||||
default:
|
||||
break;
|
||||
case View.FOCUS_UP:
|
||||
return getReverseLayout() ? 1 : -1;
|
||||
case View.FOCUS_DOWN:
|
||||
return getReverseLayout() ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int getDistance(final int sourcePosition, final View candidate, final int direction) {
|
||||
View itemView = findContainingItemView(candidate);
|
||||
if (itemView == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int position = getPosition(itemView);
|
||||
|
||||
return direction * (position - sourcePosition);
|
||||
}
|
||||
}
|
@ -178,7 +178,6 @@
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
tools:ignore="RtlHardcoded"
|
||||
tools:text="The Video Title LONG very LONG"/>
|
||||
|
||||
@ -194,12 +193,12 @@
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
tools:text="The Video Artist LONG very LONG very Long"/>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
<Button
|
||||
android:id="@+id/qualityTextView"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_marginLeft="2dp"
|
||||
@ -210,11 +209,13 @@
|
||||
android:text="720p"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:textAllCaps="false"
|
||||
android:padding="5dp"
|
||||
tools:ignore="HardcodedText,RtlHardcoded"/>
|
||||
|
||||
<TextView
|
||||
<Button
|
||||
android:id="@+id/playbackSpeed"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginRight="2dp"
|
||||
@ -224,7 +225,8 @@
|
||||
android:minWidth="40dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:textAllCaps="false"
|
||||
android:padding="5dp"
|
||||
tools:ignore="RtlHardcoded,RtlSymmetry"
|
||||
tools:text="1x" />
|
||||
|
||||
@ -270,8 +272,9 @@
|
||||
tools:ignore="RtlHardcoded"
|
||||
tools:visibility="visible">
|
||||
|
||||
<TextView
|
||||
<Button
|
||||
android:id="@+id/resizeTextView"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
@ -281,11 +284,13 @@
|
||||
android:minWidth="50dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:textAllCaps="false"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
tools:ignore="HardcodedText,RtlHardcoded"
|
||||
tools:text="FIT"/>
|
||||
|
||||
<TextView
|
||||
<Button
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
android:id="@+id/captionTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@ -300,6 +305,7 @@
|
||||
android:paddingRight="2dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:textAllCaps="false"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
tools:ignore="RelativeOverlap,RtlHardcoded"
|
||||
tools:text="English" />
|
||||
@ -431,7 +437,7 @@
|
||||
tools:text="1:06:29"/>
|
||||
|
||||
|
||||
<androidx.appcompat.widget.AppCompatSeekBar
|
||||
<org.schabi.newpipe.views.FocusAwareSeekBar
|
||||
android:id="@+id/playbackSeekBar"
|
||||
style="@style/Widget.AppCompat.SeekBar"
|
||||
android:layout_width="0dp"
|
||||
|
@ -10,11 +10,12 @@
|
||||
android:orientation="horizontal"
|
||||
android:baselineAligned="false">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
<org.schabi.newpipe.views.FocusAwareCoordinator
|
||||
android:id="@+id/detail_main_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5"
|
||||
android:isScrollContainer="true"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
@ -22,6 +23,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:touchscreenBlocksFocus="false"
|
||||
android:fitsSystemWindows="true"
|
||||
app:elevation="0dp"
|
||||
app:layout_behavior="com.google.android.material.appbar.FlingBehavior">
|
||||
@ -246,6 +248,7 @@
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:focusable="true"
|
||||
android:padding="6dp">
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
@ -378,6 +381,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:focusable="true"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
android:padding="6dp">
|
||||
|
||||
<!-- CONTROLS -->
|
||||
@ -466,6 +471,8 @@
|
||||
android:layout_marginTop="5dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
android:focusable="true"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
tools:visibility="visible">
|
||||
|
||||
<TextView
|
||||
@ -481,6 +488,7 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detail_description_view"
|
||||
android:nextFocusUp="@+id/detail_control_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
@ -490,6 +498,7 @@
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="@dimen/video_item_detail_description_text_size"
|
||||
android:focusable="false"
|
||||
tools:text="Description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a ultricies ex. Integer sit amet sodales risus. Duis non mi et urna pretium bibendum." />
|
||||
|
||||
<View
|
||||
@ -526,7 +535,7 @@
|
||||
|
||||
</com.google.android.material.tabs.TabLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</org.schabi.newpipe.views.FocusAwareCoordinator>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/relatedStreamsLayout"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.drawerlayout.widget.DrawerLayout
|
||||
<org.schabi.newpipe.views.FocusAwareDrawerLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/drawer_layout"
|
||||
@ -24,4 +24,4 @@
|
||||
|
||||
<include layout="@layout/drawer_layout"/>
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
</org.schabi.newpipe.views.FocusAwareDrawerLayout>
|
||||
|
@ -1,11 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?colorPrimary"
|
||||
android:nextFocusDown="@+id/drawer_header_action_button">
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?colorPrimary"
|
||||
android:nextFocusDown="@+id/drawer_header_action_button">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
|
@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.navigation.NavigationView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/navigation"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:background="?android:attr/windowBackground"
|
||||
android:orientation="vertical"
|
||||
app:headerLayout="@layout/drawer_header" />
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/navigation"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:background="?android:attr/windowBackground"
|
||||
android:orientation="vertical"
|
||||
app:headerLayout="@layout/drawer_header" />
|
@ -5,7 +5,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
<org.schabi.newpipe.views.NewPipeRecyclerView
|
||||
android:id="@+id/items_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
@ -5,7 +5,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
<org.schabi.newpipe.views.NewPipeRecyclerView
|
||||
android:id="@+id/items_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
@ -12,6 +12,7 @@
|
||||
android:layout_marginTop="3dp"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:orientation="horizontal"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="5dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_marginRight="@dimen/video_item_search_image_right_margin"
|
||||
android:contentDescription="@string/list_thumbnail_view_description"
|
||||
android:focusable="false"
|
||||
android:src="@drawable/buddy"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
android:background="?attr/colorPrimary"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusDown="@+id/suggestions_list"
|
||||
android:hint="@string/search"
|
||||
android:imeOptions="actionSearch|flagNoFullscreen"
|
||||
android:inputType="textFilter|textNoSuggestions"
|
||||
|
BIN
app/src/main/res/mipmap-xhdpi/newpipe_tv_banner.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/newpipe_tv_banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
Loading…
Reference in New Issue
Block a user