diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f9201f94851..e74a5a76116 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,7 +15,7 @@ Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe. To make it ### Checklist - + - [x] I am using the latest version - x.xx.x - [ ] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index c4d378d14c1..361c8057fab 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -11,7 +11,7 @@ assignees: '' ### Checklist - + - [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. - [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3022d93f49..2787e2238b5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,7 +22,8 @@ #### APK testing -debug.zip + +On the website the APK can be found by going to the "Checks" tab below the title and then on "artifacts" on the right. #### Due diligence - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6419c65dd52..1f8960bdc4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ jobs: steps: - uses: actions/checkout@v2 + - name: create and checkout branch + # push events already checked out the branch + if: github.event_name == 'pull_request' + run: git checkout -B ${{ github.head_ref }} + - name: set up JDK 1.8 uses: actions/setup-java@v1.4.3 with: diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000000..c101f385109 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,149 @@ +
+スクリーンショット • 説明 • 機能 • インストールと更新 • 貢献 • 寄付 • ライセンス
+ ++ | + | 16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh | +
+ | + | + |
+ | + | + |
Screenshots • Description • Features • Updates • Contribution • Donate • License
+Screenshots • Description • Features • Installation and updates • Contribution • Donate • License
Screenshots • Descrição • Características • Atualizações • Contribuição • Doar • Licença
+ ++ | + | 16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh | +
+ | + | + |
+ | + | + |
Capturi de ecran • Descriere • Funcţii • Instalare şi actualizări • Contribuţie • Donaţi • Licenţă
+ ++ | + | 16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh | +
+ | + | + |
+ | + | + |
Sawir-shaashadeed • Faahfaahin • Waxqabadka • Kushubida iyo cusboonaysiinta • Kusoo Kordhin • Ugu Deeq • Laysinka
+Website-ka • Maqaalada • Su'aalaha Aalaa La-iswaydiiyo • Warbaahinta
++ | + | 16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh | +
+ | + | + |
+ | + | + |
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR + DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS + AGREEMENT.
+ +1. DEFINITIONS
+ +"Contribution" means:
+ +a) in the case of the initial Contributor, the initial + code and documentation distributed under this Agreement, and
+b) in the case of each subsequent Contributor:
+i) changes to the Program, and
+ii) additions to the Program;
+where such changes and/or additions to the Program + originate from and are distributed by that particular Contributor. A + Contribution 'originates' from a Contributor if it was added to the + Program by such Contributor itself or anyone acting on such + Contributor's behalf. Contributions do not include additions to the + Program which: (i) are separate modules of software distributed in + conjunction with the Program under their own license agreement, and (ii) + are not derivative works of the Program.
+ +"Contributor" means any person or entity that distributes + the Program.
+ +"Licensed Patents" mean patent claims licensable by a + Contributor which are necessarily infringed by the use or sale of its + Contribution alone or when combined with the Program.
+ +"Program" means the Contributions distributed in accordance + with this Agreement.
+ +"Recipient" means anyone who receives the Program under + this Agreement, including all Contributors.
+ +2. GRANT OF RIGHTS
+ +a) Subject to the terms of this Agreement, each + Contributor hereby grants Recipient a non-exclusive, worldwide, + royalty-free copyright license to reproduce, prepare derivative works + of, publicly display, publicly perform, distribute and sublicense the + Contribution of such Contributor, if any, and such derivative works, in + source code and object code form.
+ +b) Subject to the terms of this Agreement, each + Contributor hereby grants Recipient a non-exclusive, worldwide, + royalty-free patent license under Licensed Patents to make, use, sell, + offer to sell, import and otherwise transfer the Contribution of such + Contributor, if any, in source code and object code form. This patent + license shall apply to the combination of the Contribution and the + Program if, at the time the Contribution is added by the Contributor, + such addition of the Contribution causes such combination to be covered + by the Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder.
+ +c) Recipient understands that although each Contributor + grants the licenses to its Contributions set forth herein, no assurances + are provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. Each + Contributor disclaims any liability to Recipient for claims brought by + any other entity based on infringement of intellectual property rights + or otherwise. As a condition to exercising the rights and licenses + granted hereunder, each Recipient hereby assumes sole responsibility to + secure any other intellectual property rights needed, if any. For + example, if a third party patent license is required to allow Recipient + to distribute the Program, it is Recipient's responsibility to acquire + that license before distributing the Program.
+ +d) Each Contributor represents that to its knowledge it + has sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement.
+ +3. REQUIREMENTS
+ +A Contributor may choose to distribute the Program in object code + form under its own license agreement, provided that:
+ +a) it complies with the terms and conditions of this + Agreement; and
+ +b) its license agreement:
+ +i) effectively disclaims on behalf of all Contributors + all warranties and conditions, express and implied, including warranties + or conditions of title and non-infringement, and implied warranties or + conditions of merchantability and fitness for a particular purpose;
+ +ii) effectively excludes on behalf of all Contributors + all liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits;
+ +iii) states that any provisions which differ from this + Agreement are offered by that Contributor alone and not by any other + party; and
+ +iv) states that source code for the Program is available + from such Contributor, and informs licensees how to obtain it in a + reasonable manner on or through a medium customarily used for software + exchange.
+ +When the Program is made available in source code form:
+ +a) it must be made available under this Agreement; and
+ +b) a copy of this Agreement must be included with each + copy of the Program.
+ +Contributors may not remove or alter any copyright notices contained + within the Program.
+ +Each Contributor must identify itself as the originator of its + Contribution, if any, in a manner that reasonably allows subsequent + Recipients to identify the originator of the Contribution.
+ +4. COMMERCIAL DISTRIBUTION
+ +Commercial distributors of software may accept certain + responsibilities with respect to end users, business partners and the + like. While this license is intended to facilitate the commercial use of + the Program, the Contributor who includes the Program in a commercial + product offering should do so in a manner which does not create + potential liability for other Contributors. Therefore, if a Contributor + includes the Program in a commercial product offering, such Contributor + ("Commercial Contributor") hereby agrees to defend and + indemnify every other Contributor ("Indemnified Contributor") + against any losses, damages and costs (collectively "Losses") + arising from claims, lawsuits and other legal actions brought by a third + party against the Indemnified Contributor to the extent caused by the + acts or omissions of such Commercial Contributor in connection with its + distribution of the Program in a commercial product offering. The + obligations in this section do not apply to any claims or Losses + relating to any actual or alleged intellectual property infringement. In + order to qualify, an Indemnified Contributor must: a) promptly notify + the Commercial Contributor in writing of such claim, and b) allow the + Commercial Contributor to control, and cooperate with the Commercial + Contributor in, the defense and any related settlement negotiations. The + Indemnified Contributor may participate in any such claim at its own + expense.
+ +For example, a Contributor might include the Program in a commercial + product offering, Product X. That Contributor is then a Commercial + Contributor. If that Commercial Contributor then makes performance + claims, or offers warranties related to Product X, those performance + claims and warranties are such Commercial Contributor's responsibility + alone. Under this section, the Commercial Contributor would have to + defend claims against the other Contributors related to those + performance claims and warranties, and if a court requires any other + Contributor to pay any damages as a result, the Commercial Contributor + must pay those damages.
+ +5. NO WARRANTY
+ +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS + PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, + ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY + OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely + responsible for determining the appropriateness of using and + distributing the Program and assumes all risks associated with its + exercise of rights under this Agreement , including but not limited to + the risks and costs of program errors, compliance with applicable laws, + damage to or loss of data, programs or equipment, and unavailability or + interruption of operations.
+ +6. DISCLAIMER OF LIABILITY
+ +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT + NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING + WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR + DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED + HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+ +7. GENERAL
+ +If any provision of this Agreement is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this Agreement, and without further action + by the parties hereto, such provision shall be reformed to the minimum + extent necessary to make such provision valid and enforceable.
+ +If Recipient institutes patent litigation against any entity + (including a cross-claim or counterclaim in a lawsuit) alleging that the + Program itself (excluding combinations of the Program with other + software or hardware) infringes such Recipient's patent(s), then such + Recipient's rights granted under Section 2(b) shall terminate as of the + date such litigation is filed.
+ +All Recipient's rights under this Agreement shall terminate if it + fails to comply with any of the material terms or conditions of this + Agreement and does not cure such failure in a reasonable period of time + after becoming aware of such noncompliance. If all Recipient's rights + under this Agreement terminate, Recipient agrees to cease use and + distribution of the Program as soon as reasonably practicable. However, + Recipient's obligations under this Agreement and any licenses granted by + Recipient relating to the Program shall continue and survive.
+ +Everyone is permitted to copy and distribute copies of this + Agreement, but in order to avoid inconsistency the Agreement is + copyrighted and may only be modified in the following manner. The + Agreement Steward reserves the right to publish new versions (including + revisions) of this Agreement from time to time. No one other than the + Agreement Steward has the right to modify this Agreement. The Eclipse + Foundation is the initial Agreement Steward. The Eclipse Foundation may + assign the responsibility to serve as the Agreement Steward to a + suitable separate entity. Each new version of the Agreement will be + given a distinguishing version number. The Program (including + Contributions) may always be distributed subject to the version of the + Agreement under which it was received. In addition, after a new version + of the Agreement is published, Contributor may elect to distribute the + Program (including its Contributions) under the new version. Except as + expressly stated in Sections 2(a) and 2(b) above, Recipient receives no + rights or licenses to the intellectual property of any Contributor under + this Agreement, whether expressly, by implication, estoppel or + otherwise. All rights in the Program not expressly granted under this + Agreement are reserved.
+ +This Agreement is governed by the laws of the State of New York and + the intellectual property laws of the United States of America. No party + to this Agreement will bring a legal action under this Agreement more + than one year after the cause of action arose. Each party waives its + rights to a jury trial in any resulting litigation.
+ + + + \ No newline at end of file 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 3518aa139cf..743ff1ff20e 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 @@ -10,6 +10,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; + import org.schabi.newpipe.R; import java.lang.reflect.Field; @@ -27,7 +28,7 @@ public FlingBehavior(final Context context, final AttributeSet attrs) { private boolean allowScroll = true; private final Rect globalRect = new Rect(); private final ListThere are multiple types of errors:
- *+ * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed + * and {@code true} is returned to represent this change. + *
+ */ + public void checkPopupPositionBounds() { + if (DEBUG) { + Log.d(TAG, "checkPopupPositionBounds() called with: " + + "screenWidth = [" + screenWidth + "], " + + "screenHeight = [" + screenHeight + "]"); + } + if (popupLayoutParams == null) { + return; + } + + if (popupLayoutParams.x < 0) { + popupLayoutParams.x = 0; + } else if (popupLayoutParams.x > screenWidth - popupLayoutParams.width) { + popupLayoutParams.x = (int) (screenWidth - popupLayoutParams.width); + } + + if (popupLayoutParams.y < 0) { + popupLayoutParams.y = 0; + } else if (popupLayoutParams.y > screenHeight - popupLayoutParams.height) { + popupLayoutParams.y = (int) (screenHeight - popupLayoutParams.height); + } + } + + public void updateScreenSize() { + if (windowManager != null) { + final DisplayMetrics metrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(metrics); + + screenWidth = metrics.widthPixels; + screenHeight = metrics.heightPixels; + if (DEBUG) { + Log.d(TAG, "updateScreenSize() called: screenWidth = [" + + screenWidth + "], screenHeight = [" + screenHeight + "]"); + } + } + } + + /** + * Changes the size of the popup based on the width. + * @param width the new width, height is calculated with + * {@link PlayerHelper#getMinimumVideoHeight(float)} + */ + public void changePopupSize(final int width) { + if (DEBUG) { + Log.d(TAG, "changePopupSize() called with: width = [" + width + "]"); + } + + if (anyPopupViewIsNull()) { + return; + } + + final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width); + final int actualWidth = (int) (width > screenWidth ? screenWidth + : (width < minimumWidth ? minimumWidth : width)); + final int actualHeight = (int) getMinimumVideoHeight(width); + if (DEBUG) { + Log.d(TAG, "updatePopupSize() updated values:" + + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); + } + + popupLayoutParams.width = actualWidth; + popupLayoutParams.height = actualHeight; + binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); + Objects.requireNonNull(windowManager) + .updateViewLayout(binding.getRoot(), popupLayoutParams); + } + + private void changePopupWindowFlags(final int flags) { + if (DEBUG) { + Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]"); + } + + if (!anyPopupViewIsNull()) { + popupLayoutParams.flags = flags; + Objects.requireNonNull(windowManager) + .updateViewLayout(binding.getRoot(), popupLayoutParams); + } + } + + public void closePopup() { + if (DEBUG) { + Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); + } + if (isPopupClosing) { + return; + } + isPopupClosing = true; + + saveStreamProgressState(); + Objects.requireNonNull(windowManager).removeView(binding.getRoot()); + + animatePopupOverlayAndFinishService(); + } + + public void removePopupFromView() { + if (windowManager != null) { + // wrap in try-catch since it could sometimes generate errors randomly + try { + if (popupHasParent()) { + windowManager.removeView(binding.getRoot()); + } + } catch (final IllegalArgumentException e) { + Log.w(TAG, "Failed to remove popup from window manager", e); + } + + try { + final boolean closeOverlayHasParent = closeOverlayBinding != null + && closeOverlayBinding.getRoot().getParent() != null; + if (closeOverlayHasParent) { + windowManager.removeView(closeOverlayBinding.getRoot()); + } + } catch (final IllegalArgumentException e) { + Log.w(TAG, "Failed to remove popup overlay from window manager", e); + } + } + } + + private void animatePopupOverlayAndFinishService() { + final int targetTranslationY = + (int) (closeOverlayBinding.closeButton.getRootView().getHeight() + - closeOverlayBinding.closeButton.getY()); + + closeOverlayBinding.closeButton.animate().setListener(null).cancel(); + closeOverlayBinding.closeButton.animate() + .setInterpolator(new AnticipateInterpolator()) + .translationY(targetTranslationY) + .setDuration(400) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(final Animator animation) { + end(); + } + + @Override + public void onAnimationEnd(final Animator animation) { + end(); + } + + private void end() { + Objects.requireNonNull(windowManager) + .removeView(closeOverlayBinding.getRoot()); + closeOverlayBinding = null; + service.onDestroy(); + } + }).start(); + } + + private boolean popupHasParent() { + return binding != null + && binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams + && binding.getRoot().getParent() != null; + } + + private boolean anyPopupViewIsNull() { + // TODO understand why checking getParentActivity() != null + return popupLayoutParams == null || windowManager == null + || getParentActivity() != null || binding.getRoot().getParent() == null; + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Playback parameters + //////////////////////////////////////////////////////////////////////////*/ + //region + + public float getPlaybackSpeed() { + return getPlaybackParameters().speed; + } + + private void setPlaybackSpeed(final float speed) { + setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()); + } + + public float getPlaybackPitch() { + return getPlaybackParameters().pitch; + } + + public boolean getPlaybackSkipSilence() { + return getPlaybackParameters().skipSilence; + } + + public PlaybackParameters getPlaybackParameters() { + if (exoPlayerIsNull()) { + return PlaybackParameters.DEFAULT; + } + return simpleExoPlayer.getPlaybackParameters(); + } + + /** + * Sets the playback parameters of the player, and also saves them to shared preferences. + * Speed and pitch are rounded up to 2 decimal places before being used or saved. + * + * @param speed the playback speed, will be rounded to up to 2 decimal places + * @param pitch the playback pitch, will be rounded to up to 2 decimal places + * @param skipSilence skip silence during playback + */ + public void setPlaybackParameters(final float speed, final float pitch, + final boolean skipSilence) { + final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f; + final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f; + + savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence); + simpleExoPlayer.setPlaybackParameters( + new PlaybackParameters(roundedSpeed, roundedPitch, skipSilence)); + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Progress loop and updates + //////////////////////////////////////////////////////////////////////////*/ + //region + + private void onUpdateProgress(final int currentProgress, + final int duration, + final int bufferPercent) { + if (!isPrepared) { + return; + } + + if (duration != binding.playbackSeekBar.getMax()) { + binding.playbackEndTime.setText(getTimeString(duration)); + binding.playbackSeekBar.setMax(duration); + } + if (currentState != STATE_PAUSED) { + if (currentState != STATE_PAUSED_SEEK) { + binding.playbackSeekBar.setProgress(currentProgress); + } + binding.playbackCurrentTime.setText(getTimeString(currentProgress)); + } + if (simpleExoPlayer.isLoading() || bufferPercent > 90) { + binding.playbackSeekBar.setSecondaryProgress( + (int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100))); + } + if (DEBUG && bufferPercent % 20 == 0) { //Limit log + Log.d(TAG, "notifyProgressUpdateToListeners() called with: " + + "isVisible = " + isControlsVisible() + ", " + + "currentProgress = [" + currentProgress + "], " + + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); + } + binding.playbackLiveSync.setClickable(!isLiveEdge()); + + notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); + + if (areSegmentsVisible) { + segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); + } + + final boolean showThumbnail = prefs.getBoolean( + context.getString(R.string.show_thumbnail_key), true); + // setMetadata only updates the metadata when any of the metadata keys are null + mediaSessionManager.setMetadata(getVideoTitle(), getUploaderName(), + showThumbnail ? getThumbnail() : null, duration); + } + + private void startProgressLoop() { + progressUpdateDisposable.set(getProgressUpdateDisposable()); + } + + private void stopProgressLoop() { + progressUpdateDisposable.set(null); + } + + private boolean isProgressLoopRunning() { + return progressUpdateDisposable.get() != null; + } + + private void triggerProgressUpdate() { + if (exoPlayerIsNull()) { + return; + } + onUpdateProgress( + Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage() + ); + } + + private Disposable getProgressUpdateDisposable() { + return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, + AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> triggerProgressUpdate(), + error -> Log.e(TAG, "Progress update failure: ", error)); + } + + @Override // seekbar listener + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + if (DEBUG && fromUser) { + Log.d(TAG, "onProgressChanged() called with: " + + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); + } + if (fromUser) { + binding.currentDisplaySeek.setText(getTimeString(progress)); + } + } + + @Override // seekbar listener + public void onStartTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + if (currentState != STATE_PAUSED_SEEK) { + changeState(STATE_PAUSED_SEEK); + } + + saveWasPlaying(); + if (isPlaying()) { + simpleExoPlayer.setPlayWhenReady(false); + } + + showControls(0); + animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SCALE_AND_ALPHA); + } + + @Override // seekbar listener + public void onStopTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + + seekTo(seekBar.getProgress()); + if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) { + simpleExoPlayer.setPlayWhenReady(true); + } + + binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + + if (currentState == STATE_PAUSED_SEEK) { + changeState(STATE_BUFFERING); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + if (wasPlaying) { + showControlsThenHide(); + } + } + + public void saveWasPlaying() { + this.wasPlaying = simpleExoPlayer.getPlayWhenReady(); + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Controls showing / hiding + //////////////////////////////////////////////////////////////////////////*/ + //region + + public boolean isControlsVisible() { + return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; + } + + /** + * Show a animation, and depending on goneOnEnd, will stay on the screen or be gone. + * + * @param drawableId the drawable that will be used to animate, + * pass -1 to clear any animation that is visible + * @param goneOnEnd will set the animation view to GONE on the end of the animation + */ + public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) { + if (DEBUG) { + Log.d(TAG, "showAndAnimateControl() called with: " + + "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]"); + } + if (controlViewAnimator != null && controlViewAnimator.isRunning()) { + if (DEBUG) { + Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning"); + } + controlViewAnimator.end(); + } + + if (drawableId == -1) { + if (binding.controlAnimationView.getVisibility() == View.VISIBLE) { + controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder( + binding.controlAnimationView, + PropertyValuesHolder.ofFloat(View.ALPHA, 1.0f, 0.0f), + PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1.0f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1.0f) + ).setDuration(DEFAULT_CONTROLS_DURATION); + controlViewAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + binding.controlAnimationView.setVisibility(View.GONE); + } + }); + controlViewAnimator.start(); + } + return; + } + + final float scaleFrom = goneOnEnd ? 1f : 1f; + final float scaleTo = goneOnEnd ? 1.8f : 1.4f; + final float alphaFrom = goneOnEnd ? 1f : 0f; + final float alphaTo = goneOnEnd ? 0f : 1f; + + + controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder( + binding.controlAnimationView, + PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo), + PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo), + PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo) + ); + controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500); + controlViewAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + binding.controlAnimationView.setVisibility(goneOnEnd ? View.GONE : View.VISIBLE); + } + }); + + + binding.controlAnimationView.setVisibility(View.VISIBLE); + binding.controlAnimationView.setImageDrawable( + AppCompatResources.getDrawable(context, drawableId)); + controlViewAnimator.start(); + } + + public void showControlsThenHide() { + if (DEBUG) { + Log.d(TAG, "showControlsThenHide() called"); + } + showOrHideButtons(); + showSystemUIPartially(); + + final int hideTime = binding.playbackControlRoot.isInTouchMode() + ? DEFAULT_CONTROLS_HIDE_TIME + : DPAD_CONTROLS_HIDE_TIME; + + showHideShadow(true, DEFAULT_CONTROLS_DURATION); + animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); + } + + public void showControls(final long duration) { + if (DEBUG) { + Log.d(TAG, "showControls() called"); + } + showOrHideButtons(); + showSystemUIPartially(); + controlsVisibilityHandler.removeCallbacksAndMessages(null); + showHideShadow(true, duration); + animate(binding.playbackControlRoot, true, duration); + } + + public void hideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "hideControls() called with: duration = [" + duration + + "], delay = [" + delay + "]"); + } + + showOrHideButtons(); + + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler.postDelayed(() -> { + showHideShadow(false, duration); + animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA, + 0, this::hideSystemUIIfNeeded); + }, delay); + } + + private void showHideShadow(final boolean show, final long duration) { + animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null); + animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null); + } + + private void showOrHideButtons() { + if (playQueue == null) { + return; + } + + final boolean showPrev = playQueue.getIndex() != 0; + final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); + final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected(); + boolean showSegment = false; + if (currentMetadata != null) { + showSegment = !currentMetadata.getMetadata().getStreamSegments().isEmpty() + && !popupPlayerSelected(); + } + + binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); + binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); + binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE); + binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); + binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); + binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); + binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); + binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); + } + + private void showSystemUIPartially() { + final AppCompatActivity activity = getParentActivity(); + if (isFullscreen && activity != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + activity.getWindow().setStatusBarColor(Color.TRANSPARENT); + activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); + } + final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + activity.getWindow().getDecorView().setSystemUiVisibility(visibility); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + } + + private void hideSystemUIIfNeeded() { + if (fragmentListener != null) { + fragmentListener.hideSystemUiIfNeeded(); + } + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Playback states + //////////////////////////////////////////////////////////////////////////*/ + //region + + @Override // exoplayer listener + public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "playbackState = [" + playbackState + "]"); + } + + if (currentState == STATE_PAUSED_SEEK) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); + } + return; + } + + switch (playbackState) { + case com.google.android.exoplayer2.Player.STATE_IDLE: // 1 + isPrepared = false; + break; + case com.google.android.exoplayer2.Player.STATE_BUFFERING: // 2 + if (isPrepared) { + changeState(STATE_BUFFERING); + } + break; + case com.google.android.exoplayer2.Player.STATE_READY: //3 + maybeUpdateCurrentMetadata(); + maybeCorrectSeekPosition(); + if (!isPrepared) { + isPrepared = true; + onPrepared(playWhenReady); + } + changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + break; + case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 + changeState(STATE_COMPLETED); + if (currentMetadata != null) { + resetStreamProgressState(currentMetadata.getMetadata()); + } + isPrepared = false; + break; + } + } + + @Override // exoplayer listener + public void onLoadingChanged(final boolean isLoading) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + + "isLoading = [" + isLoading + "]"); + } + + if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) { + stopProgressLoop(); + } else if (isLoading && !isProgressLoopRunning()) { + startProgressLoop(); + } + + maybeUpdateCurrentMetadata(); + } + + @Override // own playback listener + public void onPlaybackBlock() { + if (exoPlayerIsNull()) { + return; + } + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackBlock() called"); + } + + currentItem = null; + currentMetadata = null; + simpleExoPlayer.stop(); + isPrepared = false; + + changeState(STATE_BLOCKED); + } + + @Override // own playback listener + public void onPlaybackUnblock(final MediaSource mediaSource) { + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackUnblock() called"); + } + + if (exoPlayerIsNull()) { + return; + } + if (currentState == STATE_BLOCKED) { + changeState(STATE_BUFFERING); + } + simpleExoPlayer.prepare(mediaSource); + } + + public void changeState(final int state) { + if (DEBUG) { + Log.d(TAG, "changeState() called with: state = [" + state + "]"); + } + currentState = state; + switch (state) { + case STATE_BLOCKED: + onBlocked(); + break; + case STATE_PLAYING: + onPlaying(); + break; + case STATE_BUFFERING: + onBuffering(); + break; + case STATE_PAUSED: + onPaused(); + break; + case STATE_PAUSED_SEEK: + onPausedSeek(); + break; + case STATE_COMPLETED: + onCompleted(); + break; + } + notifyPlaybackUpdateToListeners(); + } + + private void onPrepared(final boolean playWhenReady) { + if (DEBUG) { + Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); + } + + binding.playbackSeekBar.setMax((int) simpleExoPlayer.getDuration()); + binding.playbackEndTime.setText(getTimeString((int) simpleExoPlayer.getDuration())); + binding.playbackSpeed.setText(formatSpeed(getPlaybackSpeed())); + + if (playWhenReady) { + audioReactor.requestAudioFocus(); + } + } + + private void onBlocked() { + if (DEBUG) { + Log.d(TAG, "onBlocked() called"); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + + controlsVisibilityHandler.removeCallbacksAndMessages(null); + animate(binding.playbackControlRoot, false, DEFAULT_CONTROLS_DURATION); + + binding.playbackSeekBar.setEnabled(false); + binding.playbackSeekBar.getThumb() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); + + binding.loadingPanel.setBackgroundColor(Color.BLACK); + animate(binding.loadingPanel, true, 0); + animate(binding.surfaceForeground, true, 100); + + binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + animatePlayButtons(false, 100); + binding.getRoot().setKeepScreenOn(false); + + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + + private void onPlaying() { + if (DEBUG) { + Log.d(TAG, "onPlaying() called"); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + + updateStreamRelatedViews(); + + showAndAnimateControl(-1, true); + + binding.playbackSeekBar.setEnabled(true); + binding.playbackSeekBar.getThumb() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); + + binding.loadingPanel.setVisibility(View.GONE); + + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + + animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, + () -> { + binding.playPauseButton.setImageResource(R.drawable.ic_pause_white_24dp); + animatePlayButtons(true, 200); + if (!isQueueVisible) { + binding.playPauseButton.requestFocus(); + } + }); + + changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); + checkLandscape(); + binding.getRoot().setKeepScreenOn(true); + + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + + private void onBuffering() { + if (DEBUG) { + Log.d(TAG, "onBuffering() called"); + } + binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); + + binding.getRoot().setKeepScreenOn(true); + + if (NotificationUtil.getInstance().shouldUpdateBufferingSlot()) { + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + } + + private void onPaused() { + if (DEBUG) { + Log.d(TAG, "onPaused() called"); + } + + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + + showControls(400); + binding.loadingPanel.setVisibility(View.GONE); + + animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, + () -> { + binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + animatePlayButtons(true, 200); + if (!isQueueVisible) { + binding.playPauseButton.requestFocus(); + } + }); + + changePopupWindowFlags(IDLE_WINDOW_FLAGS); + + // Remove running notification when user does not want minimization to background or popup + if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE + && videoPlayerSelected()) { + NotificationUtil.getInstance().cancelNotificationAndStopForeground(service); + } else { + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + + binding.getRoot().setKeepScreenOn(false); + } + + private void onPausedSeek() { + if (DEBUG) { + Log.d(TAG, "onPausedSeek() called"); + } + showAndAnimateControl(-1, true); + + animatePlayButtons(false, 100); + binding.getRoot().setKeepScreenOn(true); + + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + + private void onCompleted() { + if (DEBUG) { + Log.d(TAG, "onCompleted() called"); + } + + animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, + () -> { + binding.playPauseButton.setImageResource(R.drawable.ic_replay_white_24dp); + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); + }); + + binding.getRoot().setKeepScreenOn(false); + changePopupWindowFlags(IDLE_WINDOW_FLAGS); + + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + if (isFullscreen) { + toggleFullscreen(); + } + + if (playQueue.getIndex() < playQueue.size() - 1) { + playQueue.offsetIndex(+1); + } + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + + showControls(500); + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + binding.loadingPanel.setVisibility(View.GONE); + animate(binding.surfaceForeground, true, 100); + } + + private void animatePlayButtons(final boolean show, final int duration) { + animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA); + + boolean showQueueButtons = show; + if (playQueue == null) { + showQueueButtons = false; + } + + if (!showQueueButtons || playQueue.getIndex() > 0) { + animate( + binding.playPreviousButton, + showQueueButtons, + duration, + AnimationType.SCALE_AND_ALPHA); + } + if (!showQueueButtons || playQueue.getIndex() + 1 < playQueue.getStreams().size()) { + animate( + binding.playNextButton, + showQueueButtons, + duration, + AnimationType.SCALE_AND_ALPHA); + } + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Repeat and shuffle + //////////////////////////////////////////////////////////////////////////*/ + //region + + public void onRepeatClicked() { + if (DEBUG) { + Log.d(TAG, "onRepeatClicked() called"); + } + setRepeatMode(nextRepeatMode(getRepeatMode())); + } + + public void onShuffleClicked() { + if (DEBUG) { + Log.d(TAG, "onShuffleClicked() called"); + } + + if (exoPlayerIsNull()) { + return; + } + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); + } + + @RepeatMode + public int getRepeatMode() { + return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); + } + + private void setRepeatMode(@RepeatMode final int repeatMode) { + if (!exoPlayerIsNull()) { + simpleExoPlayer.setRepeatMode(repeatMode); + } + } + + @Override + public void onRepeatModeChanged(@RepeatMode final int repeatMode) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + + "repeatMode = [" + repeatMode + "]"); + } + setRepeatModeButton(binding.repeatButton, repeatMode); + onShuffleOrRepeatModeChanged(); + } + + @Override + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + + "mode = [" + shuffleModeEnabled + "]"); + } + + if (playQueue != null) { + if (shuffleModeEnabled) { + playQueue.shuffle(); + } else { + playQueue.unshuffle(); + } + } + + setShuffleButton(binding.shuffleButton, shuffleModeEnabled); + onShuffleOrRepeatModeChanged(); + } + + private void onShuffleOrRepeatModeChanged() { + notifyPlaybackUpdateToListeners(); + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + } + + private void setRepeatModeButton(final ImageButton imageButton, final int repeatMode) { + switch (repeatMode) { + case REPEAT_MODE_OFF: + imageButton.setImageResource(R.drawable.exo_controls_repeat_off); + break; + case REPEAT_MODE_ONE: + imageButton.setImageResource(R.drawable.exo_controls_repeat_one); + break; + case REPEAT_MODE_ALL: + imageButton.setImageResource(R.drawable.exo_controls_repeat_all); + break; + } + } + + private void setShuffleButton(final ImageButton button, final boolean shuffled) { + button.setImageAlpha(shuffled ? 255 : 77); + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Mute / Unmute + //////////////////////////////////////////////////////////////////////////*/ + //region + + public void onMuteUnmuteButtonClicked() { + if (DEBUG) { + Log.d(TAG, "onMuteUnmuteButtonClicked() called"); + } + simpleExoPlayer.setVolume(isMuted() ? 1 : 0); + notifyPlaybackUpdateToListeners(); + setMuteButton(binding.switchMute, isMuted()); + } + + boolean isMuted() { + return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0; + } + + private void setMuteButton(final ImageButton button, final boolean isMuted) { + button.setImageDrawable(AppCompatResources.getDrawable(context, isMuted + ? R.drawable.ic_volume_off_white_24dp : R.drawable.ic_volume_up_white_24dp)); + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer listeners (that didn't fit in other categories) + //////////////////////////////////////////////////////////////////////////*/ + //region + + @Override + public void onTimelineChanged(@NonNull final Timeline timeline, final int reason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " + + "timeline size = [" + timeline.getWindowCount() + "], " + + "reason = [" + reason + "]"); + } + + maybeUpdateCurrentMetadata(); + // force recreate notification to ensure seek bar is shown when preparation finishes + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); + } + + @Override + public void onTracksChanged(@NonNull final TrackGroupArray trackGroups, + @NonNull final TrackSelectionArray trackSelections) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onTracksChanged(), " + + "track group size = " + trackGroups.length); + } + maybeUpdateCurrentMetadata(); + onTextTracksChanged(); + } + + @Override + public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed + + "], pitch = [" + playbackParameters.pitch + "]"); + } + binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed)); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason final int discontinuityReason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + + "discontinuityReason = [" + discontinuityReason + "]"); + } + if (playQueue == null) { + return; + } + + // Refresh the playback if there is a transition to the next video + final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + switch (discontinuityReason) { + case DISCONTINUITY_REASON_PERIOD_TRANSITION: + // When player is in single repeat mode and a period transition occurs, + // we need to register a view count here since no metadata has changed + if (getRepeatMode() == REPEAT_MODE_ONE && newWindowIndex == playQueue.getIndex()) { + registerStreamViewed(); + break; + } + case DISCONTINUITY_REASON_SEEK: + case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + case DISCONTINUITY_REASON_INTERNAL: + if (playQueue.getIndex() != newWindowIndex) { + resetStreamProgressState(playQueue.getItem()); + playQueue.setIndex(newWindowIndex); + } + break; + case DISCONTINUITY_REASON_AD_INSERTION: + break; // only makes Android Studio linter happy, as there are no ads + } + + maybeUpdateCurrentMetadata(); + } + + @Override + public void onRenderedFirstFrame() { + //TODO check if this causes black screen when switching to fullscreen + animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION); + } + //endregion + + + + /*////////////////////////////////////////////////////////////////////////// + // Errors + //////////////////////////////////////////////////////////////////////////*/ + //region + /** + * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. + *There are multiple types of errors:
+ *- * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed - * and {@code true} is returned to represent this change. - *
- * - * @param boundaryWidth width of the boundary - * @param boundaryHeight height of the boundary - * @return if the popup was out of bounds and have been moved back to it - */ - public boolean checkPopupPositionBounds(final float boundaryWidth, final float boundaryHeight) { - if (DEBUG) { - Log.d(TAG, "checkPopupPositionBounds() called with: " - + "boundaryWidth = [" + boundaryWidth + "], " - + "boundaryHeight = [" + boundaryHeight + "]"); - } - - if (popupLayoutParams.x < 0) { - popupLayoutParams.x = 0; - return true; - } else if (popupLayoutParams.x > boundaryWidth - popupLayoutParams.width) { - popupLayoutParams.x = (int) (boundaryWidth - popupLayoutParams.width); - return true; - } - - if (popupLayoutParams.y < 0) { - popupLayoutParams.y = 0; - return true; - } else if (popupLayoutParams.y > boundaryHeight - popupLayoutParams.height) { - popupLayoutParams.y = (int) (boundaryHeight - popupLayoutParams.height); - return true; - } - - return false; - } - - public void savePositionAndSize() { - final SharedPreferences sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(service); - sharedPreferences.edit().putInt(POPUP_SAVED_X, popupLayoutParams.x).apply(); - sharedPreferences.edit().putInt(POPUP_SAVED_Y, popupLayoutParams.y).apply(); - sharedPreferences.edit().putFloat(POPUP_SAVED_WIDTH, popupLayoutParams.width).apply(); - } - - private float getMinimumVideoHeight(final float width) { - final float height = width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have - /*if (DEBUG) { - Log.d(TAG, "getMinimumVideoHeight() called with: width = [" - + width + "], returned: " + height); - }*/ - return height; - } - - public void updateScreenSize() { - final DisplayMetrics metrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(metrics); - - screenWidth = metrics.widthPixels; - screenHeight = metrics.heightPixels; - if (DEBUG) { - Log.d(TAG, "updateScreenSize() called > screenWidth = " - + screenWidth + ", screenHeight = " + screenHeight); - } - - popupWidth = service.getResources().getDimension(R.dimen.popup_default_width); - popupHeight = getMinimumVideoHeight(popupWidth); - - minimumWidth = service.getResources().getDimension(R.dimen.popup_minimum_width); - minimumHeight = getMinimumVideoHeight(minimumWidth); - - maximumWidth = screenWidth; - maximumHeight = screenHeight; - } - - public void updatePopupSize(final int width, final int height) { - if (DEBUG) { - Log.d(TAG, "updatePopupSize() called with: width = [" - + width + "], height = [" + height + "]"); - } - - if (popupLayoutParams == null - || windowManager == null - || getParentActivity() != null - || getRootView().getParent() == null) { - return; - } - - final int actualWidth = (int) (width > maximumWidth - ? maximumWidth : width < minimumWidth ? minimumWidth : width); - final int actualHeight; - if (height == -1) { - actualHeight = (int) getMinimumVideoHeight(width); - } else { - actualHeight = (int) (height > maximumHeight - ? maximumHeight : height < minimumHeight - ? minimumHeight : height); - } - - popupLayoutParams.width = actualWidth; - popupLayoutParams.height = actualHeight; - popupWidth = actualWidth; - popupHeight = actualHeight; - getSurfaceView().setHeights((int) popupHeight, (int) popupHeight); - - if (DEBUG) { - Log.d(TAG, "updatePopupSize() updated values:" - + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); - } - windowManager.updateViewLayout(getRootView(), popupLayoutParams); - } - - private void updateWindowFlags(final int flags) { - if (popupLayoutParams == null - || windowManager == null - || getParentActivity() != null - || getRootView().getParent() == null) { - return; - } - - popupLayoutParams.flags = flags; - windowManager.updateViewLayout(getRootView(), popupLayoutParams); - } - - private int popupLayoutParamType() { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.O - ? WindowManager.LayoutParams.TYPE_PHONE - : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; - } - - /*////////////////////////////////////////////////////////////////////////// - // Misc - //////////////////////////////////////////////////////////////////////////*/ - - public void closePopup() { - if (DEBUG) { - Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); - } - if (isPopupClosing) { - return; - } - isPopupClosing = true; - - savePlaybackState(); - windowManager.removeView(getRootView()); - - animateOverlayAndFinishService(); - } - - public void removePopupFromView() { - final boolean isCloseOverlayHasParent = closeOverlayView != null - && closeOverlayView.getParent() != null; - if (popupHasParent()) { - windowManager.removeView(getRootView()); - } - if (isCloseOverlayHasParent) { - windowManager.removeView(closeOverlayView); - } - } - - private void animateOverlayAndFinishService() { - final int targetTranslationY = (int) (closeOverlayButton.getRootView().getHeight() - - closeOverlayButton.getY()); - - closeOverlayButton.animate().setListener(null).cancel(); - closeOverlayButton.animate() - .setInterpolator(new AnticipateInterpolator()) - .translationY(targetTranslationY) - .setDuration(400) - .setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationCancel(final Animator animation) { - end(); - } - - @Override - public void onAnimationEnd(final Animator animation) { - end(); - } - - private void end() { - windowManager.removeView(closeOverlayView); - closeOverlayView = null; - - service.onDestroy(); - } - }).start(); - } - - private boolean popupHasParent() { - final View root = getRootView(); - return root != null - && root.getLayoutParams() instanceof WindowManager.LayoutParams - && root.getParent() != null; - } - - /////////////////////////////////////////////////////////////////////////// - // Manipulations with listener - /////////////////////////////////////////////////////////////////////////// - - public void setFragmentListener(final PlayerServiceEventListener listener) { - fragmentListener = listener; - fragmentIsVisible = true; - // Apply window insets because Android will not do it when orientation changes - // from landscape to portrait - if (!isFullscreen) { - getControlsRoot().setPadding(0, 0, 0, 0); - } - queueLayout.setPadding(0, 0, 0, 0); - updateQueue(); - updateMetadata(); - updatePlayback(); - triggerProgressUpdate(); - } - - public void removeFragmentListener(final PlayerServiceEventListener listener) { - if (fragmentListener == listener) { - fragmentListener = null; - } - } - - void setActivityListener(final PlayerEventListener listener) { - activityListener = listener; - updateMetadata(); - updatePlayback(); - triggerProgressUpdate(); - } - - void removeActivityListener(final PlayerEventListener listener) { - if (activityListener == listener) { - activityListener = null; - } - } - - private void updateQueue() { - if (fragmentListener != null && playQueue != null) { - fragmentListener.onQueueUpdate(playQueue); - } - if (activityListener != null && playQueue != null) { - activityListener.onQueueUpdate(playQueue); - } - } - - private void updateMetadata() { - if (fragmentListener != null && getCurrentMetadata() != null) { - fragmentListener.onMetadataUpdate(getCurrentMetadata().getMetadata(), playQueue); - } - if (activityListener != null && getCurrentMetadata() != null) { - activityListener.onMetadataUpdate(getCurrentMetadata().getMetadata(), playQueue); - } - } - - private void updatePlayback() { - if (fragmentListener != null && simpleExoPlayer != null && playQueue != null) { - fragmentListener.onPlaybackUpdate(currentState, getRepeatMode(), - playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); - } - if (activityListener != null && simpleExoPlayer != null && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), - playQueue.isShuffled(), getPlaybackParameters()); - } - } - - private void updateProgress(final int currentProgress, final int duration, - final int bufferPercent) { - if (fragmentListener != null) { - fragmentListener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - if (activityListener != null) { - activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - } - - void stopActivityBinding() { - if (fragmentListener != null) { - fragmentListener.onServiceStopped(); - fragmentListener = null; - } - if (activityListener != null) { - activityListener.onServiceStopped(); - activityListener = null; - } - } - - /** - * This will be called when a user goes to another app/activity, turns off a screen. - * We don't want to interrupt playback and don't want to see notification so - * next lines of code will enable audio-only playback only if needed - */ - private void onFragmentStopped() { - if (videoPlayerSelected() && (isPlaying() || isLoading())) { - if (backgroundPlaybackEnabled()) { - useVideoSource(false); - } else if (minimizeOnPopupEnabled()) { - setRecovery(); - NavigationHelper.playOnPopupPlayer(getParentActivity(), playQueue, true); - } else { - onPause(); - } - } - } - - /////////////////////////////////////////////////////////////////////////// - // Getters - /////////////////////////////////////////////////////////////////////////// - - public RelativeLayout getVolumeRelativeLayout() { - return volumeRelativeLayout; - } - - public ProgressBar getVolumeProgressBar() { - return volumeProgressBar; - } - - public ImageView getVolumeImageView() { - return volumeImageView; - } - - public RelativeLayout getBrightnessRelativeLayout() { - return brightnessRelativeLayout; - } - - public ProgressBar getBrightnessProgressBar() { - return brightnessProgressBar; - } - - public ImageView getBrightnessImageView() { - return brightnessImageView; - } - - public ImageButton getPlayPauseButton() { - return playPauseButton; - } - - public int getMaxGestureLength() { - return maxGestureLength; - } - - public TextView getResizingIndicator() { - return resizingIndicator; - } - - public GestureDetector getGestureDetector() { - return gestureDetector; - } - - public WindowManager.LayoutParams getPopupLayoutParams() { - return popupLayoutParams; - } - - public MainPlayer.PlayerType getPlayerType() { - return playerType; - } - - public float getScreenWidth() { - return screenWidth; - } - - public float getScreenHeight() { - return screenHeight; - } - - public float getPopupWidth() { - return popupWidth; - } - - public float getPopupHeight() { - return popupHeight; - } - - public void setPopupWidth(final float width) { - popupWidth = width; - } - - public void setPopupHeight(final float height) { - popupHeight = height; - } - - public View getCloseOverlayButton() { - return closeOverlayButton; - } - - public View getClosingOverlayView() { - return closingOverlayView; - } - - public boolean isVerticalVideo() { - return isVerticalVideo; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt index 043e7f31de6..989c78c57cb 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt @@ -7,25 +7,25 @@ import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration -import org.schabi.newpipe.player.BasePlayer +import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.player.MainPlayer -import org.schabi.newpipe.player.VideoPlayerImpl +import org.schabi.newpipe.player.Player import org.schabi.newpipe.player.helper.PlayerHelper -import org.schabi.newpipe.util.AnimationUtils +import org.schabi.newpipe.player.helper.PlayerHelper.savePopupPositionAndSizeToPrefs import kotlin.math.abs import kotlin.math.hypot import kotlin.math.max import kotlin.math.min /** - * Base gesture handling for [VideoPlayerImpl] + * Base gesture handling for [Player] * * This class contains the logic for the player gestures like View preparations * and provides some abstract methods to make it easier separating the logic from the UI. */ abstract class BasePlayerGestureListener( @JvmField - protected val playerImpl: VideoPlayerImpl, + protected val player: Player, @JvmField protected val service: MainPlayer ) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { @@ -78,7 +78,7 @@ abstract class BasePlayerGestureListener( // /////////////////////////////////////////////////////////////////// override fun onTouch(v: View, event: MotionEvent): Boolean { - return if (playerImpl.popupPlayerSelected()) { + return if (player.popupPlayerSelected()) { onTouchInPopup(v, event) } else { onTouchInMain(v, event) @@ -86,14 +86,14 @@ abstract class BasePlayerGestureListener( } private fun onTouchInMain(v: View, event: MotionEvent): Boolean { - playerImpl.gestureDetector.onTouchEvent(event) + player.gestureDetector.onTouchEvent(event) if (event.action == MotionEvent.ACTION_UP && isMovingInMain) { isMovingInMain = false onScrollEnd(MainPlayer.PlayerType.VIDEO, event) } return when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - v.parent.requestDisallowInterceptTouchEvent(playerImpl.isFullscreen) + v.parent.requestDisallowInterceptTouchEvent(player.isFullscreen) true } MotionEvent.ACTION_UP -> { @@ -105,7 +105,7 @@ abstract class BasePlayerGestureListener( } private fun onTouchInPopup(v: View, event: MotionEvent): Boolean { - playerImpl.gestureDetector.onTouchEvent(event) + player.gestureDetector.onTouchEvent(event) if (event.pointerCount == 2 && !isMovingInPopup && !isResizing) { if (DEBUG) { Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") @@ -157,10 +157,10 @@ abstract class BasePlayerGestureListener( initSecPointerY = (-1).toFloat() onPopupResizingEnd() - playerImpl.changeState(playerImpl.currentState) + player.changeState(player.currentState) } - if (!playerImpl.isPopupClosing) { - playerImpl.savePositionAndSize() + if (!player.isPopupClosing) { + savePopupPositionAndSizeToPrefs(player) } } @@ -190,19 +190,15 @@ abstract class BasePlayerGestureListener( event.getY(0) - event.getY(1).toDouble() ) - val popupWidth = playerImpl.popupWidth.toDouble() + val popupWidth = player.popupLayoutParams!!.width.toDouble() // change co-ordinates of popup so the center stays at the same position val newWidth = popupWidth * currentPointerDistance / initPointerDistance initPointerDistance = currentPointerDistance - playerImpl.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt() + player.popupLayoutParams!!.x += ((popupWidth - newWidth) / 2.0).toInt() - playerImpl.checkPopupPositionBounds() - playerImpl.updateScreenSize() - - playerImpl.updatePopupSize( - min(playerImpl.screenWidth.toDouble(), newWidth).toInt(), - -1 - ) + player.checkPopupPositionBounds() + player.updateScreenSize() + player.changePopupSize(min(player.screenWidth.toDouble(), newWidth).toInt()) return true } } @@ -222,7 +218,7 @@ abstract class BasePlayerGestureListener( return true } - return if (playerImpl.popupPlayerSelected()) + return if (player.popupPlayerSelected()) onDownInPopup(e) else true @@ -231,12 +227,10 @@ abstract class BasePlayerGestureListener( private fun onDownInPopup(e: MotionEvent): Boolean { // Fix popup position when the user touch it, it may have the wrong one // because the soft input is visible (the draggable area is currently resized). - playerImpl.updateScreenSize() - playerImpl.checkPopupPositionBounds() - initialPopupX = playerImpl.popupLayoutParams.x - initialPopupY = playerImpl.popupLayoutParams.y - playerImpl.popupWidth = playerImpl.popupLayoutParams.width.toFloat() - playerImpl.popupHeight = playerImpl.popupLayoutParams.height.toFloat() + player.updateScreenSize() + player.checkPopupPositionBounds() + initialPopupX = player.popupLayoutParams!!.x + initialPopupY = player.popupLayoutParams!!.y return super.onDown(e) } @@ -255,15 +249,15 @@ abstract class BasePlayerGestureListener( if (isDoubleTapping) return true - if (playerImpl.popupPlayerSelected()) { - if (playerImpl.player == null) + if (player.popupPlayerSelected()) { + if (player.exoPlayerIsNull()) return false onSingleTap(MainPlayer.PlayerType.POPUP) return true } else { super.onSingleTapConfirmed(e) - if (playerImpl.currentState == BasePlayer.STATE_BLOCKED) + if (player.currentState == Player.STATE_BLOCKED) return true onSingleTap(MainPlayer.PlayerType.VIDEO) @@ -272,10 +266,10 @@ abstract class BasePlayerGestureListener( } override fun onLongPress(e: MotionEvent?) { - if (playerImpl.popupPlayerSelected()) { - playerImpl.updateScreenSize() - playerImpl.checkPopupPositionBounds() - playerImpl.updatePopupSize(playerImpl.screenWidth.toInt(), -1) + if (player.popupPlayerSelected()) { + player.updateScreenSize() + player.checkPopupPositionBounds() + player.changePopupSize(player.screenWidth.toInt()) } } @@ -285,7 +279,7 @@ abstract class BasePlayerGestureListener( distanceX: Float, distanceY: Float ): Boolean { - return if (playerImpl.popupPlayerSelected()) { + return if (player.popupPlayerSelected()) { onScrollInPopup(initialEvent, movingEvent, distanceX, distanceY) } else { onScrollInMain(initialEvent, movingEvent, distanceX, distanceY) @@ -298,19 +292,18 @@ abstract class BasePlayerGestureListener( velocityX: Float, velocityY: Float ): Boolean { - return if (playerImpl.popupPlayerSelected()) { + return if (player.popupPlayerSelected()) { val absVelocityX = abs(velocityX) val absVelocityY = abs(velocityY) if (absVelocityX.coerceAtLeast(absVelocityY) > tossFlingVelocity) { if (absVelocityX > tossFlingVelocity) { - playerImpl.popupLayoutParams.x = velocityX.toInt() + player.popupLayoutParams!!.x = velocityX.toInt() } if (absVelocityY > tossFlingVelocity) { - playerImpl.popupLayoutParams.y = velocityY.toInt() + player.popupLayoutParams!!.y = velocityY.toInt() } - playerImpl.checkPopupPositionBounds() - playerImpl.windowManager - .updateViewLayout(playerImpl.rootView, playerImpl.popupLayoutParams) + player.checkPopupPositionBounds() + player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams) return true } return false @@ -326,13 +319,13 @@ abstract class BasePlayerGestureListener( distanceY: Float ): Boolean { - if (!playerImpl.isFullscreen) { + if (!player.isFullscreen) { return false } val isTouchingStatusBar: Boolean = initialEvent.y < getStatusBarHeight(service) val isTouchingNavigationBar: Boolean = - initialEvent.y > (playerImpl.rootView.height - getNavigationBarHeight(service)) + initialEvent.y > (player.rootView.height - getNavigationBarHeight(service)) if (isTouchingStatusBar || isTouchingNavigationBar) { return false } @@ -340,7 +333,7 @@ abstract class BasePlayerGestureListener( val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD if ( !isMovingInMain && (insideThreshold || abs(distanceX) > abs(distanceY)) || - playerImpl.currentState == BasePlayer.STATE_COMPLETED + player.currentState == Player.STATE_COMPLETED ) { return false } @@ -371,7 +364,7 @@ abstract class BasePlayerGestureListener( } if (!isMovingInPopup) { - AnimationUtils.animateView(playerImpl.closeOverlayButton, true, 200) + player.closeOverlayButton.animate(true, 200) } isMovingInPopup = true @@ -381,20 +374,20 @@ abstract class BasePlayerGestureListener( val diffY: Float = (movingEvent.rawY - initialEvent.rawY) var posY: Float = (initialPopupY + diffY) - if (posX > playerImpl.screenWidth - playerImpl.popupWidth) { - posX = (playerImpl.screenWidth - playerImpl.popupWidth) + if (posX > player.screenWidth - player.popupLayoutParams!!.width) { + posX = (player.screenWidth - player.popupLayoutParams!!.width) } else if (posX < 0) { posX = 0f } - if (posY > playerImpl.screenHeight - playerImpl.popupHeight) { - posY = (playerImpl.screenHeight - playerImpl.popupHeight) + if (posY > player.screenHeight - player.popupLayoutParams!!.height) { + posY = (player.screenHeight - player.popupLayoutParams!!.height) } else if (posY < 0) { posY = 0f } - playerImpl.popupLayoutParams.x = posX.toInt() - playerImpl.popupLayoutParams.y = posY.toInt() + player.popupLayoutParams!!.x = posX.toInt() + player.popupLayoutParams!!.y = posY.toInt() onScroll( MainPlayer.PlayerType.POPUP, @@ -405,8 +398,7 @@ abstract class BasePlayerGestureListener( distanceY ) - playerImpl.windowManager - .updateViewLayout(playerImpl.rootView, playerImpl.popupLayoutParams) + player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams) return true } @@ -474,16 +466,16 @@ abstract class BasePlayerGestureListener( // /////////////////////////////////////////////////////////////////// private fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return if (playerImpl.playerType == MainPlayer.PlayerType.POPUP) { + return if (player.playerType == MainPlayer.PlayerType.POPUP) { when { - e.x < playerImpl.popupWidth / 3.0 -> DisplayPortion.LEFT - e.x > playerImpl.popupWidth * 2.0 / 3.0 -> DisplayPortion.RIGHT + e.x < player.popupLayoutParams!!.width / 3.0 -> DisplayPortion.LEFT + e.x > player.popupLayoutParams!!.width * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } else /* MainPlayer.PlayerType.VIDEO */ { when { - e.x < playerImpl.rootView.width / 3.0 -> DisplayPortion.LEFT - e.x > playerImpl.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT + e.x < player.rootView.width / 3.0 -> DisplayPortion.LEFT + e.x > player.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } @@ -491,14 +483,14 @@ abstract class BasePlayerGestureListener( // Currently needed for scrolling since there is no action more the middle portion private fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { - return if (playerImpl.playerType == MainPlayer.PlayerType.POPUP) { + return if (player.playerType == MainPlayer.PlayerType.POPUP) { when { - e.x < playerImpl.popupWidth / 2.0 -> DisplayPortion.LEFT_HALF + e.x < player.popupLayoutParams!!.width / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } else /* MainPlayer.PlayerType.VIDEO */ { when { - e.x < playerImpl.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF + e.x < player.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } @@ -522,7 +514,7 @@ abstract class BasePlayerGestureListener( companion object { private const val TAG = "BasePlayerGestListener" - private val DEBUG = BasePlayer.DEBUG + private val DEBUG = Player.DEBUG private const val DOUBLE_TAP_DELAY = 550L private const val MOVEMENT_THRESHOLD = 40 diff --git a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java index 26ecb187105..61023875cda 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java @@ -6,9 +6,12 @@ import android.view.MotionEvent; import android.view.View; import android.widget.FrameLayout; + import androidx.annotation.NonNull; import androidx.coordinatorlayout.widget.CoordinatorLayout; + import com.google.android.material.bottomsheet.BottomSheetBehavior; + import org.schabi.newpipe.R; import java.util.Arrays; @@ -24,7 +27,7 @@ public CustomBottomSheetBehavior(final Context context, final AttributeSet attrs private boolean skippingInterception = false; private final List+ * This method tries to open the default app market with the package id passed as the + * second param (a system chooser will be opened if there are multiple markets and no default) + * and falls back to Google Play Store web URL if no app to handle the market scheme was found. + *
+ * It uses {@link ShareUtils#openIntentInApp(Context, Intent)} to open market scheme and + * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} to open Google Play Store web + * URL with false for the boolean param. + * + * @param context the context to use + * @param packageId the package id of the app to be installed + */ + public static void installApp(final Context context, final String packageId) { + // Try market:// scheme + final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + packageId)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + if (!marketSchemeResult) { + // Fall back to Google Play Store Web URL (F-Droid can handle it) + openUrlInBrowser(context, + "https://play.google.com/store/apps/details?id=" + packageId, false); + } + } + /** * Open the url with the system default browser. *
* If no browser is set as default, fallbacks to - * {@link ShareUtils#openInDefaultApp(Context, String)} + * {@link ShareUtils#openAppChooser(Context, Intent, String)} * - * @param context the context to use - * @param url the url to browse + * @param context the context to use + * @param url the url to browse + * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be + * for HTTP protocol or for the created intent + * @return true if the URL can be opened or false if it cannot */ - public static void openUrlInBrowser(final Context context, final String url) { - final String defaultBrowserPackageName = getDefaultBrowserPackageName(context); + public static boolean openUrlInBrowser(final Context context, final String url, + final boolean httpDefaultBrowserTest) { + final String defaultPackageName; + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (httpDefaultBrowserTest) { + defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } else { + defaultPackageName = getDefaultAppPackageName(context, intent); + } - if (defaultBrowserPackageName.equals("android")) { - // no browser set as default - openInDefaultApp(context, url); + if (defaultPackageName.equals("android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, context.getString(R.string.open_with)); } else { - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setPackage(defaultBrowserPackageName) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(intent); + if (defaultPackageName.isEmpty()) { + // No app installed to open a web url + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); + return false; + } else { + try { + intent.setPackage(defaultPackageName); + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // Not a browser but an app chooser because of OEMs changes + intent.setPackage(null); + openAppChooser(context, intent, context.getString(R.string.open_with)); + } + } } + + return true; } /** - * Open the url in the default app set to open this type of link. + * Open the url with the system default browser. *
- * If no app is set as default, it will open a chooser + * If no browser is set as default, fallbacks to + * {@link ShareUtils#openAppChooser(Context, Intent, String)} + *
+ * This calls {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} with true + * for the boolean parameter * * @param context the context to use * @param url the url to browse + * @return true if the URL can be opened or false if it cannot be + **/ + public static boolean openUrlInBrowser(final Context context, final String url) { + return openUrlInBrowser(context, url, true); + } + + /** + * Open an intent with the system default app. + *
+ * The intent can be of every type, excepted a web intent for which + * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} should be used. + *
+ * If no app is set as default, fallbacks to + * {@link ShareUtils#openAppChooser(Context, Intent, String)} + * + * @param context the context to use + * @param intent the intent to open + * @return true if the intent can be opened or false if it cannot be */ - private static void openInDefaultApp(final Context context, final String url) { - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - context.startActivity(Intent.createChooser( - intent, context.getString(R.string.share_dialog_title)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + public static boolean openIntentInApp(final Context context, final Intent intent) { + final String defaultPackageName = getDefaultAppPackageName(context, intent); + + if (defaultPackageName.equals("android")) { + // No app set as default (doesn't work on some devices) + openAppChooser(context, intent, context.getString(R.string.open_with)); + } else { + if (defaultPackageName.isEmpty()) { + // No app installed to open the intent + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); + return false; + } else { + try { + intent.setPackage(defaultPackageName); + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // Not an app to open the intent but an app chooser because of OEMs changes + intent.setPackage(null); + openAppChooser(context, intent, context.getString(R.string.open_with)); + } + } + } + + return true; + } + + /** + * Open the system chooser to launch an intent. + *
+ * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted + * as the viewIntent param. A string for the chooser's title must be passed as the last param. + * + * @param context the context to use + * @param intent the intent to open + * @param chooserStringTitle the string of chooser's title + */ + private static void openAppChooser(final Context context, final Intent intent, + final String chooserStringTitle) { + final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.putExtra(Intent.EXTRA_TITLE, chooserStringTitle); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(chooserIntent); } /** - * Get the default browser package name. + * Get the default app package name. + *
+ * If no app is set as default, it will return "android" (not on some devices because some + * OEMs changed the app chooser). *
- * If no browser is set as default, it will return "android" + * If no app is installed on user's device to handle the intent, it will return an empty string. * * @param context the context to use - * @return the package name of the default browser, or "android" if there's no default + * @param intent the intent to get default app + * @return the package name of the default app, an empty string if there's no app installed to + * handle the intent or the app chooser if there's no default */ - private static String getDefaultBrowserPackageName(final Context context) { - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity( - intent, PackageManager.MATCH_DEFAULT_ONLY); - return resolveInfo.activityInfo.packageName; + private static String getDefaultAppPackageName(final Context context, final Intent intent) { + final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, + PackageManager.MATCH_DEFAULT_ONLY); + + if (resolveInfo == null) { + return ""; + } else { + return resolveInfo.activityInfo.packageName; + } } /** @@ -78,14 +198,13 @@ private static String getDefaultBrowserPackageName(final Context context) { * @param subject the url subject, typically the title * @param url the url to share */ - public static void shareUrl(final Context context, final String subject, final String url) { - final Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_SUBJECT, subject); - intent.putExtra(Intent.EXTRA_TEXT, url); - context.startActivity(Intent.createChooser( - intent, context.getString(R.string.share_dialog_title)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + public static void shareText(final Context context, final String subject, final String url) { + final Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + shareIntent.putExtra(Intent.EXTRA_TEXT, url); + + openAppChooser(context, shareIntent, context.getString(R.string.share_dialog_title)); } /** @@ -100,14 +219,11 @@ public static void copyToClipboard(final Context context, final String text) { ContextCompat.getSystemService(context, ClipboardManager.class); if (clipboardManager == null) { - Toast.makeText(context, - R.string.permission_denied, - Toast.LENGTH_LONG).show(); + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); return; } clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT) - .show(); + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java index 34ff637ad61..73fee32f7f5 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; import android.content.Context; +import android.net.Uri; import androidx.fragment.app.Fragment; @@ -70,8 +71,17 @@ public enum StreamDialogEntry { } }), + play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> { + final Uri videoUrl = Uri.parse(item.getUrl()); + try { + NavigationHelper.playWithKore(fragment.getContext(), videoUrl); + } catch (final Exception e) { + KoreUtil.showInstallKoreDialog(fragment.getActivity()); + } + }), + share(R.string.share, (fragment, item) -> - ShareUtils.shareUrl(fragment.getContext(), item.getName(), item.getUrl())); + ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl())); /////////////// diff --git a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java new file mode 100644 index 00000000000..08767733339 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java @@ -0,0 +1,145 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.text.SpannableStringBuilder; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.text.HtmlCompat; + +import io.noties.markwon.Markwon; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class TextLinkifier { + public static final String TAG = TextLinkifier.class.getSimpleName(); + + private TextLinkifier() { + } + + /** + * Create web links for contents with an HTML description. + *
+ * This will call + * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} + * after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}. + * + * @param context the context to use + * @param htmlBlock the htmlBlock to be linked + * @param textView the TextView to set the htmlBlock linked + * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} + * will be called + * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + */ + public static Disposable createLinksFromHtmlBlock(final Context context, + final String htmlBlock, + final TextView textView, + final int htmlCompatFlag) { + return changeIntentsOfDescriptionLinks(context, + HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView); + } + + /** + * Create web links for contents with a plain text description. + *
+ * This will call + * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} + * after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and + * {@link TextView#setText(CharSequence, TextView.BufferType)}. + * + * @param context the context to use + * @param plainTextBlock the block of plain text to be linked + * @param textView the TextView to set the plain text block linked + * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + */ + public static Disposable createLinksFromPlainText(final Context context, + final String plainTextBlock, + final TextView textView) { + textView.setAutoLinkMask(Linkify.WEB_URLS); + textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); + return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); + } + + /** + * Create web links for contents with a markdown description. + *
+ * This will call + * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} + * after creating an {@link Markwon} object and using + * {@link Markwon#setMarkdown(TextView, String)}. + * + * @param context the context to use + * @param markdownBlock the block of markdown text to be linked + * @param textView the TextView to set the plain text block linked + * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + */ + public static Disposable createLinksFromMarkdownText(final Context context, + final String markdownBlock, + final TextView textView) { + final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build(); + markwon.setMarkdown(textView, markdownBlock); + return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); + } + + /** + * Change links generated by libraries in the description of a content to a custom link action. + *
+ * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of a + * content, this method will parse the {@link CharSequence} and replace all current web links + * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. + *
+ * This method is required in order to intercept links and e.g. show a confirmation dialog
+ * before opening a web link.
+ *
+ * @param context the context to use
+ * @param chars the CharSequence to be parsed
+ * @param textView the TextView in which the converted CharSequence will be applied
+ * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
+ */
+ private static Disposable changeIntentsOfDescriptionLinks(final Context context,
+ final CharSequence chars,
+ final TextView textView) {
+ return Single.fromCallable(() -> {
+ final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
+ final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
+
+ for (final URLSpan span : urls) {
+ final ClickableSpan clickableSpan = new ClickableSpan() {
+ public void onClick(@NonNull final View view) {
+ ShareUtils.openUrlInBrowser(context, span.getURL(), false);
+ }
+ };
+
+ textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
+ textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
+ textBlockLinked.removeSpan(span);
+ }
+
+ return textBlockLinked;
+ }).subscribeOn(Schedulers.computation())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
+ throwable -> {
+ Log.e(TAG, "Unable to linkify text", throwable);
+ // this should never happen, but if it does, just fallback to it
+ setTextViewCharSequence(textView, chars);
+ });
+ }
+
+ private static void setTextViewCharSequence(final TextView textView,
+ final CharSequence charSequence) {
+ textView.setText(charSequence);
+ textView.setMovementMethod(LinkMovementMethod.getInstance());
+ textView.setVisibility(View.VISIBLE);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
index a1af0387acf..5ac4de84ce3 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
@@ -22,7 +22,6 @@
import android.app.Activity;
import android.content.Context;
import android.content.res.TypedArray;
-import androidx.preference.PreferenceManager;
import android.util.TypedValue;
import android.view.ContextThemeWrapper;
@@ -32,6 +31,7 @@
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
+import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
index f9a950d2bdf..e2b766bb03e 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
@@ -4,7 +4,9 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
+import java.io.IOException;
import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -99,4 +101,12 @@ public static boolean extractFileFromZip(final String filePath, final String fil
return found;
}
}
+
+ public static boolean isValidZipFile(final String filePath) {
+ try (ZipFile ignored = new ZipFile(filePath)) {
+ return true;
+ } catch (final IOException ioe) {
+ return false;
+ }
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
index b34a6be637c..e1ada4f9bdb 100644
--- a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
+++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
@@ -31,7 +31,7 @@
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
-import org.schabi.newpipe.util.AnimationUtils;
+import org.schabi.newpipe.ktx.ViewUtils;
import java.lang.annotation.Retention;
import java.util.ArrayList;
@@ -128,7 +128,7 @@ public void collapse() {
if (currentAnimator != null && currentAnimator.isRunning()) {
currentAnimator.cancel();
}
- currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, 0);
+ currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, 0);
setCurrentState(COLLAPSED);
}
@@ -151,7 +151,7 @@ public void expand() {
if (currentAnimator != null && currentAnimator.isRunning()) {
currentAnimator.cancel();
}
- currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight);
+ currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight);
setCurrentState(EXPANDED);
}
diff --git a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java
index 23e16ff585d..dc667b22a39 100644
--- a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java
+++ b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java
@@ -2,10 +2,12 @@
import android.content.Context;
import android.util.AttributeSet;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
+
import com.google.android.material.appbar.CollapsingToolbarLayout;
public class CustomCollapsingToolbarLayout extends CollapsingToolbarLayout {
diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java
index e7a028d508d..cfa17e20c0b 100644
--- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java
+++ b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java
@@ -4,6 +4,7 @@
import android.os.Build;
import android.util.AttributeSet;
import android.view.SurfaceView;
+
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT;
diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java
index f400b62b157..798d08c729c 100644
--- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java
+++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java
@@ -24,11 +24,12 @@
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
-
import android.view.WindowInsets;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
+
import org.schabi.newpipe.R;
public final class FocusAwareCoordinator extends CoordinatorLayout {
diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java
index d7c586083ba..2b3faa3e050 100644
--- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java
+++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java
@@ -22,6 +22,7 @@
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.channels.ClosedByInterruptException;
+import java.util.Objects;
import javax.net.ssl.SSLException;
@@ -154,8 +155,8 @@ public class DownloadMission extends Mission {
public transient Thread init = null;
public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) {
- if (urls == null) throw new NullPointerException("urls is null");
- if (urls.length < 1) throw new IllegalArgumentException("urls is empty");
+ if (Objects.requireNonNull(urls).length < 1)
+ throw new IllegalArgumentException("urls array is empty");
this.urls = urls;
this.kind = kind;
this.offsets = new long[urls.length];
diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java
index 7fb12d0889c..6f504cea3b1 100644
--- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java
+++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java
@@ -8,6 +8,7 @@
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
+import java.util.Objects;
import us.shandian.giga.get.DownloadMission.Block;
import us.shandian.giga.get.DownloadMission.HttpError;
@@ -29,8 +30,7 @@ public class DownloadRunnable extends Thread {
private HttpURLConnection mConn;
DownloadRunnable(DownloadMission mission, int id) {
- if (mission == null) throw new NullPointerException("mission is null");
- mMission = mission;
+ mMission = Objects.requireNonNull(mission);
mId = id;
}
diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java
index 1d1dca0dff3..15c45c6fd31 100644
--- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java
+++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java
@@ -12,6 +12,7 @@
import java.io.File;
import java.util.ArrayList;
+import java.util.Objects;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission;
@@ -140,9 +141,7 @@ private ContentValues getValuesOfMission(@NonNull Mission downloadMission) {
}
private FinishedMission getMissionFromCursor(Cursor cursor) {
- if (cursor == null) throw new NullPointerException("cursor is null");
-
- String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND));
+ String kind = Objects.requireNonNull(cursor).getString(cursor.getColumnIndex(KEY_KIND));
if (kind == null || kind.isEmpty()) kind = "?";
String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH));
@@ -186,15 +185,13 @@ public ArrayList