diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df1c27ffa..28215e013 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,10 @@ + + + diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java index 78da9678b..09f9aea58 100644 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java @@ -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()) { diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index e6269dd5f..164a2839a 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -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 diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index bb24c9681..264644c74 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -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 getChoicesForService(final StreamingService service, diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index 912d63e5a..c5a0c3a22 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -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() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 0441e1ba1..eba77847b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -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 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 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 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 diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 55301dd50..c87096712 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -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 extends BaseStateFragment protected InfoListAdapter infoListAdapter; protected RecyclerView itemsList; + private int focusedPosition = -1; /*////////////////////////////////////////////////////////////////////////// // LifeCycle @@ -129,20 +130,53 @@ public abstract class BaseListFragment extends BaseStateFragment 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 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 savedObjects) throws Exception { - if (useDefaultStateSaving) { - infoListAdapter.getItemsList().clear(); - infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + if (!useDefaultStateSaving) { + return; } + + infoListAdapter.getItemsList().clear(); + infoListAdapter.getItemsList().addAll((List) 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 extends BaseStateFragment } } + @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 extends BaseStateFragment } protected RecyclerView.LayoutManager getListLayoutManager() { - return new LinearLayoutManager(activity); + return new SuperScrollLayoutManager(activity); } protected RecyclerView.LayoutManager getGridLayoutManager() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index ce379124d..aed7c4795 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -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 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 }); } + 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); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 2b6accbc7..71a530b39 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -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 { - 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); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index ecae13290..1b8d0ccf6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -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(); diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 6656ec29e..d2b1dbd59 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -1,5 +1,6 @@ /* * Copyright 2017 Mauricio Colli + * Copyright 2019 Eltex ltd * 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; } diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index 7e74a6ee2..5fb94e6c5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -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 + "]"); diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index af89d3f3d..23e813c4b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -355,8 +355,8 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private Observable getEdgeIntervalSignal() { - return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) + return Observable.interval(progressUpdateIntervalMillis, + TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) .filter(ignored -> playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis)); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java index ffee3e693..a9531693c 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java @@ -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); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index ce5d026db..46691aa4a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -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 diff --git a/app/src/main/java/org/schabi/newpipe/util/AndroidTvUtils.java b/app/src/main/java/org/schabi/newpipe/util/AndroidTvUtils.java new file mode 100644 index 000000000..f47eb7107 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/AndroidTvUtils.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/FireTvUtils.java b/app/src/main/java/org/schabi/newpipe/util/FireTvUtils.java deleted file mode 100644 index 76634bf8a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FireTvUtils.java +++ /dev/null @@ -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"); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java index 6530243ef..9ba6ed36c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java @@ -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; diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java new file mode 100644 index 000000000..1ffb7d069 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) Eltex ltd 2019 + * 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 . + */ +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); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java new file mode 100644 index 000000000..0da42fab6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) Eltex ltd 2019 + * 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 . + */ +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 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); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java new file mode 100644 index 000000000..179e32e4c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) Eltex ltd 2019 + * 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 . + */ +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); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java new file mode 100644 index 000000000..1c868f66a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java @@ -0,0 +1,293 @@ +/* + * Copyright 2019 Alexander Rvachev + * 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 . + */ +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 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); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java b/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java new file mode 100644 index 000000000..3a3384b51 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java @@ -0,0 +1,303 @@ +/* + * Copyright 2019 Alexander Rvachev + * 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 . + */ +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; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java new file mode 100644 index 000000000..655b86818 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) Eltex ltd 2019 + * 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 . + */ +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; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java new file mode 100644 index 000000000..6c4d20603 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) Eltex ltd 2019 + * 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 . + */ +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 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); + } +} diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml index 1499eec36..8dab200e8 100644 --- a/app/src/main/res/layout-large-land/activity_main_player.xml +++ b/app/src/main/res/layout-large-land/activity_main_player.xml @@ -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"/> - - @@ -270,8 +272,9 @@ tools:ignore="RtlHardcoded" tools:visibility="visible"> - - @@ -431,7 +437,7 @@ tools:text="1:06:29"/> - - @@ -246,6 +248,7 @@ android:background="?attr/selectableItemBackground" android:gravity="center_vertical" android:orientation="horizontal" + android:focusable="true" android:padding="6dp"> @@ -466,6 +471,8 @@ android:layout_marginTop="5dp" android:orientation="vertical" android:visibility="gone" + android:focusable="true" + android:descendantFocusability="afterDescendants" tools:visibility="visible"> - + - - + diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml index a2f447cdf..3cd3b2333 100644 --- a/app/src/main/res/layout/drawer_header.xml +++ b/app/src/main/res/layout/drawer_header.xml @@ -1,11 +1,12 @@ - + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml index 0ee62c05d..4ced11d35 100644 --- a/app/src/main/res/layout/fragment_comments.xml +++ b/app/src/main/res/layout/fragment_comments.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - diff --git a/app/src/main/res/layout/list_comments_item.xml b/app/src/main/res/layout/list_comments_item.xml index 393d7d1b4..41606201f 100644 --- a/app/src/main/res/layout/list_comments_item.xml +++ b/app/src/main/res/layout/list_comments_item.xml @@ -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" /> diff --git a/app/src/main/res/layout/toolbar_search_layout.xml b/app/src/main/res/layout/toolbar_search_layout.xml index b369cc282..85d915421 100644 --- a/app/src/main/res/layout/toolbar_search_layout.xml +++ b/app/src/main/res/layout/toolbar_search_layout.xml @@ -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" diff --git a/app/src/main/res/mipmap-xhdpi/newpipe_tv_banner.png b/app/src/main/res/mipmap-xhdpi/newpipe_tv_banner.png new file mode 100644 index 000000000..4be664450 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/newpipe_tv_banner.png differ