Merge branch 'master' into sponsorblock
Also fixed branch-specific changes related to error-reporting and updates
This commit is contained in:
commit
1ae09302ad
332 changed files with 7232 additions and 2482 deletions
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +1,2 @@
|
|||
liberapay: TeamNewPipe
|
||||
custom: 'https://newpipe.net/donate/'
|
||||
|
|
66
.github/ISSUE_TEMPLATE/bug_report.md
vendored
66
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,66 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe. To make it easier for us to help you please enter detailed information in the template we have provided below. If a section isn't relevant, just delete it, though it would be helpful to still provide as much detail as possible.
|
||||
-->
|
||||
|
||||
<!-- IF YOU DON'T FILL IN THE TEMPLATE PROPERLY, YOUR ISSUE IS LIABLE TO BE CLOSED. If you feel tired/lazy right now, open your issue some other time. We'll wait. -->
|
||||
|
||||
<!-- The comments between these brackets won't show up in the submitted issue (as you can see in the Preview). -->
|
||||
|
||||
### Checklist
|
||||
<!-- This checklist is COMPULSORY. The first box has been checked for you to show you how it is done. -->
|
||||
|
||||
- [x] I am using the latest version - x.xx.x <!-- Check https://github.com/TeamNewPipe/NewPipe/releases -->
|
||||
- [ ] This issue CANNOT be reproduced with the same version of upstream NewPipe <!-- If it can, report it there instead, using the official app -->
|
||||
- [ ] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. <!-- Seriously, check. O_O -->
|
||||
- [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md.
|
||||
- [ ] This issue contains only one bug. I will open one issue for every bug report I want to file.
|
||||
|
||||
### Steps to reproduce the bug
|
||||
<!--
|
||||
1. Go to '...'
|
||||
2. Press on '....'
|
||||
3. Swipe down to '....'
|
||||
-->
|
||||
|
||||
<!-- If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug. -->
|
||||
|
||||
|
||||
|
||||
### Actual behavior
|
||||
<!-- Tell us what happens with the steps given above. -->
|
||||
|
||||
|
||||
|
||||
### Expected behavior
|
||||
<!-- Tell us what you expect to happen. -->
|
||||
|
||||
|
||||
|
||||
### Screenshots/Screen recordings
|
||||
<!-- If applicable, add screenshots or a screen recording to help explain your problem. GitHub supports uploading them directly in the issue text box. If your file is too big for Github to accept, feel free to paste a link from an image/video hoster here instead. -->
|
||||
|
||||
<!-- DON'T POST SCREENSHOTS OF THE ERROR PAGE. Use the buttons given on the error page to paste the error as text in the Logs section below. -->
|
||||
|
||||
|
||||
|
||||
### Logs
|
||||
<!-- If your bug includes a crash (where you're shown the Error Report page with a bunch of info), tap on "Copy formatted report" at the bottom and paste it here: -->
|
||||
|
||||
<!-- That's right, here! -->
|
||||
|
||||
|
||||
|
||||
<!-- Please fill this section if you did not provide a log generated by NewPipe -->
|
||||
|
||||
### Device info
|
||||
|
||||
- Android version/Custom ROM version:
|
||||
- Device model:
|
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
@ -0,0 +1,113 @@
|
|||
name: Bug report
|
||||
description: Create a bug report to help us improve
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for helping to make NewPipe better by reporting a bug. :hugs:
|
||||
|
||||
Please fill in as much information as possible about your bug so that we don't have to play "information ping-pong" and can help you immediately.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
options:
|
||||
- label: "I am able to reproduce the bug with the [latest version](https://github.com/TeamNewPipe/NewPipe/releases/latest)."
|
||||
required: true
|
||||
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise."
|
||||
required: true
|
||||
- label: "This issue contains only one bug."
|
||||
required: true
|
||||
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Affected version
|
||||
description: "In which NewPipe version did you encounter the bug?"
|
||||
placeholder: "x.xx.x - Can be seen in the app from the 'About' section in the sidebar"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce the bug
|
||||
description: |
|
||||
What did you do for the bug to show up?
|
||||
|
||||
If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Press on '....'
|
||||
3. Swipe down to '....'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: |
|
||||
Tell us what you expect to happen.
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: |
|
||||
Tell us what happens with the steps given above.
|
||||
|
||||
- type: textarea
|
||||
id: screen-media
|
||||
attributes:
|
||||
label: Screenshots/Screen recordings
|
||||
description: |
|
||||
A picture or video is worth a thousand words.
|
||||
|
||||
If applicable, add screenshots or a screen recording to help explain your problem.
|
||||
GitHub supports uploading them directly in the text box.
|
||||
If your file is too big for Github to accept, try to compress it (ZIP-file) or feel free to paste a link to an image/video hoster here instead.
|
||||
|
||||
:heavy_exclamation_mark: DON'T POST SCREENSHOTS OF THE ERROR PAGE.
|
||||
Instead, follow the instructions in the "Logs" section below.
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
If your bug includes a crash (where you're shown the Error Report page with a bunch of info), tap on "Copy formatted report" at the bottom and paste it here.
|
||||
|
||||
- type: input
|
||||
id: device-os-info
|
||||
attributes:
|
||||
label: Affected Android/Custom ROM version
|
||||
description: |
|
||||
With what operating system (+ version) did you encounter the bug?
|
||||
placeholder: "Example: Android 12 / LineageOS 18.1"
|
||||
|
||||
- type: input
|
||||
id: device-model-info
|
||||
attributes:
|
||||
label: Affected device model
|
||||
description: |
|
||||
On what device did you encounter the bug?
|
||||
placeholder: "Example: Huawei P20 lite (ANE-LX1) / Samsung Galaxy S20"
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: |
|
||||
Any other information you'd like to include, for instance that
|
||||
* the affected device is foldable or a TV
|
||||
* you have disabled all animations on your device
|
||||
* your cat disabled your network connection
|
||||
* ...
|
||||
|
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
<!-- IF YOU DON'T FILL IN THE TEMPLATE PROPERLY, YOUR ISSUE IS LIABLE TO BE CLOSED. If you are currently unable to do so for any reason, open your issue some other time. We'll wait. -->
|
||||
|
||||
<!-- The comments between these brackets won't show up in the submitted issue (as you can see in the Preview tab). -->
|
||||
|
||||
### Checklist
|
||||
<!-- This checklist is COMPULSORY. The first box has been checked for you to show you how it is done. -->
|
||||
|
||||
- [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. <!-- Seriously, check. O_O -->
|
||||
- [ ] This feature is related to sponsor or ad blocking and/or has explicitly been rejected upstream. <!-- This is not the place for stale upstream feature requests. -->
|
||||
- [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md.
|
||||
- [ ] This issue contains only one feature request. I will open one issue for every feature I want to request.
|
||||
|
||||
#### What feature do you want?
|
||||
<!-- Explain how you want the app's look or behavior to change to suit your needs. -->
|
||||
|
||||
|
||||
#### Why do you want this feature?
|
||||
<!-- Describe any problem or limitation you come across while using the app which would be solved by this feature. -->
|
51
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
51
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
@ -0,0 +1,51 @@
|
|||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for helping to make NewPipe better by suggesting a feature. :hugs:
|
||||
|
||||
Your ideas are highly welcome! The app is made for you, the users, after all.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
options:
|
||||
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||
required: true
|
||||
- label: "I'm aware that this is a request for NewPipe itself and that requests for adding a new service need to be made at [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor/issues)."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the feature request will be dismissed otherwise."
|
||||
required: true
|
||||
- label: "This issue contains only one feature request."
|
||||
required: true
|
||||
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
|
||||
required: true
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: |
|
||||
Explain how you want the app's look or behavior to change to suit your needs.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why-is-the-feature-requested
|
||||
attributes:
|
||||
label: Why do you want this feature?
|
||||
description: |
|
||||
Describe any problem or limitation you come across while using the app which would be solved by this feature.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any other information you'd like to include, for instance sketches, mockups, pictures of cats, etc.
|
24
.github/ISSUE_TEMPLATE/question.md
vendored
24
.github/ISSUE_TEMPLATE/question.md
vendored
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
name: Question
|
||||
about: Ask about anything NewPipe-related
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- IF YOU DON'T FILL IN THE TEMPLATE PROPERLY, YOUR ISSUE IS LIABLE TO BE CLOSED. If you feel tired/lazy right now, open your issue some other time. We'll wait. -->
|
||||
|
||||
<!-- The comments between these brackets won't show up in the submitted issue (as you can see in the Preview). -->
|
||||
|
||||
### Checklist
|
||||
<!-- This checklist is COMPULSORY. The first box has been checked for you to show you how it is done. -->
|
||||
|
||||
- [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. <!-- Seriously, check. O_O (If there's already an issue but you'd like to see if something changed, just make a comment on the issue instead of opening a new one.) -->
|
||||
- [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md.
|
||||
|
||||
#### What's your question(s)?
|
||||
|
||||
|
||||
#### Additional context
|
||||
<!-- Add any other context, like screenshots or links, about the question here.
|
||||
Example: *Here's a photo of my cat!* -->
|
35
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
name: Question
|
||||
description: Ask about anything NewPipe-related
|
||||
labels: [question]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this issue! :hugs:
|
||||
|
||||
Note that you can also ask questions on our [IRC channel](https://web.libera.chat/#newpipe).
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
options:
|
||||
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise."
|
||||
required: true
|
||||
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-is-the-question
|
||||
attributes:
|
||||
label: What is/are your question(s)?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any other information you'd like to include, for instance sketches, mockups, pictures of cats, etc.
|
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
|
@ -7,19 +7,25 @@ on:
|
|||
- dev
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'README*.md'
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
- 'fastlane/**'
|
||||
- 'assets/**'
|
||||
- '.github/**/*.md'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'README*.md'
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
- 'fastlane/**'
|
||||
- 'assets/**'
|
||||
- '.github/**/*.md'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
jobs:
|
||||
build-and-test-jvm:
|
||||
|
@ -39,7 +45,7 @@ jobs:
|
|||
java-version: 11
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
|
||||
- name: Build debug APK and run jvm tests
|
||||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||
|
||||
|
@ -52,9 +58,10 @@ jobs:
|
|||
test-android:
|
||||
# macos has hardware acceleration. See android-emulator-runner action
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
api-level: [ 21, 29 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@ -72,31 +79,38 @@ jobs:
|
|||
api-level: ${{ matrix.api-level }}
|
||||
# workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
|
||||
emulator-build: 7425822
|
||||
script: ./gradlew connectedCheck
|
||||
script: ./gradlew connectedCheck --stacktrace
|
||||
|
||||
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
||||
uses: actions/upload-artifact@v2
|
||||
if: failure()
|
||||
with:
|
||||
name: android-test-report-api${{ matrix.api-level }}
|
||||
path: app/build/reports/androidTests/connected/**
|
||||
|
||||
# sonar:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# with:
|
||||
# fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
sonar:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
# - name: Set up JDK 11
|
||||
# uses: actions/setup-java@v2
|
||||
# with:
|
||||
# java-version: 11 # Sonar requires JDK 11
|
||||
# distribution: "temurin"
|
||||
# cache: 'gradle'
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 11 # Sonar requires JDK 11
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
# - name: Cache SonarCloud packages
|
||||
# uses: actions/cache@v2
|
||||
# with:
|
||||
# path: ~/.sonar/cache
|
||||
# key: ${{ runner.os }}-sonar
|
||||
# restore-keys: ${{ runner.os }}-sonar
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
# - name: Build and analyze
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
# run: ./gradlew build sonarqube --info
|
||||
- name: Build and analyze
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: ./gradlew build sonarqube --info
|
||||
|
|
107
.github/workflows/image-minimizer.js
vendored
Normal file
107
.github/workflows/image-minimizer.js
vendored
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Script for minimizing big images (jpg,gif,png) when they are uploaded to GitHub and not edited otherwise
|
||||
*/
|
||||
module.exports = async ({github, context}) => {
|
||||
const IGNORE_KEY = '<!-- IGNORE IMAGE MINIFY -->';
|
||||
const IGNORE_ALT_NAME_END = 'ignoreImageMinify';
|
||||
const IMG_MAX_HEIGHT_PX = 600;
|
||||
|
||||
// Get the body of the image
|
||||
let initialBody = null;
|
||||
if (context.eventName == 'issue_comment') {
|
||||
initialBody = context.payload.comment.body;
|
||||
} else if (context.eventName == 'issues') {
|
||||
initialBody = context.payload.issue.body;
|
||||
} else {
|
||||
console.log('Aborting: No body found');
|
||||
return;
|
||||
}
|
||||
console.log(`Found body: \n${initialBody}\n`);
|
||||
|
||||
// Check if we should ignore the currently processing element
|
||||
if (initialBody.includes(IGNORE_KEY)) {
|
||||
console.log('Ignoring: Body contains IGNORE_KEY');
|
||||
return;
|
||||
}
|
||||
|
||||
// Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
|
||||
const REGEX_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
|
||||
// Check if we found something
|
||||
let foundSimpleImages = REGEX_IMAGE_LOOKUP.test(initialBody);
|
||||
if (!foundSimpleImages) {
|
||||
console.log('Found no simple images to process');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Found at least one simple image to process');
|
||||
|
||||
// Require the probe lib for getting the image dimensions
|
||||
const probe = require('probe-image-size');
|
||||
|
||||
// Try to find and replace the images with minimized ones
|
||||
let newBody = await replaceAsync(initialBody, REGEX_IMAGE_LOOKUP, async (match, g1, g2) => {
|
||||
console.log(`Found match '${match}'`);
|
||||
|
||||
if (g1.endsWith(IGNORE_ALT_NAME_END)) {
|
||||
console.log(`Ignoring match '${match}': IGNORE_ALT_NAME_END`);
|
||||
return match;
|
||||
}
|
||||
|
||||
let shouldModifiy = false;
|
||||
try {
|
||||
console.log(`Probing ${g2}`);
|
||||
let probeResult = await probe(g2);
|
||||
if (probeResult == null) {
|
||||
throw 'No probeResult';
|
||||
}
|
||||
if (probeResult.hUnits != 'px') {
|
||||
throw `Unexpected probeResult.hUnits (expected px but got ${probeResult.hUnits})`;
|
||||
}
|
||||
|
||||
shouldModifiy = probeResult.height > IMG_MAX_HEIGHT_PX;
|
||||
} catch(e) {
|
||||
console.log('Probing failed:', e);
|
||||
// Immediately abort
|
||||
return match;
|
||||
}
|
||||
|
||||
if (shouldModifiy) {
|
||||
console.log(`Modifying match '${match}'`);
|
||||
return `<img alt="${g1}" src="${g2}" height=${IMG_MAX_HEIGHT_PX} />`;
|
||||
}
|
||||
|
||||
console.log(`Match '${match}' is ok/will not be modified`);
|
||||
return match;
|
||||
});
|
||||
|
||||
// Update the corresponding element
|
||||
if (context.eventName == 'issue_comment') {
|
||||
console.log('Updating comment with id', context.payload.comment.id);
|
||||
await github.rest.issues.updateComment({
|
||||
comment_id: context.payload.comment.id,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: newBody
|
||||
})
|
||||
} else if (context.eventName == 'issues') {
|
||||
console.log('Updating issue', context.payload.issue.number);
|
||||
await github.rest.issues.update({
|
||||
issue_number: context.payload.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: newBody
|
||||
});
|
||||
}
|
||||
|
||||
// Asnyc replace function from https://stackoverflow.com/a/48032528
|
||||
async function replaceAsync(str, regex, asyncFn) {
|
||||
const promises = [];
|
||||
str.replace(regex, (match, ...args) => {
|
||||
const promise = asyncFn(match, ...args);
|
||||
promises.push(promise);
|
||||
});
|
||||
const data = await Promise.all(promises);
|
||||
return str.replace(regex, () => data.shift());
|
||||
}
|
||||
}
|
29
.github/workflows/image-minimizer.yml
vendored
Normal file
29
.github/workflows/image-minimizer.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: Image Minimizer
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
try-minimize:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Install probe-image-size
|
||||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||
|
||||
- name: Minimize simple images
|
||||
uses: actions/github-script@v5
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
script: |
|
||||
const script = require('.github/workflows/image-minimizer.js');
|
||||
await script({github, context});
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -8,8 +8,8 @@ captures/
|
|||
*~
|
||||
.weblate
|
||||
*.class
|
||||
**/debug/
|
||||
**/release/
|
||||
app/debug/
|
||||
app/release/
|
||||
|
||||
# vscode / eclipse files
|
||||
*.classpath
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
plugins {
|
||||
id "org.sonarqube" version "3.1.1"
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "kotlin-kapt"
|
||||
id "kotlin-parcelize"
|
||||
id "checkstyle"
|
||||
id "org.sonarqube" version "3.3"
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'checkstyle'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdk 30
|
||||
buildToolsVersion '30.0.3'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.polymorphicshade.newpipe"
|
||||
resValue "string", "app_name", "NewPipe SponsorBlock"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 29
|
||||
versionCode 982
|
||||
versionName "0.21.16"
|
||||
minSdk 19
|
||||
targetSdk 29
|
||||
versionCode 983
|
||||
versionName "0.22.0"
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
|
@ -66,7 +65,7 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
lint {
|
||||
checkReleaseBuilds false
|
||||
// Or, if you prefer, you can continue to check for errors in release builds,
|
||||
// but continue the build even when errors are found:
|
||||
|
@ -80,13 +79,13 @@ android {
|
|||
// Flag to enable support for the new language APIs
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
encoding 'utf-8'
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8
|
||||
jvmTarget = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
@ -99,20 +98,21 @@ android {
|
|||
}
|
||||
|
||||
ext {
|
||||
checkstyleVersion = '8.38'
|
||||
checkstyleVersion = '9.2.1'
|
||||
|
||||
androidxLifecycleVersion = '2.3.1'
|
||||
androidxRoomVersion = '2.3.0'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.14.2'
|
||||
googleAutoServiceVersion = '1.0'
|
||||
googleAutoServiceVersion = '1.0.1'
|
||||
groupieVersion = '2.10.0'
|
||||
markwonVersion = '4.6.2'
|
||||
|
||||
leakCanaryVersion = '2.5'
|
||||
stethoVersion = '1.6.0'
|
||||
mockitoVersion = '3.6.0'
|
||||
mockitoVersion = '4.0.0'
|
||||
assertJVersion = '3.22.0'
|
||||
}
|
||||
|
||||
configurations {
|
||||
|
@ -189,11 +189,11 @@ dependencies {
|
|||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.13'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.14'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
ktlint 'com.pinterest:ktlint:0.40.0'
|
||||
ktlint 'com.pinterest:ktlint:0.43.2'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||
|
@ -201,7 +201,7 @@ dependencies {
|
|||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
|
@ -220,7 +220,7 @@ dependencies {
|
|||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.2.1'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
|
@ -228,7 +228,7 @@ dependencies {
|
|||
kapt "frankiesardo:icepick-processor:${icepickVersion}"
|
||||
|
||||
// HTML parser
|
||||
implementation "org.jsoup:jsoup:1.13.1"
|
||||
implementation "org.jsoup:jsoup:1.14.3"
|
||||
|
||||
// HTTP client
|
||||
//noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users
|
||||
|
@ -266,13 +266,13 @@ dependencies {
|
|||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||
|
||||
// Reactive extensions for Java VM
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.0.7"
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.0.13"
|
||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
|
||||
// RxJava binding APIs for Android UI widgets
|
||||
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
|
||||
|
||||
// Date and time formatting
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.1.Final"
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.2.Final"
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
|
@ -288,11 +288,10 @@ dependencies {
|
|||
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
|
||||
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
|
||||
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.3"
|
||||
androidTestImplementation "androidx.test:runner:1.4.0"
|
||||
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0", {
|
||||
exclude module: 'support-annotations'
|
||||
}
|
||||
androidTestImplementation "org.assertj:assertj-core:${assertJVersion}"
|
||||
}
|
||||
|
||||
static String getGitWorkingBranch() {
|
||||
|
|
|
@ -0,0 +1,178 @@
|
|||
package org.schabi.newpipe.local.history
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
import org.schabi.newpipe.testUtil.TestDatabase
|
||||
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class HistoryRecordManagerTest {
|
||||
|
||||
private lateinit var manager: HistoryRecordManager
|
||||
private lateinit var database: AppDatabase
|
||||
|
||||
@get:Rule
|
||||
val trampolineScheduler = TrampolineSchedulerRule()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
database = TestDatabase.createReplacingNewPipeDatabase()
|
||||
manager = HistoryRecordManager(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSearched() {
|
||||
manager.onSearched(0, "Hello").test().await().assertValue(1)
|
||||
|
||||
// For some reason the Flowable returned by getAll() never completes, so we can't assert
|
||||
// that the number of Lists it returns is exactly 1, we can only check if the first List is
|
||||
// correct. Why on earth has a Flowable been used instead of a Single for getAll()?!?
|
||||
val entities = database.searchHistoryDAO().all.blockingFirst()
|
||||
assertThat(entities).hasSize(1)
|
||||
assertThat(entities[0].id).isEqualTo(1)
|
||||
assertThat(entities[0].serviceId).isEqualTo(0)
|
||||
assertThat(entities[0].search).isEqualTo("Hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSearchHistory() {
|
||||
val entries = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(1), 0, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 2, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 1, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 0, "B"),
|
||||
)
|
||||
|
||||
// make sure all 4 were inserted
|
||||
database.searchHistoryDAO().insertAll(entries)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
|
||||
|
||||
// try to delete only "A" entries, "B" entries should be untouched
|
||||
manager.deleteSearchHistory("A").test().await().assertValue(2)
|
||||
val entities = database.searchHistoryDAO().all.blockingFirst()
|
||||
assertThat(entities).hasSize(2)
|
||||
assertThat(entities).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
|
||||
.containsExactly(*entries.subList(2, 4).toTypedArray())
|
||||
|
||||
// assert that nothing happens if we delete a search query that does exist in the db
|
||||
manager.deleteSearchHistory("A").test().await().assertValue(0)
|
||||
val entities2 = database.searchHistoryDAO().all.blockingFirst()
|
||||
assertThat(entities2).hasSize(2)
|
||||
assertThat(entities2).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
|
||||
.containsExactly(*entries.subList(2, 4).toTypedArray())
|
||||
|
||||
// delete all remaining entries
|
||||
manager.deleteSearchHistory("B").test().await().assertValue(2)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteCompleteSearchHistory() {
|
||||
val entries = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 2, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 0, "C"),
|
||||
)
|
||||
|
||||
// make sure all 3 were inserted
|
||||
database.searchHistoryDAO().insertAll(entries)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
|
||||
|
||||
// should remove everything
|
||||
manager.deleteCompleteSearchHistory().test().await().assertValue(entries.size)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
|
||||
}
|
||||
|
||||
private fun insertShuffledRelatedSearches(relatedSearches: Collection<SearchHistoryEntry>) {
|
||||
|
||||
// shuffle to make sure the order of items returned by queries depends only on
|
||||
// SearchHistoryEntry.creationDate, not on the actual insertion time, so that we can
|
||||
// verify that the `ORDER BY` clause does its job
|
||||
database.searchHistoryDAO().insertAll(relatedSearches.shuffled())
|
||||
|
||||
// make sure all entries were inserted
|
||||
assertEquals(
|
||||
relatedSearches.size,
|
||||
database.searchHistoryDAO().all.blockingFirst().size
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRelatedSearches_emptyQuery() {
|
||||
insertShuffledRelatedSearches(RELATED_SEARCHES_ENTRIES)
|
||||
|
||||
// make sure correct number of searches is returned and in correct order
|
||||
val searches = manager.getRelatedSearches("", 6, 4).blockingFirst()
|
||||
assertThat(searches).containsExactly(
|
||||
RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places)
|
||||
RELATED_SEARCHES_ENTRIES[4].search, // B
|
||||
RELATED_SEARCHES_ENTRIES[5].search, // AA
|
||||
RELATED_SEARCHES_ENTRIES[2].search, // BA
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRelatedSearches_emptyQuery_manyDuplicates() {
|
||||
insertShuffledRelatedSearches(
|
||||
listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(9), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(8), 3, "AB"),
|
||||
SearchHistoryEntry(time.minusSeconds(7), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(6), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(5), 3, "BA"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 0, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(1), 2, "AA"),
|
||||
)
|
||||
)
|
||||
|
||||
val searches = manager.getRelatedSearches("", 9, 3).blockingFirst()
|
||||
assertThat(searches).containsExactly("AA", "A", "BA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRelatedSearched_nonEmptyQuery() {
|
||||
insertShuffledRelatedSearches(RELATED_SEARCHES_ENTRIES)
|
||||
|
||||
// make sure correct number of searches is returned and in correct order
|
||||
val searches = manager.getRelatedSearches("A", 3, 5).blockingFirst()
|
||||
assertThat(searches).containsExactly(
|
||||
RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places)
|
||||
RELATED_SEARCHES_ENTRIES[5].search, // AA
|
||||
RELATED_SEARCHES_ENTRIES[1].search, // BA
|
||||
)
|
||||
|
||||
// also make sure that the string comparison is case insensitive
|
||||
val searches2 = manager.getRelatedSearches("a", 3, 5).blockingFirst()
|
||||
assertThat(searches).isEqualTo(searches2)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val time = OffsetDateTime.of(LocalDateTime.of(2000, 1, 1, 1, 1), ZoneOffset.UTC)
|
||||
|
||||
private val RELATED_SEARCHES_ENTRIES = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(7), 2, "AC"),
|
||||
SearchHistoryEntry(time.minusSeconds(6), 0, "ABC"),
|
||||
SearchHistoryEntry(time.minusSeconds(5), 1, "BA"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 0, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 2, "AA"),
|
||||
SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,17 +1,14 @@
|
|||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.testUtil.TestDatabase
|
||||
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class LocalPlaylistManagerTest {
|
||||
|
||||
|
@ -21,18 +18,9 @@ class LocalPlaylistManagerTest {
|
|||
@get:Rule
|
||||
val trampolineScheduler = TrampolineSchedulerRule()
|
||||
|
||||
@get:Rule
|
||||
val timeout = Timeout(10, TimeUnit.SECONDS)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
|
||||
database = TestDatabase.createReplacingNewPipeDatabase()
|
||||
manager = LocalPlaylistManager(database)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package org.schabi.newpipe.testUtil
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertSame
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
|
||||
class TestDatabase {
|
||||
companion object {
|
||||
fun createReplacingNewPipeDatabase(): AppDatabase {
|
||||
val database = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
|
||||
val databaseField = NewPipeDatabase::class.java.getDeclaredField("databaseInstance")
|
||||
databaseField.isAccessible = true
|
||||
databaseField.set(NewPipeDatabase::class, database)
|
||||
|
||||
assertSame(
|
||||
"Mocking database failed!",
|
||||
database,
|
||||
NewPipeDatabase.getInstance(ApplicationProvider.getApplicationContext())
|
||||
)
|
||||
|
||||
return database
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import leakcanary.LeakCanary;
|
||||
|
||||
/**
|
||||
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
|
||||
* This class is loaded via reflection by
|
||||
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
|
||||
*/
|
||||
@SuppressWarnings("unused") // Class is used but loaded via reflection
|
||||
public class DebugSettingsBVDLeakCanary
|
||||
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
|
||||
|
||||
@Override
|
||||
public Intent getNewLeakDisplayActivityIntent() {
|
||||
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
|
||||
import leakcanary.LeakCanary;
|
||||
|
||||
public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.debug_settings);
|
||||
|
||||
final Preference showMemoryLeaksPreference
|
||||
= findPreference(getString(R.string.show_memory_leaks_key));
|
||||
final Preference showImageIndicatorsPreference
|
||||
= findPreference(getString(R.string.show_image_indicators_key));
|
||||
final Preference crashTheAppPreference
|
||||
= findPreference(getString(R.string.crash_the_app_key));
|
||||
|
||||
assert showMemoryLeaksPreference != null;
|
||||
assert showImageIndicatorsPreference != null;
|
||||
assert crashTheAppPreference != null;
|
||||
|
||||
showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> {
|
||||
startActivity(LeakCanary.INSTANCE.newLeakDisplayActivityIntent());
|
||||
return true;
|
||||
});
|
||||
|
||||
showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
PicassoHelper.setIndicatorsEnabled((Boolean) newValue);
|
||||
return true;
|
||||
});
|
||||
|
||||
crashTheAppPreference.setOnPreferenceClickListener(preference -> {
|
||||
throw new RuntimeException();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -335,6 +335,7 @@
|
|||
<data android:host="video.ploud.fr" />
|
||||
<data android:host="video.lqdn.fr" />
|
||||
<data android:host="skeptikon.fr" />
|
||||
<data android:host="media.fsfe.org" />
|
||||
|
||||
<data android:pathPrefix="/videos/" /> <!-- it contains playlists -->
|
||||
<data android:pathPrefix="/w/" /> <!-- short video URLs -->
|
||||
|
|
|
@ -63,6 +63,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
|
|||
return consumed == dy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
|
||||
@NonNull final AppBarLayout child,
|
||||
@NonNull final MotionEvent ev) {
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.apache.commons.text.similarity;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A matching algorithm that is similar to the searching algorithms implemented in editors such
|
||||
* as Sublime Text, TextMate, Atom and others.
|
||||
*
|
||||
* <p>
|
||||
* One point is given for every matched character. Subsequent matches yield two bonus points.
|
||||
* A higher score indicates a higher similarity.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This code has been adapted from Apache Commons Lang 3.3.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* Note: This class was forked from
|
||||
* <a href="https://git.io/JyYJg">
|
||||
* apache/commons-text (8cfdafc) FuzzyScore.java
|
||||
* </a>
|
||||
*/
|
||||
public class FuzzyScore {
|
||||
|
||||
/**
|
||||
* Locale used to change the case of text.
|
||||
*/
|
||||
private final Locale locale;
|
||||
|
||||
|
||||
/**
|
||||
* This returns a {@link Locale}-specific {@link FuzzyScore}.
|
||||
*
|
||||
* @param locale The string matching logic is case insensitive.
|
||||
A {@link Locale} is necessary to normalize both Strings to lower case.
|
||||
* @throws IllegalArgumentException
|
||||
* This is thrown if the {@link Locale} parameter is {@code null}.
|
||||
*/
|
||||
public FuzzyScore(final Locale locale) {
|
||||
if (locale == null) {
|
||||
throw new IllegalArgumentException("Locale must not be null");
|
||||
}
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Fuzzy Score which indicates the similarity score between two
|
||||
* Strings.
|
||||
*
|
||||
* <pre>
|
||||
* score.fuzzyScore(null, null) = IllegalArgumentException
|
||||
* score.fuzzyScore("not null", null) = IllegalArgumentException
|
||||
* score.fuzzyScore(null, "not null") = IllegalArgumentException
|
||||
* score.fuzzyScore("", "") = 0
|
||||
* score.fuzzyScore("Workshop", "b") = 0
|
||||
* score.fuzzyScore("Room", "o") = 1
|
||||
* score.fuzzyScore("Workshop", "w") = 1
|
||||
* score.fuzzyScore("Workshop", "ws") = 2
|
||||
* score.fuzzyScore("Workshop", "wo") = 4
|
||||
* score.fuzzyScore("Apache Software Foundation", "asf") = 3
|
||||
* </pre>
|
||||
*
|
||||
* @param term a full term that should be matched against, must not be null
|
||||
* @param query the query that will be matched against a term, must not be
|
||||
* null
|
||||
* @return result score
|
||||
* @throws IllegalArgumentException if the term or query is {@code null}
|
||||
*/
|
||||
public Integer fuzzyScore(final CharSequence term, final CharSequence query) {
|
||||
if (term == null || query == null) {
|
||||
throw new IllegalArgumentException("CharSequences must not be null");
|
||||
}
|
||||
|
||||
// fuzzy logic is case insensitive. We normalize the Strings to lower
|
||||
// case right from the start. Turning characters to lower case
|
||||
// via Character.toLowerCase(char) is unfortunately insufficient
|
||||
// as it does not accept a locale.
|
||||
final String termLowerCase = term.toString().toLowerCase(locale);
|
||||
final String queryLowerCase = query.toString().toLowerCase(locale);
|
||||
|
||||
// the resulting score
|
||||
int score = 0;
|
||||
|
||||
// the position in the term which will be scanned next for potential
|
||||
// query character matches
|
||||
int termIndex = 0;
|
||||
|
||||
// index of the previously matched character in the term
|
||||
int previousMatchingCharacterIndex = Integer.MIN_VALUE;
|
||||
|
||||
for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) {
|
||||
final char queryChar = queryLowerCase.charAt(queryIndex);
|
||||
|
||||
boolean termCharacterMatchFound = false;
|
||||
for (; termIndex < termLowerCase.length()
|
||||
&& !termCharacterMatchFound; termIndex++) {
|
||||
final char termChar = termLowerCase.charAt(termIndex);
|
||||
|
||||
if (queryChar == termChar) {
|
||||
// simple character matches result in one point
|
||||
score++;
|
||||
|
||||
// subsequent character matches further improve
|
||||
// the score.
|
||||
if (previousMatchingCharacterIndex + 1 == termIndex) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
previousMatchingCharacterIndex = termIndex;
|
||||
|
||||
// we can leave the nested loop. Every character in the
|
||||
// query can match at most one character in the term.
|
||||
termCharacterMatchFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the locale.
|
||||
*
|
||||
* @return The locale
|
||||
*/
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
}
|
|
@ -16,8 +16,8 @@ import org.acra.ACRA;
|
|||
import org.acra.config.ACRAConfigurationException;
|
||||
import org.acra.config.CoreConfiguration;
|
||||
import org.acra.config.CoreConfigurationBuilder;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
|
@ -217,7 +217,7 @@ public class App extends MultiDexApplication {
|
|||
ACRA.init(this, acraConfig);
|
||||
} catch (final ACRAConfigurationException exception) {
|
||||
exception.printStackTrace();
|
||||
ErrorActivity.reportError(this, new ErrorInfo(exception,
|
||||
ErrorUtil.openActivity(this, new ErrorInfo(exception,
|
||||
UserAction.SOMETHING_ELSE, "Could not initialize ACRA crash report"));
|
||||
}
|
||||
}
|
||||
|
@ -227,28 +227,35 @@ public class App extends MultiDexApplication {
|
|||
// the main and update channels
|
||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
||||
appUpdateChannel, hashChannel));
|
||||
appUpdateChannel, hashChannel, errorReportChannel));
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
|
|
|
@ -77,6 +77,13 @@ public final class CheckForNewAppVersion {
|
|||
notificationManager.notify(2000, notificationBuilder.build());
|
||||
}
|
||||
|
||||
public static boolean isReleaseApk(@NonNull final App app) {
|
||||
// TODO: hope this isn't gonna be an issue
|
||||
//return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isConnected(@NonNull final App app) {
|
||||
final ConnectivityManager connectivityManager =
|
||||
ContextCompat.getSystemService(app, ConnectivityManager.class);
|
||||
|
|
|
@ -63,7 +63,7 @@ import org.schabi.newpipe.databinding.DrawerHeaderBinding;
|
|||
import org.schabi.newpipe.databinding.DrawerLayoutBinding;
|
||||
import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding;
|
||||
import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
|
@ -157,7 +157,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
try {
|
||||
setupDrawer();
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Setting up drawer", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||
}
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
|
@ -208,7 +208,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
/**
|
||||
* Builds the drawer menu for the current service.
|
||||
*
|
||||
* @throws ExtractionException
|
||||
* @throws ExtractionException if the service didn't provide available kiosks
|
||||
*/
|
||||
private void addDrawerMenuForCurrentService() throws ExtractionException {
|
||||
//Tabs
|
||||
|
@ -260,7 +260,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
try {
|
||||
tabSelected(item);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Selecting main page tab", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
|
||||
}
|
||||
break;
|
||||
case R.id.menu_options_about_group:
|
||||
|
@ -366,7 +366,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
try {
|
||||
addDrawerMenuForCurrentService();
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Showing main page tabs", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Showing main page tabs", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -469,7 +469,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
drawerHeaderBinding.drawerHeaderActionButton.setContentDescription(
|
||||
getString(R.string.drawer_header_description) + selectedServiceName);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Setting up service toggle", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
|
||||
}
|
||||
|
||||
final SharedPreferences sharedPreferences
|
||||
|
@ -779,7 +779,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
NavigationHelper.gotoMainFragment(getSupportFragmentManager());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Handling intent", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Handling intent", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
|||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
|
@ -61,11 +62,13 @@ public final class QueueItemMenuUtil {
|
|||
|
||||
return true;
|
||||
case R.id.menu_item_channel_details:
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
NavigationHelper.openChannelFragmentUsingIntent(context, item.getServiceId(),
|
||||
item.getUploaderUrl(), item.getUploader());
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent(
|
||||
context, item.getServiceId(), uploaderUrl, item.getUploader()
|
||||
));
|
||||
return true;
|
||||
case R.id.menu_item_share:
|
||||
shareText(context, item.getTitle(), item.getUrl(),
|
||||
|
|
|
@ -37,8 +37,8 @@ import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.Info;
|
||||
|
@ -231,7 +231,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||
} else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
|
||||
Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
ErrorActivity.reportError(context, errorInfo);
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
if (context instanceof RouterActivity) {
|
||||
|
|
|
@ -185,7 +185,11 @@ class AboutActivity : AppCompatActivity() {
|
|||
SoftwareComponent(
|
||||
"RxJava", "2016 - 2020", "RxJava Contributors",
|
||||
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
|
||||
)
|
||||
),
|
||||
SoftwareComponent(
|
||||
"SearchPreference", "2018", "ByteHamster",
|
||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
private const val POS_ABOUT = 0
|
||||
private const val POS_LICENSE = 1
|
||||
|
|
|
@ -19,6 +19,7 @@ import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE
|
|||
@Dao
|
||||
public interface SearchHistoryDAO extends HistoryDAO<SearchHistoryEntry> {
|
||||
String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC";
|
||||
String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC";
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME
|
||||
+ " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")")
|
||||
|
@ -36,16 +37,16 @@ public interface SearchHistoryDAO extends HistoryDAO<SearchHistoryEntry> {
|
|||
@Override
|
||||
Flowable<List<SearchHistoryEntry>> getAll();
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME + " GROUP BY " + SEARCH + ORDER_BY_CREATION_DATE
|
||||
+ " LIMIT :limit")
|
||||
Flowable<List<SearchHistoryEntry>> getUniqueEntries(int limit);
|
||||
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH
|
||||
+ ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
|
||||
Flowable<List<String>> getUniqueEntries(int limit);
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME
|
||||
+ " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)
|
||||
@Override
|
||||
Flowable<List<SearchHistoryEntry>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
|
||||
+ " GROUP BY " + SEARCH + " LIMIT :limit")
|
||||
Flowable<List<SearchHistoryEntry>> getSimilarEntries(String query, int limit);
|
||||
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
|
||||
+ " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
|
||||
Flowable<List<String>> getSimilarEntries(String query, int limit);
|
||||
}
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
package org.schabi.newpipe.database.history.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH;
|
||||
|
||||
@Entity(tableName = SearchHistoryEntry.TABLE_NAME,
|
||||
indices = {@Index(value = SEARCH)})
|
||||
public class SearchHistoryEntry {
|
||||
public static final String ID = "id";
|
||||
public static final String TABLE_NAME = "search_history";
|
||||
public static final String SERVICE_ID = "service_id";
|
||||
public static final String CREATION_DATE = "creation_date";
|
||||
public static final String SEARCH = "search";
|
||||
|
||||
@ColumnInfo(name = ID)
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long id;
|
||||
|
||||
@ColumnInfo(name = CREATION_DATE)
|
||||
private OffsetDateTime creationDate;
|
||||
|
||||
@ColumnInfo(name = SERVICE_ID)
|
||||
private int serviceId;
|
||||
|
||||
@ColumnInfo(name = SEARCH)
|
||||
private String search;
|
||||
|
||||
public SearchHistoryEntry(final OffsetDateTime creationDate, final int serviceId,
|
||||
final String search) {
|
||||
this.serviceId = serviceId;
|
||||
this.creationDate = creationDate;
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(final long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreationDate() {
|
||||
return creationDate;
|
||||
}
|
||||
|
||||
public void setCreationDate(final OffsetDateTime creationDate) {
|
||||
this.creationDate = creationDate;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(final int serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getSearch() {
|
||||
return search;
|
||||
}
|
||||
|
||||
public void setSearch(final String search) {
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public boolean hasEqualValues(final SearchHistoryEntry otherEntry) {
|
||||
return getServiceId() == otherEntry.getServiceId()
|
||||
&& getSearch().equals(otherEntry.getSearch());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Entity(
|
||||
tableName = SearchHistoryEntry.TABLE_NAME,
|
||||
indices = [Index(value = [SearchHistoryEntry.SEARCH])]
|
||||
)
|
||||
data class SearchHistoryEntry(
|
||||
@field:ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?,
|
||||
@field:ColumnInfo(
|
||||
name = SERVICE_ID
|
||||
) var serviceId: Int,
|
||||
@field:ColumnInfo(name = SEARCH) var search: String?
|
||||
) {
|
||||
@ColumnInfo(name = ID)
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0
|
||||
|
||||
@Ignore
|
||||
fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean {
|
||||
return (
|
||||
serviceId == otherEntry.serviceId &&
|
||||
search == otherEntry.search
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID = "id"
|
||||
const val TABLE_NAME = "search_history"
|
||||
const val SERVICE_ID = "service_id"
|
||||
const val CREATION_DATE = "creation_date"
|
||||
const val SEARCH = "search"
|
||||
}
|
||||
}
|
|
@ -41,8 +41,8 @@ import com.nononsenseapps.filepicker.Utils;
|
|||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.DownloadDialogBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.MediaFormat;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
|
@ -53,6 +53,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|||
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
|
@ -409,7 +410,7 @@ public class DownloadDialog extends DialogFragment
|
|||
== R.id.video_button) {
|
||||
setupVideoSpinner();
|
||||
}
|
||||
}, throwable -> ErrorActivity.reportErrorInSnackbar(context,
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading video stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
|
@ -419,7 +420,7 @@ public class DownloadDialog extends DialogFragment
|
|||
== R.id.audio_button) {
|
||||
setupAudioSpinner();
|
||||
}
|
||||
}, throwable -> ErrorActivity.reportErrorInSnackbar(context,
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading audio stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
|
@ -429,7 +430,7 @@ public class DownloadDialog extends DialogFragment
|
|||
== R.id.subtitle_button) {
|
||||
setupSubtitleSpinner();
|
||||
}
|
||||
}, throwable -> ErrorActivity.reportErrorInSnackbar(context,
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading subtitle stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
|
@ -694,7 +695,12 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
||||
launcher.launch(StoredDirectoryHelper.getPicker(context));
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
launcher,
|
||||
StoredDirectoryHelper.getPicker(context),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
private void prepareSelectedDownload() {
|
||||
|
@ -773,8 +779,12 @@ public class DownloadDialog extends DialogFragment
|
|||
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
||||
}
|
||||
|
||||
requestDownloadSaveAsLauncher.launch(StoredFileHelper.getNewPicker(context,
|
||||
filenameTmp, mimeTmp, initialPath));
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestDownloadSaveAsLauncher,
|
||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -806,7 +816,7 @@ public class DownloadDialog extends DialogFragment
|
|||
mainStorage.getTag());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportErrorInSnackbar(this,
|
||||
ErrorUtil.createNotification(requireContext(),
|
||||
new ErrorInfo(e, UserAction.DOWNLOAD_FAILED, "Getting storage"));
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -33,12 +33,11 @@ public class AcraReportSender implements ReportSender {
|
|||
|
||||
@Override
|
||||
public void send(@NonNull final Context context, @NonNull final CrashReportData report) {
|
||||
ErrorActivity.reportError(context, new ErrorInfo(
|
||||
ErrorUtil.openActivity(context, new ErrorInfo(
|
||||
new String[]{report.getString(ReportField.STACK_TRACE)},
|
||||
UserAction.UI_ERROR,
|
||||
ErrorInfo.SERVICE_NONE,
|
||||
"ACRA report",
|
||||
R.string.app_ui_crash,
|
||||
null));
|
||||
R.string.app_ui_crash));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
package org.schabi.newpipe.error;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ensures that a Exception is serializable.
|
||||
* This is
|
||||
*/
|
||||
public final class EnsureExceptionSerializable {
|
||||
private static final String TAG = "EnsureExSerializable";
|
||||
|
||||
private EnsureExceptionSerializable() {
|
||||
// No instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that an exception is serializable.
|
||||
* <br/>
|
||||
* If that is not the case a {@link WorkaroundNotSerializableException} is created.
|
||||
*
|
||||
* @param exception
|
||||
* @return if an exception is not serializable a new {@link WorkaroundNotSerializableException}
|
||||
* otherwise the exception from the parameter
|
||||
*/
|
||||
public static Exception ensureSerializable(@NonNull final Exception exception) {
|
||||
return checkIfSerializable(exception)
|
||||
? exception
|
||||
: WorkaroundNotSerializableException.create(exception);
|
||||
}
|
||||
|
||||
public static boolean checkIfSerializable(@NonNull final Exception exception) {
|
||||
try {
|
||||
// Check by creating a new ObjectOutputStream which does the serialization
|
||||
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ObjectOutputStream oos = new ObjectOutputStream(bos)
|
||||
) {
|
||||
oos.writeObject(exception);
|
||||
oos.flush();
|
||||
|
||||
bos.toByteArray();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (final IOException ex) {
|
||||
Log.d(TAG, "Exception is not serializable", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WorkaroundNotSerializableException extends Exception {
|
||||
protected WorkaroundNotSerializableException(
|
||||
final Throwable notSerializableException,
|
||||
final Throwable cause) {
|
||||
super(notSerializableException.toString(), cause);
|
||||
setStackTrace(notSerializableException.getStackTrace());
|
||||
}
|
||||
|
||||
protected WorkaroundNotSerializableException(final Throwable notSerializableException) {
|
||||
super(notSerializableException.toString());
|
||||
setStackTrace(notSerializableException.getStackTrace());
|
||||
}
|
||||
|
||||
public static WorkaroundNotSerializableException create(
|
||||
@NonNull final Exception notSerializableException
|
||||
) {
|
||||
// Build a list of the exception + all causes
|
||||
final List<Throwable> throwableList = new ArrayList<>();
|
||||
|
||||
int pos = 0;
|
||||
Throwable throwableToProcess = notSerializableException;
|
||||
|
||||
while (throwableToProcess != null) {
|
||||
throwableList.add(throwableToProcess);
|
||||
|
||||
pos++;
|
||||
throwableToProcess = throwableToProcess.getCause();
|
||||
}
|
||||
|
||||
// Reverse list so that it starts with the last one
|
||||
Collections.reverse(throwableList);
|
||||
|
||||
// Build exception stack
|
||||
WorkaroundNotSerializableException cause = null;
|
||||
for (final Throwable t : throwableList) {
|
||||
cause = cause == null
|
||||
? new WorkaroundNotSerializableException(t)
|
||||
: new WorkaroundNotSerializableException(t, cause);
|
||||
}
|
||||
|
||||
return cause;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package org.schabi.newpipe.error;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
@ -12,16 +12,12 @@ import android.util.Log;
|
|||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
|
@ -29,15 +25,13 @@ import org.schabi.newpipe.MainActivity;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ActivityErrorBinding;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 24.10.15.
|
||||
*
|
||||
|
@ -58,6 +52,10 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
|||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This activity is used to show error details and allow reporting them in various ways. Use {@link
|
||||
* ErrorUtil#openActivity(Context, ErrorInfo)} to correctly open this activity.
|
||||
*/
|
||||
public class ErrorActivity extends AppCompatActivity {
|
||||
// LOG TAGS
|
||||
public static final String TAG = ErrorActivity.class.toString();
|
||||
|
@ -79,74 +77,6 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
|
||||
private ActivityErrorBinding activityErrorBinding;
|
||||
|
||||
/**
|
||||
* Reports a new error by starting a new activity.
|
||||
* <br/>
|
||||
* Ensure that the data within errorInfo is serializable otherwise
|
||||
* an exception will be thrown!<br/>
|
||||
* {@link EnsureExceptionSerializable} might help.
|
||||
*
|
||||
* @param context
|
||||
* @param errorInfo
|
||||
*/
|
||||
public static void reportError(final Context context, final ErrorInfo errorInfo) {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
||||
if (prefs.getBoolean(context
|
||||
.getString(R.string.disable_error_reports_key), false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Intent intent = new Intent(context, ErrorActivity.class);
|
||||
intent.putExtra(ERROR_INFO, errorInfo);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
public static void reportErrorInSnackbar(final Context context, final ErrorInfo errorInfo) {
|
||||
final View rootView = context instanceof Activity
|
||||
? ((Activity) context).findViewById(android.R.id.content) : null;
|
||||
reportErrorInSnackbar(context, rootView, errorInfo);
|
||||
}
|
||||
|
||||
public static void reportErrorInSnackbar(final Fragment fragment, final ErrorInfo errorInfo) {
|
||||
View rootView = fragment.getView();
|
||||
if (rootView == null && fragment.getActivity() != null) {
|
||||
rootView = fragment.getActivity().findViewById(android.R.id.content);
|
||||
}
|
||||
reportErrorInSnackbar(fragment.requireContext(), rootView, errorInfo);
|
||||
}
|
||||
|
||||
public static void reportUiErrorInSnackbar(final Context context,
|
||||
final String request,
|
||||
final Throwable throwable) {
|
||||
reportErrorInSnackbar(context, new ErrorInfo(throwable, UserAction.UI_ERROR, request));
|
||||
}
|
||||
|
||||
public static void reportUiErrorInSnackbar(final Fragment fragment,
|
||||
final String request,
|
||||
final Throwable throwable) {
|
||||
reportErrorInSnackbar(fragment, new ErrorInfo(throwable, UserAction.UI_ERROR, request));
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private static void reportErrorInSnackbar(final Context context,
|
||||
@Nullable final View rootView,
|
||||
final ErrorInfo errorInfo) {
|
||||
if (rootView != null) {
|
||||
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.setAction(context.getString(R.string.error_snackbar_action).toUpperCase(), v ->
|
||||
reportError(context, errorInfo)).show();
|
||||
} else {
|
||||
reportError(context, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// Activity lifecycle
|
||||
|
|
|
@ -2,6 +2,8 @@ package org.schabi.newpipe.error
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
|
@ -21,11 +23,14 @@ class ErrorInfo(
|
|||
val userAction: UserAction,
|
||||
val serviceName: String,
|
||||
val request: String,
|
||||
val messageStringId: Int,
|
||||
@Transient // no need to store throwable, all data for report is in other variables
|
||||
var throwable: Throwable? = null
|
||||
val messageStringId: Int
|
||||
) : Parcelable {
|
||||
|
||||
// no need to store throwable, all data for report is in other variables
|
||||
// also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
|
||||
@IgnoredOnParcel
|
||||
var throwable: Throwable? = null
|
||||
|
||||
private constructor(
|
||||
throwable: Throwable,
|
||||
userAction: UserAction,
|
||||
|
@ -36,9 +41,10 @@ class ErrorInfo(
|
|||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable, userAction),
|
||||
throwable
|
||||
)
|
||||
getMessageStringId(throwable, userAction)
|
||||
) {
|
||||
this.throwable = throwable
|
||||
}
|
||||
|
||||
private constructor(
|
||||
throwable: List<Throwable>,
|
||||
|
@ -50,9 +56,10 @@ class ErrorInfo(
|
|||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable.firstOrNull(), userAction),
|
||||
throwable.firstOrNull()
|
||||
)
|
||||
getMessageStringId(throwable.firstOrNull(), userAction)
|
||||
) {
|
||||
this.throwable = throwable.firstOrNull()
|
||||
}
|
||||
|
||||
// constructors with single throwable
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||
|
@ -102,6 +109,13 @@ class ErrorInfo(
|
|||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||
throwable is DeobfuscateException -> R.string.youtube_signature_deobfuscation_error
|
||||
throwable is ExtractionException -> R.string.parsing_error
|
||||
throwable is ExoPlaybackException -> {
|
||||
when (throwable.type) {
|
||||
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
|
||||
ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
|
||||
else -> R.string.player_unrecoverable_failure
|
||||
}
|
||||
}
|
||||
action == UserAction.UI_ERROR -> R.string.app_ui_crash
|
||||
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
|
||||
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
|
||||
|
|
|
@ -118,7 +118,7 @@ class ErrorPanelHelper(
|
|||
showAndSetErrorButtonAction(
|
||||
R.string.error_snackbar_action
|
||||
) {
|
||||
ErrorActivity.reportError(context, errorInfo)
|
||||
ErrorUtil.openActivity(context, errorInfo)
|
||||
}
|
||||
|
||||
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
|
||||
|
@ -178,7 +178,7 @@ class ErrorPanelHelper(
|
|||
val DEBUG: Boolean = MainActivity.DEBUG
|
||||
|
||||
@StringRes
|
||||
public fun getExceptionDescription(throwable: Throwable?): Int {
|
||||
fun getExceptionDescription(throwable: Throwable?): Int {
|
||||
return when (throwable) {
|
||||
is AgeRestrictedContentException -> R.string.restricted_video_no_stream
|
||||
is GeographicRestrictionException -> R.string.georestricted_content
|
||||
|
|
185
app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
Normal file
185
app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
Normal file
|
@ -0,0 +1,185 @@
|
|||
package org.schabi.newpipe.error
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
/**
|
||||
* This class contains all of the methods that should be used to let the user know that an error has
|
||||
* occurred in the least intrusive way possible for each case. This class is for unexpected errors,
|
||||
* for handled errors (e.g. network errors) use e.g. [ErrorPanelHelper] instead.
|
||||
* - Use a snackbar if the exception is not critical and it happens in a place where a root view
|
||||
* is available.
|
||||
* - Use a notification if the exception happens inside a background service (player, subscription
|
||||
* import, ...) or there is no activity/fragment from which to extract a root view.
|
||||
* - Finally use the error activity only as a last resort in case the exception is critical and
|
||||
* happens in an open activity (since the workflow would be interrupted anyway in that case).
|
||||
*/
|
||||
class ErrorUtil {
|
||||
companion object {
|
||||
private const val ERROR_REPORT_NOTIFICATION_ID = 5340681
|
||||
|
||||
/**
|
||||
* Starts a new error activity allowing the user to report the provided error. Only use this
|
||||
* method directly as a last resort in case the exception is critical and happens in an open
|
||||
* activity (since the workflow would be interrupted anyway in that case). So never use this
|
||||
* for background services.
|
||||
*
|
||||
* @param context the context to use to start the new activity
|
||||
* @param errorInfo the error info to be reported
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openActivity(context: Context, errorInfo: ErrorInfo) {
|
||||
if (getIsErrorReportsDisabled(context)) {
|
||||
return
|
||||
}
|
||||
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error activity.
|
||||
* Use this method if the exception is not critical and it happens in a place where a root
|
||||
* view is available.
|
||||
*
|
||||
* @param context will be used to obtain the root view if it is an [Activity]; if no root
|
||||
* view can be found an error notification is shown instead
|
||||
* @param errorInfo the error info to be reported
|
||||
*/
|
||||
@JvmStatic
|
||||
fun showSnackbar(context: Context, errorInfo: ErrorInfo) {
|
||||
val rootView = if (context is Activity) context.findViewById<View>(R.id.content) else null
|
||||
showSnackbar(context, rootView, errorInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error activity.
|
||||
* Use this method if the exception is not critical and it happens in a place where a root
|
||||
* view is available.
|
||||
*
|
||||
* @param fragment will be used to obtain the root view if it has a connected [Activity]; if
|
||||
* no root view can be found an error notification is shown instead
|
||||
* @param errorInfo the error info to be reported
|
||||
*/
|
||||
@JvmStatic
|
||||
fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) {
|
||||
var rootView = fragment.view
|
||||
if (rootView == null && fragment.activity != null) {
|
||||
rootView = fragment.requireActivity().findViewById(R.id.content)
|
||||
}
|
||||
showSnackbar(fragment.requireContext(), rootView, errorInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun showUiErrorSnackbar(context: Context, request: String, throwable: Throwable) {
|
||||
showSnackbar(context, ErrorInfo(throwable, UserAction.UI_ERROR, request))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun showUiErrorSnackbar(fragment: Fragment, request: String, throwable: Throwable) {
|
||||
showSnackbar(fragment, ErrorInfo(throwable, UserAction.UI_ERROR, request))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error notification. Tapping on the notification opens the error activity. Use
|
||||
* this method if the exception happens inside a background service (player, subscription
|
||||
* import, ...) or there is no activity/fragment from which to extract a root view.
|
||||
*
|
||||
* @param context the context to use to show the notification
|
||||
* @param errorInfo the error info to be reported; the error message
|
||||
* [ErrorInfo.messageStringId] will be shown in the notification
|
||||
* description
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createNotification(context: Context, errorInfo: ErrorInfo) {
|
||||
if (getIsErrorReportsDisabled(context)) {
|
||||
return
|
||||
}
|
||||
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
if (notificationManager == null) {
|
||||
// this should never happen, but just in case open error activity
|
||||
openActivity(context, errorInfo)
|
||||
}
|
||||
|
||||
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
val notificationBuilder: NotificationCompat.Builder =
|
||||
NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.error_report_channel_id)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_bug_report)
|
||||
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
getErrorActivityIntent(context, errorInfo),
|
||||
pendingIntentFlags
|
||||
)
|
||||
)
|
||||
|
||||
notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
val intent = Intent(context, ErrorActivity::class.java)
|
||||
intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
return intent
|
||||
}
|
||||
|
||||
private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) {
|
||||
if (getIsErrorReportsDisabled(context)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (rootView == null) {
|
||||
// fallback to showing a notification if no root view is available
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
||||
openActivity(context, errorInfo)
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIsErrorReportsDisabled(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(
|
||||
context.getString(R.string.disable_error_reports_key), false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,12 +7,13 @@ import android.widget.ProgressBar;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorPanelHelper;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.util.InfoCache;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
@ -198,9 +199,8 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
|||
}
|
||||
|
||||
/**
|
||||
* Show a SnackBar and only call
|
||||
* {@link ErrorActivity#reportErrorInSnackbar(androidx.fragment.app.Fragment, ErrorInfo)}
|
||||
* IF we a find a valid view (otherwise the error screen appears).
|
||||
* Directly calls {@link ErrorUtil#showSnackbar(Fragment, ErrorInfo)}, that shows a snackbar if
|
||||
* a valid view can be found, otherwise creates an error report notification.
|
||||
*
|
||||
* @param errorInfo The error information
|
||||
*/
|
||||
|
@ -208,6 +208,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]");
|
||||
}
|
||||
ErrorActivity.reportErrorInSnackbar(this, errorInfo);
|
||||
ErrorUtil.showSnackbar(this, errorInfo);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import com.google.android.material.tabs.TabLayout;
|
|||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentMainBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
|
@ -145,7 +145,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
NavigationHelper.openSearchFragment(getFM(),
|
||||
ServiceHelper.getSelectedServiceId(activity), "");
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Opening search fragment", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening search fragment", e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
@ -227,16 +227,11 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
public Fragment getItem(final int position) {
|
||||
final Tab tab = internalTabsList.get(position);
|
||||
|
||||
Throwable throwable = null;
|
||||
Fragment fragment = null;
|
||||
final Fragment fragment;
|
||||
try {
|
||||
fragment = tab.getFragment(context);
|
||||
} catch (final ExtractionException e) {
|
||||
throwable = e;
|
||||
}
|
||||
|
||||
if (throwable != null) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(context, "Getting fragment item", throwable);
|
||||
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
|
||||
return new BlankFragment();
|
||||
}
|
||||
|
||||
|
|
|
@ -55,8 +55,8 @@ import org.schabi.newpipe.R;
|
|||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
|
@ -509,6 +509,10 @@ public final class VideoDetailFragment
|
|||
break;
|
||||
case R.id.detail_thumbnail_root_layout:
|
||||
autoPlayEnabled = true; // forcefully start playing
|
||||
// FIXME Workaround #7427
|
||||
if (isPlayerAvailable()) {
|
||||
player.setRecovery();
|
||||
}
|
||||
openVideoPlayerAutoFullscreen();
|
||||
break;
|
||||
case R.id.detail_title_root_layout:
|
||||
|
@ -542,7 +546,7 @@ public final class VideoDetailFragment
|
|||
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
|
||||
subChannelUrl, subChannelName);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Opening channel fragment", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -694,7 +698,7 @@ public final class VideoDetailFragment
|
|||
});
|
||||
|
||||
setupBottomPlayer();
|
||||
if (!playerHolder.bound) {
|
||||
if (!playerHolder.isBound()) {
|
||||
setHeightThumbnail();
|
||||
} else {
|
||||
playerHolder.startService(false, this);
|
||||
|
@ -1107,6 +1111,11 @@ public final class VideoDetailFragment
|
|||
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
|
||||
if (isPlayerAvailable()) {
|
||||
// FIXME Workaround #7427
|
||||
player.setRecovery();
|
||||
}
|
||||
|
||||
if (!useExternalAudioPlayer) {
|
||||
openNormalBackgroundPlayer(append);
|
||||
} else {
|
||||
|
@ -1123,6 +1132,9 @@ public final class VideoDetailFragment
|
|||
// See UI changes while remote playQueue changes
|
||||
if (!isPlayerAvailable()) {
|
||||
playerHolder.startService(false, this);
|
||||
} else {
|
||||
// FIXME Workaround #7427
|
||||
player.setRecovery();
|
||||
}
|
||||
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
|
@ -1443,7 +1455,7 @@ public final class VideoDetailFragment
|
|||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
// Rebound to the service if it was closed via notification or mini player
|
||||
if (!playerHolder.bound) {
|
||||
if (!playerHolder.isBound()) {
|
||||
playerHolder.startService(
|
||||
false, VideoDetailFragment.this);
|
||||
}
|
||||
|
@ -1530,6 +1542,8 @@ public final class VideoDetailFragment
|
|||
animate(binding.detailThumbnailPlayButton, true, 200);
|
||||
binding.detailVideoTitleView.setText(title);
|
||||
|
||||
binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
|
||||
|
||||
if (!isEmpty(info.getSubChannelName())) {
|
||||
displayBothUploaderAndSubChannel(info);
|
||||
} else if (!isEmpty(info.getUploaderName())) {
|
||||
|
@ -1720,7 +1734,7 @@ public final class VideoDetailFragment
|
|||
downloadDialog.setVideoSegments(videoSegments);
|
||||
downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportErrorInSnackbar(activity,
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Showing download dialog",
|
||||
currentInfo));
|
||||
|
@ -2022,7 +2036,9 @@ public final class VideoDetailFragment
|
|||
// Prevent jumping of the player on devices with cutout
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
|
||||
isMultiWindowOrFullscreen()
|
||||
? WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
|
||||
: WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
|
||||
}
|
||||
activity.getWindow().getDecorView().setSystemUiVisibility(0);
|
||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
|
@ -2044,7 +2060,9 @@ public final class VideoDetailFragment
|
|||
// Prevent jumping of the player on devices with cutout
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
||||
isMultiWindowOrFullscreen()
|
||||
? WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
|
||||
: WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
||||
}
|
||||
int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
|
@ -2061,7 +2079,7 @@ public final class VideoDetailFragment
|
|||
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& (isInMultiWindow || (isPlayerAvailable() && player.isFullscreen()))) {
|
||||
&& isMultiWindowOrFullscreen()) {
|
||||
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
|
||||
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
@ -2077,6 +2095,11 @@ public final class VideoDetailFragment
|
|||
}
|
||||
}
|
||||
|
||||
private boolean isMultiWindowOrFullscreen() {
|
||||
return DeviceUtils.isInMultiWindow(activity)
|
||||
|| (isPlayerAvailable() && player.isFullscreen());
|
||||
}
|
||||
|
||||
private boolean playerIsNotStopped() {
|
||||
return isPlayerAvailable() && !player.isStopped();
|
||||
}
|
||||
|
@ -2239,12 +2262,20 @@ public final class VideoDetailFragment
|
|||
mainFragment.setDescendantFocusability(afterDescendants);
|
||||
toolbar.setDescendantFocusability(afterDescendants);
|
||||
((ViewGroup) requireView()).setDescendantFocusability(blockDescendants);
|
||||
mainFragment.requestFocus();
|
||||
// Only focus the mainFragment if the mainFragment (e.g. search-results)
|
||||
// or the toolbar (e.g. Textfield for search) don't have focus.
|
||||
// This was done to fix problems with the keyboard input, see also #7490
|
||||
if (!mainFragment.hasFocus() && !toolbar.hasFocus()) {
|
||||
mainFragment.requestFocus();
|
||||
}
|
||||
} else {
|
||||
mainFragment.setDescendantFocusability(blockDescendants);
|
||||
toolbar.setDescendantFocusability(blockDescendants);
|
||||
((ViewGroup) requireView()).setDescendantFocusability(afterDescendants);
|
||||
binding.detailThumbnailRootLayout.requestFocus();
|
||||
// Only focus the player if it not already has focus
|
||||
if (!binding.getRoot().hasFocus()) {
|
||||
binding.detailThumbnailRootLayout.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ import androidx.viewbinding.ViewBinding;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
|
@ -293,7 +293,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
selectedItem.getUrl(),
|
||||
selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(
|
||||
ErrorUtil.showUiErrorSnackbar(
|
||||
BaseListFragment.this, "Opening channel fragment", e);
|
||||
}
|
||||
}
|
||||
|
@ -309,7 +309,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
selectedItem.getUrl(),
|
||||
selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(BaseListFragment.this,
|
||||
ErrorUtil.showUiErrorSnackbar(BaseListFragment.this,
|
||||
"Opening playlist fragment", e);
|
||||
}
|
||||
}
|
||||
|
@ -352,7 +352,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
}
|
||||
final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
|
@ -407,7 +407,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
currentInfo.getParentChannelUrl(),
|
||||
currentInfo.getParentChannelName());
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Opening channel fragment", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
} else if (DEBUG) {
|
||||
Log.i(TAG, "Can't open parent channel because we got no channel URL");
|
||||
|
|
|
@ -24,8 +24,8 @@ import org.schabi.newpipe.R;
|
|||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.databinding.PlaylistHeaderBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
|
@ -149,7 +149,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
|
@ -268,7 +268,10 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
ShareUtils.openUrlInBrowser(requireContext(), url);
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl());
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name, url,
|
||||
currentInfo.getThumbnailUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
|
@ -310,7 +313,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
NavigationHelper.openChannelFragment(getFM(), result.getServiceId(),
|
||||
result.getUploaderUrl(), result.getUploaderName());
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Opening channel fragment", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
package org.schabi.newpipe.fragments.list.search;
|
||||
|
||||
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||
import static java.util.Arrays.asList;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
@ -20,7 +25,6 @@ import android.view.View;
|
|||
import android.view.ViewGroup;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
|
@ -29,17 +33,15 @@ import androidx.annotation.Nullable;
|
|||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||
import org.schabi.newpipe.databinding.FragmentSearchBinding;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
|
@ -61,6 +63,7 @@ import org.schabi.newpipe.settings.NewPipeSettings;
|
|||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
|
@ -68,12 +71,11 @@ import java.util.ArrayList;
|
|||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
@ -84,11 +86,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
|||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||
|
||||
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
|
||||
import static java.util.Arrays.asList;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||
|
||||
public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.InfoItemsPage<?>>
|
||||
implements BackPressable {
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -225,8 +222,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
try {
|
||||
service = NewPipe.getService(serviceId);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this,
|
||||
"Getting service for id " + serviceId, e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Getting service for id " + serviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -673,31 +669,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "showKeyboardSearch() called");
|
||||
}
|
||||
if (searchEditText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchEditText.requestFocus()) {
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.showSoftInput(searchEditText, InputMethodManager.SHOW_FORCED);
|
||||
}
|
||||
KeyboardUtil.showKeyboard(activity, searchEditText);
|
||||
}
|
||||
|
||||
private void hideKeyboardSearch() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "hideKeyboardSearch() called");
|
||||
}
|
||||
if (searchEditText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.hideSoftInputFromWindow(searchEditText.getWindowToken(),
|
||||
InputMethodManager.RESULT_UNCHANGED_SHOWN);
|
||||
|
||||
searchEditText.clearFocus();
|
||||
KeyboardUtil.hideKeyboard(activity, searchEditText);
|
||||
}
|
||||
|
||||
private void showDeleteSuggestionDialog(final SuggestionItem item) {
|
||||
|
@ -727,7 +707,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
@Override
|
||||
public boolean onBackPressed() {
|
||||
if (suggestionsPanelVisible
|
||||
&& infoListAdapter.getItemsList().size() > 0
|
||||
&& !infoListAdapter.getItemsList().isEmpty()
|
||||
&& !isLoading.get()) {
|
||||
hideSuggestionsPanel();
|
||||
hideKeyboardSearch();
|
||||
|
@ -743,13 +723,10 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
return historyRecordManager
|
||||
.getRelatedSearches(query, similarQueryLimit, 25)
|
||||
.toObservable()
|
||||
.map(searchHistoryEntries -> {
|
||||
final Set<SuggestionItem> result = new HashSet<>(); // remove duplicates
|
||||
for (final SearchHistoryEntry entry : searchHistoryEntries) {
|
||||
result.add(new SuggestionItem(true, entry.getSearch()));
|
||||
}
|
||||
return new ArrayList<>(result);
|
||||
});
|
||||
.map(searchHistoryEntries ->
|
||||
searchHistoryEntries.stream()
|
||||
.map(entry -> new SuggestionItem(true, entry))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private Observable<List<SuggestionItem>> getRemoteSuggestionsObservable(final String query) {
|
||||
|
|
|
@ -34,12 +34,14 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
|
|||
public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
|
||||
public final TextView itemTitleView;
|
||||
private final ImageView itemHeartView;
|
||||
private final ImageView itemPinnedView;
|
||||
|
||||
public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_comments_item, parent);
|
||||
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -55,5 +57,7 @@ public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
|
|||
itemTitleView.setText(item.getUploaderName());
|
||||
|
||||
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import android.widget.TextView;
|
|||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
|
@ -171,7 +171,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||
item.getUploaderUrl(),
|
||||
item.getUploaderName());
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(activity, "Opening channel fragment", e);
|
||||
ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -75,6 +75,7 @@ fun View.animate(
|
|||
}
|
||||
animate().setListener(null).cancel()
|
||||
isVisible = true
|
||||
|
||||
when (animationType) {
|
||||
AnimationType.ALPHA -> animateAlpha(enterOrExit, duration, delay, execOnEnd)
|
||||
AnimationType.SCALE_AND_ALPHA -> animateScaleAndAlpha(enterOrExit, duration, delay, execOnEnd)
|
||||
|
|
|
@ -271,7 +271,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
|
||||
override fun onDestroyView() {
|
||||
// Ensure that all animations are canceled
|
||||
feedBinding.newItemsLoadedButton?.clearAnimation()
|
||||
tryGetNewItemsLoadedButton()?.clearAnimation()
|
||||
|
||||
feedBinding.itemsList.adapter = null
|
||||
_feedBinding = null
|
||||
|
@ -362,7 +362,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
if (context == null || context.resources == null || activity == null) return
|
||||
|
||||
val entries = ArrayList<StreamDialogEntry>()
|
||||
if (PlayerHolder.getInstance().isPlayerOpen) {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady) {
|
||||
entries.add(StreamDialogEntry.enqueue)
|
||||
|
||||
if (PlayerHolder.getInstance().queueSize > 1) {
|
||||
|
|
|
@ -183,28 +183,23 @@ class FeedLoadService : Service() {
|
|||
|
||||
subscriptions
|
||||
.take(1)
|
||||
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
updateNotificationProgress(null)
|
||||
broadcastProgress()
|
||||
}
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
|
@ -239,14 +234,11 @@ class FeedLoadService : Service() {
|
|||
}
|
||||
}
|
||||
.sequential()
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(notificationsConsumer)
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(databaseConsumer)
|
||||
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(resultSubscriber)
|
||||
|
|
|
@ -244,9 +244,9 @@ public class HistoryRecordManager {
|
|||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<SearchHistoryEntry>> getRelatedSearches(final String query,
|
||||
final int similarQueryLimit,
|
||||
final int uniqueQueryLimit) {
|
||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||
final int similarQueryLimit,
|
||||
final int uniqueQueryLimit) {
|
||||
return query.length() > 0
|
||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||
|
|
|
@ -338,7 +338,7 @@ public class StatisticsPlaylistFragment
|
|||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
|
|
|
@ -753,7 +753,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
|
|
|
@ -55,6 +55,7 @@ import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
|||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
|
@ -179,15 +180,23 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
}
|
||||
|
||||
private fun onImportPreviousSelected() {
|
||||
requestImportLauncher.launch(StoredFileHelper.getPicker(activity, JSON_MIME_TYPE))
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestImportLauncher,
|
||||
StoredFileHelper.getPicker(activity, JSON_MIME_TYPE),
|
||||
TAG,
|
||||
requireContext()
|
||||
)
|
||||
}
|
||||
|
||||
private fun onExportSelected() {
|
||||
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
|
||||
val exportName = "newpipe_subscriptions_$date.json"
|
||||
|
||||
requestExportLauncher.launch(
|
||||
StoredFileHelper.getNewPicker(activity, exportName, JSON_MIME_TYPE, null)
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestExportLauncher,
|
||||
StoredFileHelper.getNewPicker(activity, exportName, JSON_MIME_TYPE, null),
|
||||
TAG,
|
||||
requireContext()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -23,13 +23,14 @@ import androidx.core.text.util.LinkifyCompat;
|
|||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
@ -86,12 +87,11 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
|||
|
||||
setupServiceVariables();
|
||||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
ErrorActivity.reportErrorInSnackbar(activity,
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
||||
NewPipe.getNameOfService(currentServiceId),
|
||||
"Service does not support importing subscriptions",
|
||||
R.string.general_error,
|
||||
null));
|
||||
R.string.general_error));
|
||||
activity.finish();
|
||||
}
|
||||
}
|
||||
|
@ -175,8 +175,14 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
|||
}
|
||||
|
||||
public void onImportFile() {
|
||||
// leave */* mime type to support all services with different mime types and file extensions
|
||||
requestImportFileLauncher.launch(StoredFileHelper.getPicker(activity, "*/*"));
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestImportFileLauncher,
|
||||
// leave */* mime type to support all services
|
||||
// with different mime types and file extensions
|
||||
StoredFileHelper.getPicker(activity, "*/*"),
|
||||
TAG,
|
||||
getContext()
|
||||
);
|
||||
}
|
||||
|
||||
private void requestImportFileResult(final ActivityResult result) {
|
||||
|
|
|
@ -54,11 +54,9 @@ class ChannelItem(
|
|||
context.getString(R.string.subscribers_count_not_available)
|
||||
}
|
||||
|
||||
if (itemVersion == ItemVersion.NORMAL) {
|
||||
if (infoItem.streamCount >= 0) {
|
||||
val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
|
||||
details = Localization.concatenateStrings(details, formattedVideoAmount)
|
||||
}
|
||||
if (itemVersion == ItemVersion.NORMAL && infoItem.streamCount >= 0) {
|
||||
val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
|
||||
details = Localization.concatenateStrings(details, formattedVideoAmount)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
|
|
@ -35,8 +35,8 @@ import androidx.core.app.ServiceCompat;
|
|||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
|
@ -153,7 +153,7 @@ public abstract class BaseImportExportService extends Service {
|
|||
|
||||
protected void stopAndReportError(final Throwable throwable, final String request) {
|
||||
stopService();
|
||||
ErrorActivity.reportError(this, new ErrorInfo(
|
||||
ErrorUtil.createNotification(this, new ErrorInfo(
|
||||
throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request));
|
||||
}
|
||||
|
||||
|
|
|
@ -51,9 +51,6 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
|||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.PropertyValuesHolder;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
|
@ -143,6 +140,9 @@ import org.schabi.newpipe.MainActivity;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.PlayerBinding;
|
||||
import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.MediaFormat;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamSegment;
|
||||
|
@ -153,6 +153,7 @@ import org.schabi.newpipe.info_list.StreamSegmentAdapter;
|
|||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.event.DisplayPortion;
|
||||
import org.schabi.newpipe.player.event.PlayerEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerGestureListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
|
@ -167,7 +168,6 @@ import org.schabi.newpipe.player.playback.MediaSourceManager;
|
|||
import org.schabi.newpipe.player.playback.PlaybackListener;
|
||||
import org.schabi.newpipe.player.playback.PlayerMediaSession;
|
||||
import org.schabi.newpipe.player.playback.SurfaceHolderCallback;
|
||||
import org.schabi.newpipe.player.playererror.PlayerErrorHandler;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueAdapter;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
@ -190,6 +190,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|||
import org.schabi.newpipe.util.SponsorBlockMode;
|
||||
import org.schabi.newpipe.util.VideoSegment;
|
||||
import org.schabi.newpipe.views.ExpandableSurfaceView;
|
||||
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
@ -254,6 +255,7 @@ public final class Player implements
|
|||
public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis
|
||||
public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds
|
||||
public static final int DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds
|
||||
public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Other constants
|
||||
|
@ -267,7 +269,8 @@ public final class Player implements
|
|||
// Playback
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private PlayQueue playQueue;
|
||||
// play queue might be null e.g. while player is starting
|
||||
@Nullable private PlayQueue playQueue;
|
||||
private PlayQueueAdapter playQueueAdapter;
|
||||
private StreamSegmentAdapter segmentAdapter;
|
||||
|
||||
|
@ -277,8 +280,6 @@ public final class Player implements
|
|||
@Nullable private MediaSourceTag currentMetadata;
|
||||
@Nullable private Bitmap currentThumbnail;
|
||||
|
||||
@NonNull private PlayerErrorHandler playerErrorHandler;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Player
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -322,7 +323,6 @@ public final class Player implements
|
|||
|
||||
private PlayerBinding binding;
|
||||
|
||||
private ValueAnimator controlViewAnimator;
|
||||
private final Handler controlsVisibilityHandler = new Handler();
|
||||
|
||||
// fullscreen player
|
||||
|
@ -374,6 +374,7 @@ public final class Player implements
|
|||
|
||||
private int maxGestureLength; // scaled
|
||||
private GestureDetectorCompat gestureDetector;
|
||||
private PlayerGestureListener playerGestureListener;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Listeners and disposables
|
||||
|
@ -428,8 +429,6 @@ public final class Player implements
|
|||
videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver());
|
||||
audioResolver = new AudioPlaybackResolver(context, dataSource);
|
||||
|
||||
playerErrorHandler = new PlayerErrorHandler(context);
|
||||
|
||||
windowManager = ContextCompat.getSystemService(context, WindowManager.class);
|
||||
}
|
||||
|
||||
|
@ -466,6 +465,8 @@ public final class Player implements
|
|||
initPlayer(true);
|
||||
}
|
||||
initListeners();
|
||||
|
||||
setupPlayerSeekOverlay();
|
||||
}
|
||||
|
||||
private void initViews(@NonNull final PlayerBinding playerBinding) {
|
||||
|
@ -542,9 +543,9 @@ public final class Player implements
|
|||
binding.resizeTextView.setOnClickListener(this);
|
||||
binding.playbackLiveSync.setOnClickListener(this);
|
||||
|
||||
final PlayerGestureListener listener = new PlayerGestureListener(this, service);
|
||||
gestureDetector = new GestureDetectorCompat(context, listener);
|
||||
binding.getRoot().setOnTouchListener(listener);
|
||||
playerGestureListener = new PlayerGestureListener(this, service);
|
||||
gestureDetector = new GestureDetectorCompat(context, playerGestureListener);
|
||||
binding.getRoot().setOnTouchListener(playerGestureListener);
|
||||
|
||||
binding.queueButton.setOnClickListener(this);
|
||||
binding.segmentsButton.setOnClickListener(this);
|
||||
|
@ -589,15 +590,83 @@ public final class Player implements
|
|||
});
|
||||
|
||||
// PlaybackControlRoot already consumed window insets but we should pass them to
|
||||
// player_overlays too. Without it they will be off-centered
|
||||
// player_overlays and fast_seek_overlay too. Without it they will be off-centered.
|
||||
binding.playbackControlRoot.addOnLayoutChangeListener(
|
||||
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
|
||||
binding.playerOverlays.setPadding(
|
||||
v.getPaddingLeft(),
|
||||
v.getPaddingTop(),
|
||||
v.getPaddingRight(),
|
||||
v.getPaddingBottom()));
|
||||
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
binding.playerOverlays.setPadding(
|
||||
v.getPaddingLeft(),
|
||||
v.getPaddingTop(),
|
||||
v.getPaddingRight(),
|
||||
v.getPaddingBottom());
|
||||
binding.fastSeekOverlay.setPadding(
|
||||
v.getPaddingLeft(),
|
||||
v.getPaddingTop(),
|
||||
v.getPaddingRight(),
|
||||
v.getPaddingBottom());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Fast-For/Backward overlay.
|
||||
*/
|
||||
private void setupPlayerSeekOverlay() {
|
||||
binding.fastSeekOverlay
|
||||
.seekSecondsSupplier(
|
||||
() -> (int) (retrieveSeekDurationFromPreferences(this) / 1000.0f))
|
||||
.performListener(new PlayerFastSeekOverlay.PerformListener() {
|
||||
|
||||
@Override
|
||||
public void onDoubleTap() {
|
||||
animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDoubleTapEnd() {
|
||||
animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FastSeekDirection getFastSeekDirection(
|
||||
@NonNull final DisplayPortion portion
|
||||
) {
|
||||
if (exoPlayerIsNull()) {
|
||||
// Abort seeking
|
||||
playerGestureListener.endMultiDoubleTap();
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
if (portion == DisplayPortion.LEFT) {
|
||||
// Check if it's possible to rewind
|
||||
// Small puffer to eliminate infinite rewind seeking
|
||||
if (simpleExoPlayer.getCurrentPosition() < 500L) {
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
return FastSeekDirection.BACKWARD;
|
||||
} else if (portion == DisplayPortion.RIGHT) {
|
||||
// Check if it's possible to fast-forward
|
||||
if (currentState == STATE_COMPLETED
|
||||
|| simpleExoPlayer.getCurrentPosition()
|
||||
>= simpleExoPlayer.getDuration()) {
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
return FastSeekDirection.FORWARD;
|
||||
}
|
||||
/* portion == DisplayPortion.MIDDLE */
|
||||
return FastSeekDirection.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seek(final boolean forward) {
|
||||
playerGestureListener.keepInDoubleTapMode();
|
||||
if (forward) {
|
||||
fastForward();
|
||||
} else {
|
||||
fastRewind();
|
||||
}
|
||||
}
|
||||
});
|
||||
playerGestureListener.doubleTapControls(binding.fastSeekOverlay);
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
|
@ -655,6 +724,7 @@ public final class Player implements
|
|||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||||
|
||||
/*
|
||||
* TODO As seen in #7427 this does not work:
|
||||
* There are 3 situations when playback shouldn't be started from scratch (zero timestamp):
|
||||
* 1. User pressed on a timestamp link and the same video should be rewound to the timestamp
|
||||
* 2. User changed a player from, for example. main to popup, or from audio to main, etc
|
||||
|
@ -1897,71 +1967,6 @@ public final class Player implements
|
|||
return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a animation, and depending on goneOnEnd, will stay on the screen or be gone.
|
||||
*
|
||||
* @param drawableId the drawable that will be used to animate,
|
||||
* pass -1 to clear any animation that is visible
|
||||
* @param goneOnEnd will set the animation view to GONE on the end of the animation
|
||||
*/
|
||||
public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showAndAnimateControl() called with: "
|
||||
+ "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]");
|
||||
}
|
||||
if (controlViewAnimator != null && controlViewAnimator.isRunning()) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning");
|
||||
}
|
||||
controlViewAnimator.end();
|
||||
}
|
||||
|
||||
if (drawableId == -1) {
|
||||
if (binding.controlAnimationView.getVisibility() == View.VISIBLE) {
|
||||
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
|
||||
binding.controlAnimationView,
|
||||
PropertyValuesHolder.ofFloat(View.ALPHA, 1.0f, 0.0f),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1.0f),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1.0f)
|
||||
).setDuration(DEFAULT_CONTROLS_DURATION);
|
||||
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(final Animator animation) {
|
||||
binding.controlAnimationView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
controlViewAnimator.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final float scaleFrom = goneOnEnd ? 1f : 1f;
|
||||
final float scaleTo = goneOnEnd ? 1.8f : 1.4f;
|
||||
final float alphaFrom = goneOnEnd ? 1f : 0f;
|
||||
final float alphaTo = goneOnEnd ? 0f : 1f;
|
||||
|
||||
|
||||
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
|
||||
binding.controlAnimationView,
|
||||
PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo),
|
||||
PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo)
|
||||
);
|
||||
controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500);
|
||||
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(final Animator animation) {
|
||||
binding.controlAnimationView.setVisibility(goneOnEnd ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
binding.controlAnimationView.setVisibility(View.VISIBLE);
|
||||
binding.controlAnimationView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(context, drawableId));
|
||||
controlViewAnimator.start();
|
||||
}
|
||||
|
||||
public void showControlsThenHide() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showControlsThenHide() called");
|
||||
|
@ -2006,6 +2011,7 @@ public final class Player implements
|
|||
}
|
||||
|
||||
private void showHideShadow(final boolean show, final long duration) {
|
||||
animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||||
}
|
||||
|
@ -2149,7 +2155,7 @@ public final class Player implements
|
|||
if (currentState == STATE_BLOCKED) {
|
||||
changeState(STATE_BUFFERING);
|
||||
}
|
||||
simpleExoPlayer.setMediaSource(mediaSource);
|
||||
simpleExoPlayer.setMediaSource(mediaSource, false);
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
|
||||
|
@ -2205,8 +2211,8 @@ public final class Player implements
|
|||
startProgressLoop();
|
||||
}
|
||||
|
||||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||||
animate(binding.playbackControlRoot, false, DEFAULT_CONTROLS_DURATION);
|
||||
// if we are e.g. switching players, hide controls
|
||||
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
||||
|
||||
binding.playbackSeekBar.setEnabled(false);
|
||||
binding.playbackSeekBar.getThumb()
|
||||
|
@ -2233,8 +2239,6 @@ public final class Player implements
|
|||
|
||||
updateStreamRelatedViews();
|
||||
|
||||
showAndAnimateControl(-1, true);
|
||||
|
||||
binding.playbackSeekBar.setEnabled(true);
|
||||
binding.playbackSeekBar.getThumb()
|
||||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
|
||||
|
@ -2282,18 +2286,21 @@ public final class Player implements
|
|||
stopProgressLoop();
|
||||
}
|
||||
|
||||
showControls(400);
|
||||
binding.loadingPanel.setVisibility(View.GONE);
|
||||
|
||||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||||
() -> {
|
||||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||||
animatePlayButtons(true, 200);
|
||||
if (!isQueueVisible) {
|
||||
binding.playPauseButton.requestFocus();
|
||||
}
|
||||
});
|
||||
// Don't let UI elements popup during double tap seeking. This state is entered sometimes
|
||||
// during seeking/loading. This if-else check ensures that the controls aren't popping up.
|
||||
if (!playerGestureListener.isDoubleTapping()) {
|
||||
showControls(400);
|
||||
binding.loadingPanel.setVisibility(View.GONE);
|
||||
|
||||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||||
() -> {
|
||||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||||
animatePlayButtons(true, 200);
|
||||
if (!isQueueVisible) {
|
||||
binding.playPauseButton.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
|
||||
|
||||
// Remove running notification when user does not want minimization to background or popup
|
||||
|
@ -2311,7 +2318,6 @@ public final class Player implements
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "onPausedSeek() called");
|
||||
}
|
||||
showAndAnimateControl(-1, true);
|
||||
|
||||
animatePlayButtons(false, 100);
|
||||
binding.getRoot().setKeepScreenOn(true);
|
||||
|
@ -2452,7 +2458,8 @@ public final class Player implements
|
|||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||||
}
|
||||
|
||||
private void setRepeatModeButton(final AppCompatImageButton imageButton, final int repeatMode) {
|
||||
private void setRepeatModeButton(final AppCompatImageButton imageButton,
|
||||
@RepeatMode final int repeatMode) {
|
||||
switch (repeatMode) {
|
||||
case REPEAT_MODE_OFF:
|
||||
imageButton.setImageResource(R.drawable.exo_controls_repeat_off);
|
||||
|
@ -2466,7 +2473,7 @@ public final class Player implements
|
|||
}
|
||||
}
|
||||
|
||||
private void setShuffleButton(final ImageButton button, final boolean shuffled) {
|
||||
private void setShuffleButton(@NonNull final ImageButton button, final boolean shuffled) {
|
||||
button.setImageAlpha(shuffled ? 255 : 77);
|
||||
}
|
||||
//endregion
|
||||
|
@ -2491,7 +2498,7 @@ public final class Player implements
|
|||
return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0;
|
||||
}
|
||||
|
||||
private void setMuteButton(final ImageButton button, final boolean isMuted) {
|
||||
private void setMuteButton(@NonNull final ImageButton button, final boolean isMuted) {
|
||||
button.setImageDrawable(AppCompatResources.getDrawable(context, isMuted
|
||||
? R.drawable.ic_volume_off : R.drawable.ic_volume_up));
|
||||
}
|
||||
|
@ -2619,50 +2626,77 @@ public final class Player implements
|
|||
Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error);
|
||||
|
||||
saveStreamProgressState();
|
||||
boolean isCatchableException = false;
|
||||
|
||||
switch (error.type) {
|
||||
case ExoPlaybackException.TYPE_SOURCE:
|
||||
processSourceError(error.getSourceException());
|
||||
playerErrorHandler.showPlayerError(
|
||||
error,
|
||||
currentMetadata.getMetadata(),
|
||||
R.string.player_stream_failure);
|
||||
isCatchableException = processSourceError(error.getSourceException());
|
||||
break;
|
||||
case ExoPlaybackException.TYPE_UNEXPECTED:
|
||||
playerErrorHandler.showPlayerError(
|
||||
error,
|
||||
currentMetadata.getMetadata(),
|
||||
R.string.player_recoverable_failure);
|
||||
setRecovery();
|
||||
reloadPlayQueueManager();
|
||||
break;
|
||||
case ExoPlaybackException.TYPE_REMOTE:
|
||||
case ExoPlaybackException.TYPE_RENDERER:
|
||||
default:
|
||||
playerErrorHandler.showPlayerError(
|
||||
error,
|
||||
currentMetadata.getMetadata(),
|
||||
R.string.player_unrecoverable_failure);
|
||||
onPlaybackShutdown();
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCatchableException) {
|
||||
return;
|
||||
}
|
||||
|
||||
createErrorNotification(error);
|
||||
|
||||
if (fragmentListener != null) {
|
||||
fragmentListener.onPlayerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private void processSourceError(final IOException error) {
|
||||
if (exoPlayerIsNull() || playQueue == null) {
|
||||
return;
|
||||
private void createErrorNotification(@NonNull final ExoPlaybackException error) {
|
||||
final ErrorInfo errorInfo;
|
||||
if (currentMetadata == null) {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred, currentMetadata is null");
|
||||
} else {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.type + "] occurred while playing "
|
||||
+ currentMetadata.getMetadata().getUrl(),
|
||||
currentMetadata.getMetadata());
|
||||
}
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an {@link IOException} returned by {@link ExoPlaybackException#getSourceException()}
|
||||
* for {@link ExoPlaybackException#TYPE_SOURCE} exceptions.
|
||||
*
|
||||
* <p>
|
||||
* This method sets the recovery position and sends an error message to the play queue if the
|
||||
* exception is not a {@link BehindLiveWindowException}.
|
||||
* </p>
|
||||
* @param error the source error which was thrown by ExoPlayer
|
||||
* @return whether the exception thrown is a {@link BehindLiveWindowException} ({@code false}
|
||||
* is always returned if ExoPlayer or the play queue is null)
|
||||
*/
|
||||
private boolean processSourceError(final IOException error) {
|
||||
if (exoPlayerIsNull() || playQueue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setRecovery();
|
||||
|
||||
if (error instanceof BehindLiveWindowException) {
|
||||
reloadPlayQueueManager();
|
||||
} else {
|
||||
playQueue.error();
|
||||
simpleExoPlayer.seekToDefaultPosition();
|
||||
simpleExoPlayer.prepare();
|
||||
// Inform the user that we are reloading the stream by switching to the buffering state
|
||||
onBuffering();
|
||||
return true;
|
||||
}
|
||||
|
||||
playQueue.error();
|
||||
return false;
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
@ -2939,7 +2973,6 @@ public final class Player implements
|
|||
}
|
||||
seekBy(retrieveSeekDurationFromPreferences(this));
|
||||
triggerProgressUpdate();
|
||||
showAndAnimateControl(R.drawable.ic_fast_forward, true);
|
||||
}
|
||||
|
||||
public void fastRewind() {
|
||||
|
@ -2948,7 +2981,6 @@ public final class Player implements
|
|||
}
|
||||
seekBy(-retrieveSeekDurationFromPreferences(this));
|
||||
triggerProgressUpdate();
|
||||
showAndAnimateControl(R.drawable.ic_fast_rewind, true);
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
@ -2979,7 +3011,7 @@ public final class Player implements
|
|||
databaseUpdateDisposable
|
||||
.add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError((e) -> {
|
||||
.doOnError(e -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
@ -3504,7 +3536,7 @@ public final class Player implements
|
|||
playbackSpeedPopupMenu.setOnDismissListener(this);
|
||||
}
|
||||
|
||||
private void buildCaptionMenu(final List<String> availableLanguages) {
|
||||
private void buildCaptionMenu(@NonNull final List<String> availableLanguages) {
|
||||
if (captionPopupMenu == null) {
|
||||
return;
|
||||
}
|
||||
|
@ -3572,7 +3604,7 @@ public final class Player implements
|
|||
* Called when an item of the quality selector or the playback speed selector is selected.
|
||||
*/
|
||||
@Override
|
||||
public boolean onMenuItemClick(final MenuItem menuItem) {
|
||||
public boolean onMenuItemClick(@NonNull final MenuItem menuItem) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onMenuItemClick() called with: "
|
||||
+ "menuItem = [" + menuItem + "], "
|
||||
|
@ -3609,7 +3641,7 @@ public final class Player implements
|
|||
* Called when some popup menu is dismissed.
|
||||
*/
|
||||
@Override
|
||||
public void onDismiss(final PopupMenu menu) {
|
||||
public void onDismiss(@Nullable final PopupMenu menu) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
|
||||
}
|
||||
|
@ -3662,7 +3694,7 @@ public final class Player implements
|
|||
isSomePopupMenuVisible = true;
|
||||
}
|
||||
|
||||
private void setPlaybackQuality(final String quality) {
|
||||
private void setPlaybackQuality(@Nullable final String quality) {
|
||||
videoResolver.setPlaybackQuality(quality);
|
||||
}
|
||||
//endregion
|
||||
|
@ -3686,7 +3718,7 @@ public final class Player implements
|
|||
final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels);
|
||||
final float captionRatioInverse = 20f + 4f * (1.0f - captionScale);
|
||||
binding.subtitleView.setFixedTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX, (float) minimumLength / captionRatioInverse);
|
||||
TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse);
|
||||
}
|
||||
binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT);
|
||||
binding.subtitleView.setStyle(captionStyle);
|
||||
|
@ -3992,7 +4024,7 @@ public final class Player implements
|
|||
}
|
||||
|
||||
@Override // exoplayer listener
|
||||
public void onVideoSizeChanged(final VideoSize videoSize) {
|
||||
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onVideoSizeChanged() called with: "
|
||||
+ "width / height = [" + videoSize.width + " / " + videoSize.height
|
||||
|
@ -4113,7 +4145,7 @@ public final class Player implements
|
|||
}
|
||||
}
|
||||
|
||||
private int distanceFromCloseButton(final MotionEvent popupMotionEvent) {
|
||||
private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) {
|
||||
final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft()
|
||||
+ closeOverlayBinding.closeButton.getWidth() / 2;
|
||||
final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop()
|
||||
|
@ -4132,7 +4164,7 @@ public final class Player implements
|
|||
return buttonRadius * 1.2f;
|
||||
}
|
||||
|
||||
public boolean isInsideClosingRadius(final MotionEvent popupMotionEvent) {
|
||||
public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) {
|
||||
return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius();
|
||||
}
|
||||
//endregion
|
||||
|
@ -4252,6 +4284,7 @@ public final class Player implements
|
|||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public AppCompatActivity getParentActivity() {
|
||||
// ! instanceof ViewGroup means that view was added via windowManager for Popup
|
||||
if (binding == null || !(binding.getRoot().getParent() instanceof ViewGroup)) {
|
||||
|
@ -4353,6 +4386,7 @@ public final class Player implements
|
|||
}
|
||||
|
||||
|
||||
@Nullable
|
||||
public PlayQueue getPlayQueue() {
|
||||
return playQueue;
|
||||
}
|
||||
|
@ -4430,6 +4464,10 @@ public final class Player implements
|
|||
return binding.currentDisplaySeek;
|
||||
}
|
||||
|
||||
public PlayerFastSeekOverlay getFastSeekOverlay() {
|
||||
return binding.fastSeekOverlay;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public WindowManager.LayoutParams getPopupLayoutParams() {
|
||||
return popupLayoutParams;
|
||||
|
|
|
@ -411,7 +411,7 @@ abstract class BasePlayerGestureListener(
|
|||
var doubleTapControls: DoubleTapListener? = null
|
||||
private set
|
||||
|
||||
val isDoubleTapEnabled: Boolean
|
||||
private val isDoubleTapEnabled: Boolean
|
||||
get() = doubleTapDelay > 0
|
||||
|
||||
var isDoubleTapping = false
|
||||
|
@ -459,10 +459,6 @@ abstract class BasePlayerGestureListener(
|
|||
doubleTapControls?.onDoubleTapFinished()
|
||||
}
|
||||
|
||||
fun enableMultiDoubleTap(enable: Boolean) = apply {
|
||||
doubleTapDelay = if (enable) DOUBLE_TAP_DELAY else 0
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
// ///////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import static org.schabi.newpipe.ktx.AnimationType.ALPHA;
|
||||
import static org.schabi.newpipe.ktx.AnimationType.SCALE_AND_ALPHA;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_DURATION;
|
||||
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_HIDE_TIME;
|
||||
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
|
@ -8,22 +15,15 @@ import android.view.Window;
|
|||
import android.view.WindowManager;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.player.MainPlayer;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
|
||||
import static org.schabi.newpipe.ktx.AnimationType.ALPHA;
|
||||
import static org.schabi.newpipe.ktx.AnimationType.SCALE_AND_ALPHA;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_DURATION;
|
||||
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_HIDE_TIME;
|
||||
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
|
||||
|
||||
/**
|
||||
* GestureListener for the player
|
||||
*
|
||||
|
@ -45,8 +45,8 @@ public class PlayerGestureListener
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onDoubleTap(@NotNull final MotionEvent event,
|
||||
@NotNull final DisplayPortion portion) {
|
||||
public void onDoubleTap(@NonNull final MotionEvent event,
|
||||
@NonNull final DisplayPortion portion) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onDoubleTap called with playerType = ["
|
||||
+ player.getPlayerType() + "], portion = [" + portion + "]");
|
||||
|
@ -55,17 +55,15 @@ public class PlayerGestureListener
|
|||
player.hideControls(0, 0);
|
||||
}
|
||||
|
||||
if (portion == DisplayPortion.LEFT) {
|
||||
player.fastRewind();
|
||||
if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) {
|
||||
startMultiDoubleTap(event);
|
||||
} else if (portion == DisplayPortion.MIDDLE) {
|
||||
player.playPause();
|
||||
} else if (portion == DisplayPortion.RIGHT) {
|
||||
player.fastForward();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSingleTap(@NotNull final MainPlayer.PlayerType playerType) {
|
||||
public void onSingleTap(@NonNull final MainPlayer.PlayerType playerType) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSingleTap called with playerType = [" + player.getPlayerType() + "]");
|
||||
}
|
||||
|
@ -85,10 +83,10 @@ public class PlayerGestureListener
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onScroll(@NotNull final MainPlayer.PlayerType playerType,
|
||||
@NotNull final DisplayPortion portion,
|
||||
@NotNull final MotionEvent initialEvent,
|
||||
@NotNull final MotionEvent movingEvent,
|
||||
public void onScroll(@NonNull final MainPlayer.PlayerType playerType,
|
||||
@NonNull final DisplayPortion portion,
|
||||
@NonNull final MotionEvent initialEvent,
|
||||
@NonNull final MotionEvent movingEvent,
|
||||
final float distanceX, final float distanceY) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onScroll called with playerType = ["
|
||||
|
@ -197,8 +195,8 @@ public class PlayerGestureListener
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onScrollEnd(@NotNull final MainPlayer.PlayerType playerType,
|
||||
@NotNull final MotionEvent event) {
|
||||
public void onScrollEnd(@NonNull final MainPlayer.PlayerType playerType,
|
||||
@NonNull final MotionEvent event) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onScrollEnd called with playerType = ["
|
||||
+ player.getPlayerType() + "]");
|
||||
|
@ -232,10 +230,10 @@ public class PlayerGestureListener
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "onPopupResizingStart called");
|
||||
}
|
||||
player.showAndAnimateControl(-1, true);
|
||||
player.getLoadingPanel().setVisibility(View.GONE);
|
||||
|
||||
player.hideControls(0, 0);
|
||||
animate(player.getFastSeekOverlay(), false, 0);
|
||||
animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.google.android.exoplayer2.source.SingleSampleMediaSource;
|
|||
import com.google.android.exoplayer2.source.dash.DashMediaSource;
|
||||
import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource;
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
|
||||
import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker;
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource;
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
|
@ -17,9 +18,18 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
|
|||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
public class PlayerDataSource {
|
||||
|
||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
||||
|
||||
/**
|
||||
* An approximately 4.3 times greater value than the
|
||||
* {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT default}
|
||||
* to ensure that (very) low latency livestreams which got stuck for a moment don't crash too
|
||||
* early.
|
||||
*/
|
||||
private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15;
|
||||
private static final int MANIFEST_MINIMUM_RETRY = 5;
|
||||
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
|
||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
||||
|
||||
private final DataSource.Factory cacheDataSourceFactory;
|
||||
private final DataSource.Factory cachelessDataSourceFactory;
|
||||
|
@ -44,8 +54,13 @@ public class PlayerDataSource {
|
|||
public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() {
|
||||
return new HlsMediaSource.Factory(cachelessDataSourceFactory)
|
||||
.setAllowChunklessPreparation(true)
|
||||
.setLoadErrorHandlingPolicy(
|
||||
new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY));
|
||||
.setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(
|
||||
MANIFEST_MINIMUM_RETRY))
|
||||
.setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy,
|
||||
playlistParserFactory) ->
|
||||
new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy,
|
||||
playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)
|
||||
);
|
||||
}
|
||||
|
||||
public DashMediaSource.Factory getLiveDashMediaSourceFactory() {
|
||||
|
|
|
@ -35,15 +35,15 @@ public final class PlayerHolder {
|
|||
return PlayerHolder.instance;
|
||||
}
|
||||
|
||||
private final boolean DEBUG = MainActivity.DEBUG;
|
||||
private final String TAG = PlayerHolder.class.getSimpleName();
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final String TAG = PlayerHolder.class.getSimpleName();
|
||||
|
||||
private PlayerServiceExtendedEventListener listener;
|
||||
@Nullable private PlayerServiceExtendedEventListener listener;
|
||||
|
||||
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||
public boolean bound;
|
||||
private MainPlayer playerService;
|
||||
private Player player;
|
||||
private boolean bound;
|
||||
@Nullable private MainPlayer playerService;
|
||||
@Nullable private Player player;
|
||||
|
||||
/**
|
||||
* Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service,
|
||||
|
@ -70,8 +70,25 @@ public final class PlayerHolder {
|
|||
return player != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
|
||||
* the stream long press menu) when there actually is a play queue to manipulate.
|
||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||
*/
|
||||
public boolean isPlayQueueReady() {
|
||||
return player != null && player.getPlayQueue() != null;
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
return isPlayerOpen() ? player.getPlayQueue().size() : 0;
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return 0;
|
||||
}
|
||||
return player.getPlayQueue().size();
|
||||
}
|
||||
|
||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||
|
@ -148,7 +165,7 @@ public final class PlayerHolder {
|
|||
}
|
||||
startPlayerListener();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void bind(final Context context) {
|
||||
if (DEBUG) {
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
package org.schabi.newpipe.player.playererror;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.EnsureExceptionSerializable;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.Info;
|
||||
|
||||
/**
|
||||
* Handles (exoplayer)errors that occur in the player.
|
||||
*/
|
||||
public class PlayerErrorHandler {
|
||||
// This has to be <= 23 chars on devices running Android 7 or lower (API <= 25)
|
||||
// or it fails with an IllegalArgumentException
|
||||
// https://stackoverflow.com/a/54744028
|
||||
private static final String TAG = "PlayerErrorHandler";
|
||||
|
||||
@Nullable
|
||||
private Toast errorToast;
|
||||
|
||||
@NonNull
|
||||
private final Context context;
|
||||
|
||||
public PlayerErrorHandler(@NonNull final Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void showPlayerError(
|
||||
@NonNull final ExoPlaybackException exception,
|
||||
@NonNull final Info info,
|
||||
@StringRes final int textResId
|
||||
) {
|
||||
// Hide existing toast message
|
||||
if (errorToast != null) {
|
||||
Log.d(TAG, "Trying to cancel previous player error error toast");
|
||||
errorToast.cancel();
|
||||
errorToast = null;
|
||||
}
|
||||
|
||||
if (shouldReportError()) {
|
||||
try {
|
||||
reportError(exception, info);
|
||||
// When a report pops up we need no toast
|
||||
return;
|
||||
} catch (final Exception ex) {
|
||||
Log.w(TAG, "Unable to report error:", ex);
|
||||
// This will show the toast as fallback
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Showing player error toast");
|
||||
errorToast = Toast.makeText(context, textResId, Toast.LENGTH_SHORT);
|
||||
errorToast.show();
|
||||
}
|
||||
|
||||
private void reportError(@NonNull final ExoPlaybackException exception,
|
||||
@NonNull final Info info) {
|
||||
ErrorActivity.reportError(
|
||||
context,
|
||||
new ErrorInfo(
|
||||
EnsureExceptionSerializable.ensureSerializable(exception),
|
||||
UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + exception.type + "] occurred while playing: "
|
||||
+ info.getUrl(),
|
||||
info
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean shouldReportError() {
|
||||
return PreferenceManager
|
||||
.getDefaultSharedPreferences(context)
|
||||
.getBoolean(
|
||||
context.getString(R.string.report_player_errors_key),
|
||||
false);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ package org.schabi.newpipe.settings;
|
|||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
|
@ -15,14 +14,10 @@ import org.schabi.newpipe.util.Constants;
|
|||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
||||
private static final boolean CAPTIONING_SETTINGS_ACCESSIBLE =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
|
||||
|
||||
private String captionSettingsKey;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.appearance_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final String themeKey = getString(R.string.theme_key);
|
||||
// the key of the active theme when settings were opened (or recreated after theme change)
|
||||
|
@ -51,16 +46,11 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
|||
} else {
|
||||
removePreference(nightThemeKey);
|
||||
}
|
||||
|
||||
captionSettingsKey = getString(R.string.caption_settings_key);
|
||||
if (!CAPTIONING_SETTINGS_ACCESSIBLE) {
|
||||
removePreference(captionSettingsKey);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPreferenceTreeClick(final Preference preference) {
|
||||
if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) {
|
||||
if (preference.getKey().equals(getString(R.string.caption_settings_key))) {
|
||||
try {
|
||||
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
|
|
|
@ -18,7 +18,7 @@ import java.util.Objects;
|
|||
|
||||
public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
|
||||
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
|
||||
protected final boolean DEBUG = MainActivity.DEBUG;
|
||||
protected static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
SharedPreferences defaultPreferences;
|
||||
|
||||
|
@ -28,6 +28,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
|
|||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
protected void addPreferencesFromResourceRegistry() {
|
||||
addPreferencesFromResource(
|
||||
SettingsResourceRegistry.getInstance().getPreferencesResId(this.getClass()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
@ -20,11 +23,11 @@ import androidx.preference.PreferenceManager;
|
|||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.localization.Localization;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
|
@ -37,9 +40,6 @@ import java.util.Date;
|
|||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private static final String ZIP_MIME_TYPE = "application/zip";
|
||||
|
||||
|
@ -69,23 +69,32 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
importExportDataPathKey = getString(R.string.import_export_data_path);
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
|
||||
addPreferencesFromResource(R.xml.content_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final Preference importDataPreference = requirePreference(R.string.import_data);
|
||||
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
|
||||
requestImportPathLauncher.launch(
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestImportPathLauncher,
|
||||
StoredFileHelper.getPicker(requireContext(),
|
||||
ZIP_MIME_TYPE, getImportExportDataUri()));
|
||||
ZIP_MIME_TYPE, getImportExportDataUri()),
|
||||
TAG,
|
||||
getContext()
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
final Preference exportDataPreference = requirePreference(R.string.export_data);
|
||||
exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
|
||||
|
||||
requestExportPathLauncher.launch(
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestExportPathLauncher,
|
||||
StoredFileHelper.getNewPicker(requireContext(),
|
||||
"NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
|
||||
ZIP_MIME_TYPE, getImportExportDataUri()));
|
||||
ZIP_MIME_TYPE, getImportExportDataUri()),
|
||||
TAG,
|
||||
getContext()
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
@ -95,21 +104,6 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
.getPreferredContentCountry(requireContext());
|
||||
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
|
||||
final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key);
|
||||
clearCookiePref.setOnPreferenceClickListener(preference -> {
|
||||
defaultPreferences.edit()
|
||||
.putString(getString(R.string.recaptcha_cookies_key), "").apply();
|
||||
DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "");
|
||||
Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
clearCookiePref.setVisible(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) {
|
||||
clearCookiePref.setVisible(false);
|
||||
}
|
||||
|
||||
findPreference(getString(R.string.download_thumbnail_key)).setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
PicassoHelper.setShouldLoadImages((Boolean) newValue);
|
||||
|
@ -205,7 +199,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
saveLastImportExportDataUri(exportDataUri); // save export path only on success
|
||||
Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -247,7 +241,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
finishImport(importDataUri);
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Importing database", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Importing database", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class DebugSettingsFragment extends BasePreferenceFragment {
|
||||
private static final String DUMMY = "Dummy";
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final Preference allowHeapDumpingPreference
|
||||
= findPreference(getString(R.string.allow_heap_dumping_key));
|
||||
final Preference showMemoryLeaksPreference
|
||||
= findPreference(getString(R.string.show_memory_leaks_key));
|
||||
final Preference showImageIndicatorsPreference
|
||||
= findPreference(getString(R.string.show_image_indicators_key));
|
||||
final Preference crashTheAppPreference
|
||||
= findPreference(getString(R.string.crash_the_app_key));
|
||||
final Preference showErrorSnackbarPreference
|
||||
= findPreference(getString(R.string.show_error_snackbar_key));
|
||||
final Preference createErrorNotificationPreference
|
||||
= findPreference(getString(R.string.create_error_notification_key));
|
||||
|
||||
assert allowHeapDumpingPreference != null;
|
||||
assert showMemoryLeaksPreference != null;
|
||||
assert showImageIndicatorsPreference != null;
|
||||
assert crashTheAppPreference != null;
|
||||
assert showErrorSnackbarPreference != null;
|
||||
assert createErrorNotificationPreference != null;
|
||||
|
||||
final Optional<DebugSettingsBVDLeakCanaryAPI> optBVLeakCanary = getBVDLeakCanary();
|
||||
|
||||
allowHeapDumpingPreference.setEnabled(optBVLeakCanary.isPresent());
|
||||
showMemoryLeaksPreference.setEnabled(optBVLeakCanary.isPresent());
|
||||
|
||||
if (optBVLeakCanary.isPresent()) {
|
||||
final DebugSettingsBVDLeakCanaryAPI pdLeakCanary = optBVLeakCanary.get();
|
||||
|
||||
showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> {
|
||||
startActivity(pdLeakCanary.getNewLeakDisplayActivityIntent());
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available);
|
||||
showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available);
|
||||
}
|
||||
|
||||
showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
PicassoHelper.setIndicatorsEnabled((Boolean) newValue);
|
||||
return true;
|
||||
});
|
||||
|
||||
crashTheAppPreference.setOnPreferenceClickListener(preference -> {
|
||||
throw new RuntimeException(DUMMY);
|
||||
});
|
||||
|
||||
showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> {
|
||||
ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this,
|
||||
DUMMY, new RuntimeException(DUMMY));
|
||||
return true;
|
||||
});
|
||||
|
||||
createErrorNotificationPreference.setOnPreferenceClickListener(preference -> {
|
||||
ErrorUtil.createNotification(requireContext(),
|
||||
new ErrorInfo(new RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available.
|
||||
* @return An {@link Optional} which is empty if the implementation class couldn't be loaded.
|
||||
*/
|
||||
private Optional<DebugSettingsBVDLeakCanaryAPI> getBVDLeakCanary() {
|
||||
try {
|
||||
// Try to find the implementation of the LeakCanary API
|
||||
return Optional.of((DebugSettingsBVDLeakCanaryAPI)
|
||||
Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance());
|
||||
} catch (final Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build variant dependent (BVD) leak canary API for this fragment.
|
||||
* Why is LeakCanary not used directly? Because it can't be assured
|
||||
*/
|
||||
public interface DebugSettingsBVDLeakCanaryAPI {
|
||||
String IMPL_CLASS =
|
||||
"org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary";
|
||||
|
||||
Intent getNewLeakDisplayActivityIntent();
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ import androidx.preference.SwitchPreferenceCompat;
|
|||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
|
||||
|
@ -53,7 +54,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
|||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.download_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
downloadPathVideoPreference = getString(R.string.download_path_video_key);
|
||||
downloadPathAudioPreference = getString(R.string.download_path_audio_key);
|
||||
|
@ -214,7 +215,12 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
|||
}
|
||||
|
||||
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
||||
launcher.launch(StoredDirectoryHelper.getPicker(ctx));
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
launcher,
|
||||
StoredDirectoryHelper.getPicker(ctx),
|
||||
TAG,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
private void requestDownloadVideoPathResult(final ActivityResult result) {
|
||||
|
|
|
@ -8,9 +8,11 @@ import androidx.annotation.NonNull;
|
|||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.InfoCache;
|
||||
|
@ -29,7 +31,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.history_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
cacheWipeKey = getString(R.string.metadata_cache_wipe_key);
|
||||
viewsHistoryClearKey = getString(R.string.clear_views_history_key);
|
||||
|
@ -37,6 +39,21 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
searchHistoryClearKey = getString(R.string.clear_search_history_key);
|
||||
recordManager = new HistoryRecordManager(getActivity());
|
||||
disposables = new CompositeDisposable();
|
||||
|
||||
final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key);
|
||||
clearCookiePref.setOnPreferenceClickListener(preference -> {
|
||||
defaultPreferences.edit()
|
||||
.putString(getString(R.string.recaptcha_cookies_key), "").apply();
|
||||
DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "");
|
||||
Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
clearCookiePref.setEnabled(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) {
|
||||
clearCookiePref.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -64,7 +81,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
.subscribe(
|
||||
howManyDeleted -> Toast.makeText(context,
|
||||
R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show(),
|
||||
throwable -> ErrorActivity.reportError(context,
|
||||
throwable -> ErrorUtil.openActivity(context,
|
||||
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
|
||||
"Delete playback states")));
|
||||
}
|
||||
|
@ -76,7 +93,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
.subscribe(
|
||||
howManyDeleted -> Toast.makeText(context,
|
||||
R.string.watch_history_deleted, Toast.LENGTH_SHORT).show(),
|
||||
throwable -> ErrorActivity.reportError(context,
|
||||
throwable -> ErrorUtil.openActivity(context,
|
||||
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
|
||||
"Delete from history")));
|
||||
}
|
||||
|
@ -87,7 +104,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
howManyDeleted -> { },
|
||||
throwable -> ErrorActivity.reportError(context,
|
||||
throwable -> ErrorUtil.openActivity(context,
|
||||
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
|
||||
"Clear orphaned records")));
|
||||
}
|
||||
|
@ -99,7 +116,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment {
|
|||
.subscribe(
|
||||
howManyDeleted -> Toast.makeText(context,
|
||||
R.string.search_history_deleted, Toast.LENGTH_SHORT).show(),
|
||||
throwable -> ErrorActivity.reportError(context,
|
||||
throwable -> ErrorUtil.openActivity(context,
|
||||
new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
|
||||
"Delete search history")));
|
||||
}
|
||||
|
|
|
@ -1,15 +1,72 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.CheckForNewAppVersion;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
public class MainSettingsFragment extends BasePreferenceFragment {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
private SettingsActivity settingsActivity;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.main_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
|
||||
|
||||
// Check if the app is updatable
|
||||
if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
|
||||
getPreferenceScreen().removePreference(
|
||||
findPreference(getString(R.string.update_pref_screen_key)));
|
||||
|
||||
defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply();
|
||||
}
|
||||
|
||||
// Hide debug preferences in RELEASE build variant
|
||||
if (!DEBUG) {
|
||||
getPreferenceScreen().removePreference(
|
||||
findPreference(getString(R.string.debug_pref_screen_key)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(
|
||||
@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater
|
||||
) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
// -- Link settings activity and register menu --
|
||||
settingsActivity = (SettingsActivity) getActivity();
|
||||
|
||||
inflater.inflate(R.menu.menu_settings_main_fragment, menu);
|
||||
|
||||
final MenuItem menuSearchItem = menu.getItem(0);
|
||||
|
||||
settingsActivity.setMenuSearchItem(menuSearchItem);
|
||||
|
||||
menuSearchItem.setOnMenuItemClickListener(ev -> {
|
||||
settingsActivity.setSearchActive(true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
// Unlink activity so that we don't get memory problems
|
||||
if (settingsActivity != null) {
|
||||
settingsActivity.setMenuSearchItem(null);
|
||||
settingsActivity = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import org.schabi.newpipe.R
|
|||
|
||||
class NotificationSettingsFragment : BasePreferenceFragment() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.notification_settings)
|
||||
addPreferencesFromResourceRegistry()
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key))
|
||||
|
|
|
@ -16,7 +16,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
@ -153,7 +153,7 @@ public class SelectChannelFragment extends DialogFragment {
|
|||
|
||||
@Override
|
||||
public void onError(@NonNull final Throwable exception) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(SelectChannelFragment.this,
|
||||
ErrorUtil.showUiErrorSnackbar(SelectChannelFragment.this,
|
||||
"Loading subscription", exception);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
|
@ -16,7 +15,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
|||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
|
@ -48,20 +47,14 @@ import java.util.Vector;
|
|||
*/
|
||||
|
||||
public class SelectKioskFragment extends DialogFragment {
|
||||
private RecyclerView recyclerView = null;
|
||||
private SelectKioskAdapter selectKioskAdapter = null;
|
||||
|
||||
private OnSelectedListener onSelectedListener = null;
|
||||
private OnCancelListener onCancelListener = null;
|
||||
|
||||
public void setOnSelectedListener(final OnSelectedListener listener) {
|
||||
onSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnCancelListener(final OnCancelListener listener) {
|
||||
onCancelListener = listener;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -76,12 +69,12 @@ public class SelectKioskFragment extends DialogFragment {
|
|||
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
final View v = inflater.inflate(R.layout.select_kiosk_fragment, container, false);
|
||||
recyclerView = v.findViewById(R.id.items_list);
|
||||
final RecyclerView recyclerView = v.findViewById(R.id.items_list);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
try {
|
||||
selectKioskAdapter = new SelectKioskAdapter();
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Selecting kiosk", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting kiosk", e);
|
||||
}
|
||||
recyclerView.setAdapter(selectKioskAdapter);
|
||||
|
||||
|
@ -92,14 +85,6 @@ public class SelectKioskFragment extends DialogFragment {
|
|||
// Handle actions
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCancel(@NonNull final DialogInterface dialogInterface) {
|
||||
super.onCancel(dialogInterface);
|
||||
if (onCancelListener != null) {
|
||||
onCancelListener.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void clickedItem(final SelectKioskAdapter.Entry entry) {
|
||||
if (onSelectedListener != null) {
|
||||
onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName);
|
||||
|
@ -115,10 +100,6 @@ public class SelectKioskFragment extends DialogFragment {
|
|||
void onKioskSelected(int serviceId, String kioskId, String kioskName);
|
||||
}
|
||||
|
||||
public interface OnCancelListener {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectKioskAdapter
|
||||
extends RecyclerView.Adapter<SelectKioskAdapter.SelectKioskItemHolder> {
|
||||
private final List<Entry> kioskList = new Vector<>();
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
|
@ -21,8 +20,8 @@ import org.schabi.newpipe.database.LocalItem;
|
|||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
|
@ -105,8 +104,7 @@ public class SelectPlaylistFragment extends DialogFragment {
|
|||
}
|
||||
|
||||
protected void onError(final Throwable e) {
|
||||
final Activity activity = requireActivity();
|
||||
ErrorActivity.reportErrorInSnackbar(activity, new ErrorInfo(e,
|
||||
ErrorUtil.showSnackbar(requireActivity(), new ErrorInfo(e,
|
||||
UserAction.UI_ERROR, "Loading playlists"));
|
||||
}
|
||||
|
||||
|
|
|
@ -8,8 +8,8 @@ import android.util.Log;
|
|||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
|
@ -157,7 +157,7 @@ public final class SettingMigrations {
|
|||
} catch (final Exception e) {
|
||||
// save the version with the last successful migration and report the error
|
||||
sp.edit().putInt(lastPrefVersionKey, currentVersion).apply();
|
||||
ErrorActivity.reportError(context, new ErrorInfo(
|
||||
ErrorUtil.openActivity(context, new ErrorInfo(
|
||||
e,
|
||||
UserAction.PREFERENCES_MIGRATION,
|
||||
"Migrating preferences from version " + lastPrefVersion + " to "
|
||||
|
|
|
@ -1,22 +1,49 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import com.jakewharton.rxbinding4.widget.RxTextView;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.CheckForNewAppVersion;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.SettingsLayoutBinding;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceParser;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 31.08.15.
|
||||
|
@ -38,21 +65,54 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
|||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity
|
||||
implements BasePreferenceFragment.OnPreferenceStartFragmentCallback {
|
||||
public class SettingsActivity extends AppCompatActivity implements
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
PreferenceSearchResultListener {
|
||||
private static final String TAG = "SettingsActivity";
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
@IdRes
|
||||
private static final int FRAGMENT_HOLDER_ID = R.id.settings_fragment_holder;
|
||||
|
||||
private PreferenceSearchFragment searchFragment;
|
||||
|
||||
@Nullable
|
||||
private MenuItem menuSearchItem;
|
||||
|
||||
private View searchContainer;
|
||||
private EditText searchEditText;
|
||||
|
||||
// State
|
||||
@State
|
||||
String searchText;
|
||||
@State
|
||||
boolean wasSearchActive;
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceBundle) {
|
||||
setTheme(ThemeHelper.getSettingsThemeStyle(this));
|
||||
assureCorrectAppLanguage(this);
|
||||
|
||||
super.onCreate(savedInstanceBundle);
|
||||
Icepick.restoreInstanceState(this, savedInstanceBundle);
|
||||
final boolean restored = savedInstanceBundle != null;
|
||||
|
||||
final SettingsLayoutBinding settingsLayoutBinding =
|
||||
SettingsLayoutBinding.inflate(getLayoutInflater());
|
||||
setContentView(settingsLayoutBinding.getRoot());
|
||||
initSearch(settingsLayoutBinding, restored);
|
||||
|
||||
setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar);
|
||||
|
||||
if (savedInstanceBundle == null) {
|
||||
if (restored) {
|
||||
// Restore state
|
||||
if (this.wasSearchActive) {
|
||||
setSearchActive(true);
|
||||
if (!TextUtils.isEmpty(this.searchText)) {
|
||||
this.searchEditText.setText(this.searchText);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.settings_fragment_holder, new MainSettingsFragment())
|
||||
.commit();
|
||||
|
@ -63,6 +123,12 @@ public class SettingsActivity extends AppCompatActivity
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
Icepick.saveInstanceState(this, outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(final Menu menu) {
|
||||
final ActionBar actionBar = getSupportActionBar();
|
||||
|
@ -74,10 +140,25 @@ public class SettingsActivity extends AppCompatActivity
|
|||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (isSearchActive()) {
|
||||
setSearchActive(false);
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
final int id = item.getItemId();
|
||||
if (id == android.R.id.home) {
|
||||
// Check if the search is active and if so: Close it
|
||||
if (isSearchActive()) {
|
||||
setSearchActive(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
|
||||
finish();
|
||||
} else {
|
||||
|
@ -91,14 +172,223 @@ public class SettingsActivity extends AppCompatActivity
|
|||
@Override
|
||||
public boolean onPreferenceStartFragment(final PreferenceFragmentCompat caller,
|
||||
final Preference preference) {
|
||||
final Fragment fragment = Fragment
|
||||
.instantiate(this, preference.getFragment(), preference.getExtras());
|
||||
showSettingsFragment(instantiateFragment(preference.getFragment()));
|
||||
return true;
|
||||
}
|
||||
|
||||
private Fragment instantiateFragment(@NonNull final String className) {
|
||||
return getSupportFragmentManager()
|
||||
.getFragmentFactory()
|
||||
.instantiate(this.getClassLoader(), className);
|
||||
}
|
||||
|
||||
private void showSettingsFragment(final Fragment fragment) {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out,
|
||||
R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
.replace(R.id.settings_fragment_holder, fragment)
|
||||
.replace(FRAGMENT_HOLDER_ID, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
setMenuSearchItem(null);
|
||||
searchFragment = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Search
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
//region Search
|
||||
|
||||
private void initSearch(
|
||||
final SettingsLayoutBinding settingsLayoutBinding,
|
||||
final boolean restored
|
||||
) {
|
||||
searchContainer =
|
||||
settingsLayoutBinding.settingsToolbarLayout.toolbar
|
||||
.findViewById(R.id.toolbar_search_container);
|
||||
|
||||
// Configure input field for search
|
||||
searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text);
|
||||
RxTextView.textChanges(searchEditText)
|
||||
// Wait some time after the last input before actually searching
|
||||
.debounce(200, TimeUnit.MILLISECONDS)
|
||||
.subscribe(v -> runOnUiThread(this::onSearchChanged));
|
||||
|
||||
// Configure clear button
|
||||
searchContainer.findViewById(R.id.toolbar_search_clear)
|
||||
.setOnClickListener(ev -> resetSearchText());
|
||||
|
||||
ensureSearchRepresentsApplicationState();
|
||||
|
||||
// Build search configuration using SettingsResourceRegistry
|
||||
final PreferenceSearchConfiguration config = new PreferenceSearchConfiguration();
|
||||
|
||||
|
||||
// Build search items
|
||||
final Context searchContext = getApplicationContext();
|
||||
assureCorrectAppLanguage(searchContext);
|
||||
final PreferenceParser parser = new PreferenceParser(searchContext, config);
|
||||
final PreferenceSearcher searcher = new PreferenceSearcher(config);
|
||||
|
||||
// Find all searchable SettingsResourceRegistry fragments
|
||||
SettingsResourceRegistry.getInstance().getAllEntries().stream()
|
||||
.filter(SettingsResourceRegistry.SettingRegistryEntry::isSearchable)
|
||||
// Get the resId
|
||||
.map(SettingsResourceRegistry.SettingRegistryEntry::getPreferencesResId)
|
||||
// Parse
|
||||
.map(parser::parse)
|
||||
// Add it to the searcher
|
||||
.forEach(searcher::add);
|
||||
|
||||
if (restored) {
|
||||
searchFragment = (PreferenceSearchFragment) getSupportFragmentManager()
|
||||
.findFragmentByTag(PreferenceSearchFragment.NAME);
|
||||
if (searchFragment != null) {
|
||||
// Hide/Remove the search fragment otherwise we get an exception
|
||||
// when adding it (because it's already present)
|
||||
hideSearchFragment();
|
||||
}
|
||||
}
|
||||
if (searchFragment == null) {
|
||||
searchFragment = new PreferenceSearchFragment();
|
||||
}
|
||||
searchFragment.setSearcher(searcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the search shows the correct/available search results.
|
||||
* <br/>
|
||||
* Some features are e.g. only available for debug builds, these should not
|
||||
* be found when searching inside a release.
|
||||
*/
|
||||
private void ensureSearchRepresentsApplicationState() {
|
||||
// Check if the update settings are available
|
||||
if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getEntryByPreferencesResId(R.xml.update_settings)
|
||||
.setSearchable(false);
|
||||
}
|
||||
|
||||
// Hide debug preferences in RELEASE build variant
|
||||
if (DEBUG) {
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getEntryByPreferencesResId(R.xml.debug_settings)
|
||||
.setSearchable(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMenuSearchItem(final MenuItem menuSearchItem) {
|
||||
this.menuSearchItem = menuSearchItem;
|
||||
|
||||
// Ensure that the item is in the correct state when adding it. This is due to
|
||||
// Android's lifecycle (the Activity is recreated before the Fragment that registers this)
|
||||
if (menuSearchItem != null) {
|
||||
menuSearchItem.setVisible(!isSearchActive());
|
||||
}
|
||||
}
|
||||
|
||||
public void setSearchActive(final boolean active) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "setSearchActive called active=" + active);
|
||||
}
|
||||
|
||||
// Ignore if search is already in correct state
|
||||
if (isSearchActive() == active) {
|
||||
return;
|
||||
}
|
||||
|
||||
wasSearchActive = active;
|
||||
|
||||
searchContainer.setVisibility(active ? View.VISIBLE : View.GONE);
|
||||
if (menuSearchItem != null) {
|
||||
menuSearchItem.setVisible(!active);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME)
|
||||
.addToBackStack(PreferenceSearchFragment.NAME)
|
||||
.commit();
|
||||
|
||||
KeyboardUtil.showKeyboard(this, searchEditText);
|
||||
} else if (searchFragment != null) {
|
||||
hideSearchFragment();
|
||||
getSupportFragmentManager()
|
||||
.popBackStack(
|
||||
PreferenceSearchFragment.NAME,
|
||||
FragmentManager.POP_BACK_STACK_INCLUSIVE);
|
||||
|
||||
KeyboardUtil.hideKeyboard(this, searchEditText);
|
||||
}
|
||||
|
||||
resetSearchText();
|
||||
}
|
||||
|
||||
private void hideSearchFragment() {
|
||||
getSupportFragmentManager().beginTransaction().remove(searchFragment).commit();
|
||||
}
|
||||
|
||||
private void resetSearchText() {
|
||||
searchEditText.setText("");
|
||||
}
|
||||
|
||||
private boolean isSearchActive() {
|
||||
return searchContainer.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
private void onSearchChanged() {
|
||||
if (!isSearchActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchFragment != null) {
|
||||
searchText = this.searchEditText.getText().toString();
|
||||
searchFragment.updateSearchResults(searchText);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchResultClicked(@NonNull final PreferenceSearchItem result) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSearchResultClicked called result=" + result);
|
||||
}
|
||||
|
||||
// Hide the search
|
||||
setSearchActive(false);
|
||||
|
||||
// -- Highlight the result --
|
||||
// Find out which fragment class we need
|
||||
final Class<? extends Fragment> targetedFragmentClass =
|
||||
SettingsResourceRegistry.getInstance()
|
||||
.getFragmentClass(result.getSearchIndexItemResId());
|
||||
|
||||
if (targetedFragmentClass == null) {
|
||||
// This should never happen
|
||||
Log.w(TAG, "Unable to locate fragment class for resId="
|
||||
+ result.getSearchIndexItemResId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the currentFragment is the one which contains the result
|
||||
Fragment currentFragment =
|
||||
getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID);
|
||||
if (!targetedFragmentClass.equals(currentFragment.getClass())) {
|
||||
// If it's not the correct one display the correct one
|
||||
currentFragment = instantiateFragment(targetedFragmentClass.getName());
|
||||
showSettingsFragment(currentFragment);
|
||||
}
|
||||
|
||||
// Run the highlighting
|
||||
if (currentFragment instanceof PreferenceFragmentCompat) {
|
||||
PreferenceSearchResultHighlighter
|
||||
.highlight(result, (PreferenceFragmentCompat) currentFragment);
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.XmlRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A registry that contains information about SettingsFragments.
|
||||
* <br/>
|
||||
* includes:
|
||||
* <ul>
|
||||
* <li>Class of the SettingsFragment</li>
|
||||
* <li>XML-Resource</li>
|
||||
* <li>...</li>
|
||||
* </ul>
|
||||
*
|
||||
* E.g. used by the preference search.
|
||||
*/
|
||||
public final class SettingsResourceRegistry {
|
||||
|
||||
private static final SettingsResourceRegistry INSTANCE = new SettingsResourceRegistry();
|
||||
|
||||
private final Set<SettingRegistryEntry> registeredEntries = new HashSet<>();
|
||||
|
||||
private SettingsResourceRegistry() {
|
||||
add(MainSettingsFragment.class, R.xml.main_settings).setSearchable(false);
|
||||
|
||||
add(AppearanceSettingsFragment.class, R.xml.appearance_settings);
|
||||
add(ContentSettingsFragment.class, R.xml.content_settings);
|
||||
add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false);
|
||||
add(DownloadSettingsFragment.class, R.xml.download_settings);
|
||||
add(HistorySettingsFragment.class, R.xml.history_settings);
|
||||
add(NotificationSettingsFragment.class, R.xml.notification_settings);
|
||||
add(UpdateSettingsFragment.class, R.xml.update_settings);
|
||||
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
|
||||
add(VideoAudioSettingsFragment.class, R.xml.sponsor_block_settings);
|
||||
add(VideoAudioSettingsFragment.class, R.xml.extra_settings);
|
||||
}
|
||||
|
||||
private SettingRegistryEntry add(
|
||||
@NonNull final Class<? extends Fragment> fragmentClass,
|
||||
@XmlRes final int preferencesResId
|
||||
) {
|
||||
final SettingRegistryEntry entry =
|
||||
new SettingRegistryEntry(fragmentClass, preferencesResId);
|
||||
this.registeredEntries.add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public SettingRegistryEntry getEntryByFragmentClass(
|
||||
final Class<? extends Fragment> fragmentClass
|
||||
) {
|
||||
Objects.requireNonNull(fragmentClass);
|
||||
return registeredEntries.stream()
|
||||
.filter(e -> Objects.equals(e.getFragmentClass(), fragmentClass))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public SettingRegistryEntry getEntryByPreferencesResId(@XmlRes final int preferencesResId) {
|
||||
return registeredEntries.stream()
|
||||
.filter(e -> Objects.equals(e.getPreferencesResId(), preferencesResId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public int getPreferencesResId(@NonNull final Class<? extends Fragment> fragmentClass) {
|
||||
final SettingRegistryEntry entry = getEntryByFragmentClass(fragmentClass);
|
||||
if (entry == null) {
|
||||
return -1;
|
||||
}
|
||||
return entry.getPreferencesResId();
|
||||
}
|
||||
|
||||
public Class<? extends Fragment> getFragmentClass(@XmlRes final int preferencesResId) {
|
||||
final SettingRegistryEntry entry = getEntryByPreferencesResId(preferencesResId);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
return entry.getFragmentClass();
|
||||
}
|
||||
|
||||
public Set<SettingRegistryEntry> getAllEntries() {
|
||||
return new HashSet<>(registeredEntries);
|
||||
}
|
||||
|
||||
public static SettingsResourceRegistry getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
|
||||
public static class SettingRegistryEntry {
|
||||
@NonNull
|
||||
private final Class<? extends Fragment> fragmentClass;
|
||||
@XmlRes
|
||||
private final int preferencesResId;
|
||||
|
||||
private boolean searchable = true;
|
||||
|
||||
public SettingRegistryEntry(
|
||||
@NonNull final Class<? extends Fragment> fragmentClass,
|
||||
@XmlRes final int preferencesResId
|
||||
) {
|
||||
this.fragmentClass = Objects.requireNonNull(fragmentClass);
|
||||
this.preferencesResId = preferencesResId;
|
||||
}
|
||||
|
||||
@SuppressWarnings("HiddenField")
|
||||
public SettingRegistryEntry setSearchable(final boolean searchable) {
|
||||
this.searchable = searchable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Class<? extends Fragment> getFragmentClass() {
|
||||
return fragmentClass;
|
||||
}
|
||||
|
||||
public int getPreferencesResId() {
|
||||
return preferencesResId;
|
||||
}
|
||||
|
||||
public boolean isSearchable() {
|
||||
return searchable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final SettingRegistryEntry that = (SettingRegistryEntry) o;
|
||||
return getPreferencesResId() == that.getPreferencesResId()
|
||||
&& getFragmentClass().equals(that.getFragmentClass());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getFragmentClass(), getPreferencesResId());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
|
|||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.update_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
final String updateToggleKey = getString(R.string.update_app_key);
|
||||
findPreference(updateToggleKey).setOnPreferenceChangeListener(updatePreferenceChange);
|
||||
|
|
|
@ -23,7 +23,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
addPreferencesFromResource(R.xml.video_audio_settings);
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
updateSeekOptions();
|
||||
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.text.similarity.FuzzyScore;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PreferenceFuzzySearchFunction
|
||||
implements PreferenceSearchConfiguration.PreferenceSearchFunction {
|
||||
|
||||
private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT);
|
||||
|
||||
@Override
|
||||
public Stream<PreferenceSearchItem> search(
|
||||
final Stream<PreferenceSearchItem> allAvailable,
|
||||
final String keyword
|
||||
) {
|
||||
final int maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score
|
||||
|
||||
return allAvailable
|
||||
// General search
|
||||
// Check all fields if anyone contains something that kind of matches the keyword
|
||||
.map(item -> new FuzzySearchGeneralDTO(item, keyword))
|
||||
.filter(dto -> dto.getScore() / maxScore >= 0.3f)
|
||||
.map(FuzzySearchGeneralDTO::getItem)
|
||||
// Specific search - Used for determining order of search results
|
||||
// Calculate a score based on specific search fields
|
||||
.map(item -> new FuzzySearchSpecificDTO(item, keyword))
|
||||
.sorted(Comparator.comparing(FuzzySearchSpecificDTO::getScore).reversed())
|
||||
.map(FuzzySearchSpecificDTO::getItem)
|
||||
// Limit the amount of search results
|
||||
.limit(20);
|
||||
}
|
||||
|
||||
static class FuzzySearchGeneralDTO {
|
||||
private final PreferenceSearchItem item;
|
||||
private final float score;
|
||||
|
||||
FuzzySearchGeneralDTO(
|
||||
final PreferenceSearchItem item,
|
||||
final String keyword) {
|
||||
this.item = item;
|
||||
this.score = FUZZY_SCORE.fuzzyScore(
|
||||
TextUtils.join(";", item.getAllRelevantSearchFields()),
|
||||
keyword);
|
||||
}
|
||||
|
||||
public PreferenceSearchItem getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
public float getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
static class FuzzySearchSpecificDTO {
|
||||
private static final Map<Function<PreferenceSearchItem, String>, Float> WEIGHT_MAP = Map.of(
|
||||
// The user will most likely look for the title -> prioritize it
|
||||
PreferenceSearchItem::getTitle, 1.5f,
|
||||
// The summary is also important as it usually contains a larger desc
|
||||
// Example: Searching for '4k' → 'show higher resolution' is shown
|
||||
PreferenceSearchItem::getSummary, 1f,
|
||||
// Entries are also important as they provide all known/possible values
|
||||
// Example: Searching where the resolution can be changed to 720p
|
||||
PreferenceSearchItem::getEntries, 1f
|
||||
);
|
||||
|
||||
private final PreferenceSearchItem item;
|
||||
private final float score;
|
||||
|
||||
FuzzySearchSpecificDTO(
|
||||
final PreferenceSearchItem item,
|
||||
final String keyword) {
|
||||
this.item = item;
|
||||
|
||||
float attributeScoreSum = 0;
|
||||
int countOfAttributesWithScore = 0;
|
||||
for (final Map.Entry<Function<PreferenceSearchItem, String>, Float> we
|
||||
: WEIGHT_MAP.entrySet()) {
|
||||
final String valueToProcess = we.getKey().apply(item);
|
||||
if (valueToProcess.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
attributeScoreSum +=
|
||||
FUZZY_SCORE.fuzzyScore(valueToProcess, keyword) * we.getValue();
|
||||
countOfAttributesWithScore++;
|
||||
}
|
||||
|
||||
if (countOfAttributesWithScore != 0) {
|
||||
this.score = attributeScoreSum / countOfAttributesWithScore;
|
||||
} else {
|
||||
this.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public PreferenceSearchItem getItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
public float getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.XmlRes;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Parses the corresponding preference-file(s).
|
||||
*/
|
||||
public class PreferenceParser {
|
||||
private static final String TAG = "PreferenceParser";
|
||||
|
||||
private static final String NS_ANDROID = "http://schemas.android.com/apk/res/android";
|
||||
private static final String NS_SEARCH = "http://schemas.android.com/apk/preferencesearch";
|
||||
|
||||
private final Context context;
|
||||
private final Map<String, ?> allPreferences;
|
||||
private final PreferenceSearchConfiguration searchConfiguration;
|
||||
|
||||
public PreferenceParser(
|
||||
final Context context,
|
||||
final PreferenceSearchConfiguration searchConfiguration
|
||||
) {
|
||||
this.context = context;
|
||||
this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll();
|
||||
this.searchConfiguration = searchConfiguration;
|
||||
}
|
||||
|
||||
public List<PreferenceSearchItem> parse(
|
||||
@XmlRes final int resId
|
||||
) {
|
||||
final List<PreferenceSearchItem> results = new ArrayList<>();
|
||||
final XmlPullParser xpp = context.getResources().getXml(resId);
|
||||
|
||||
try {
|
||||
xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
|
||||
xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true);
|
||||
|
||||
final List<String> breadcrumbs = new ArrayList<>();
|
||||
while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.getEventType() == XmlPullParser.START_TAG) {
|
||||
final PreferenceSearchItem result = parseSearchResult(
|
||||
xpp,
|
||||
joinBreadcrumbs(breadcrumbs),
|
||||
resId
|
||||
);
|
||||
|
||||
if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName())
|
||||
&& result.hasData()
|
||||
&& !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) {
|
||||
results.add(result);
|
||||
}
|
||||
if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) {
|
||||
// This code adds breadcrumbs for certain containers (e.g. PreferenceScreen)
|
||||
// Example: Video and Audio > Player
|
||||
breadcrumbs.add(result.getTitle() == null ? "" : result.getTitle());
|
||||
}
|
||||
} else if (xpp.getEventType() == XmlPullParser.END_TAG
|
||||
&& searchConfiguration.getParserContainerElements()
|
||||
.contains(xpp.getName())) {
|
||||
breadcrumbs.remove(breadcrumbs.size() - 1);
|
||||
}
|
||||
|
||||
xpp.next();
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Failed to parse resid=" + resId, e);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private String joinBreadcrumbs(final List<String> breadcrumbs) {
|
||||
return breadcrumbs.stream()
|
||||
.filter(crumb -> !TextUtils.isEmpty(crumb))
|
||||
.collect(Collectors.joining(" > "));
|
||||
}
|
||||
|
||||
private String getAttribute(
|
||||
final XmlPullParser xpp,
|
||||
@NonNull final String attribute
|
||||
) {
|
||||
final String nsSearchAttr = getAttribute(xpp, NS_SEARCH, attribute);
|
||||
if (nsSearchAttr != null) {
|
||||
return nsSearchAttr;
|
||||
}
|
||||
return getAttribute(xpp, NS_ANDROID, attribute);
|
||||
}
|
||||
|
||||
private String getAttribute(
|
||||
final XmlPullParser xpp,
|
||||
@NonNull final String namespace,
|
||||
@NonNull final String attribute
|
||||
) {
|
||||
return xpp.getAttributeValue(namespace, attribute);
|
||||
}
|
||||
|
||||
private PreferenceSearchItem parseSearchResult(
|
||||
final XmlPullParser xpp,
|
||||
final String breadcrumbs,
|
||||
@XmlRes final int searchIndexItemResId
|
||||
) {
|
||||
final String key = readString(getAttribute(xpp, "key"));
|
||||
final String[] entries = readStringArray(getAttribute(xpp, "entries"));
|
||||
final String[] entryValues = readStringArray(getAttribute(xpp, "entryValues"));
|
||||
|
||||
return new PreferenceSearchItem(
|
||||
key,
|
||||
tryFillInPreferenceValue(
|
||||
readString(getAttribute(xpp, "title")),
|
||||
key,
|
||||
entries,
|
||||
entryValues),
|
||||
tryFillInPreferenceValue(
|
||||
readString(getAttribute(xpp, "summary")),
|
||||
key,
|
||||
entries,
|
||||
entryValues),
|
||||
TextUtils.join(",", entries),
|
||||
breadcrumbs,
|
||||
searchIndexItemResId
|
||||
);
|
||||
}
|
||||
|
||||
private String[] readStringArray(@Nullable final String s) {
|
||||
if (s == null) {
|
||||
return new String[0];
|
||||
}
|
||||
if (s.startsWith("@")) {
|
||||
try {
|
||||
return context.getResources().getStringArray(Integer.parseInt(s.substring(1)));
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Unable to readStringArray from '" + s + "'", e);
|
||||
}
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
private String readString(@Nullable final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (s.startsWith("@")) {
|
||||
try {
|
||||
return context.getString(Integer.parseInt(s.substring(1)));
|
||||
} catch (final Exception e) {
|
||||
Log.w(TAG, "Unable to readString from '" + s + "'", e);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private String tryFillInPreferenceValue(
|
||||
@Nullable final String s,
|
||||
@Nullable final String key,
|
||||
final String[] entries,
|
||||
final String[] entryValues
|
||||
) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (key == null) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Resolve value
|
||||
Object prefValue = allPreferences.get(key);
|
||||
if (prefValue == null) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/*
|
||||
* Resolve ListPreference values
|
||||
*
|
||||
* entryValues = Values/Keys that are saved
|
||||
* entries = Actual human readable names
|
||||
*/
|
||||
if (entries.length > 0 && entryValues.length == entries.length) {
|
||||
final int entryIndex = Arrays.asList(entryValues).indexOf(prefValue);
|
||||
if (entryIndex != -1) {
|
||||
prefValue = entries[entryIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return String.format(s, prefValue.toString());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.databinding.SettingsPreferencesearchListItemResultBinding;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
class PreferenceSearchAdapter
|
||||
extends RecyclerView.Adapter<PreferenceSearchAdapter.PreferenceViewHolder> {
|
||||
private List<PreferenceSearchItem> dataset = new ArrayList<>();
|
||||
private Consumer<PreferenceSearchItem> onItemClickListener;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public PreferenceViewHolder onCreateViewHolder(
|
||||
@NonNull final ViewGroup parent,
|
||||
final int viewType
|
||||
) {
|
||||
return new PreferenceViewHolder(
|
||||
SettingsPreferencesearchListItemResultBinding.inflate(
|
||||
LayoutInflater.from(parent.getContext()),
|
||||
parent,
|
||||
false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(
|
||||
@NonNull final PreferenceViewHolder holder,
|
||||
final int position
|
||||
) {
|
||||
final PreferenceSearchItem item = dataset.get(position);
|
||||
|
||||
holder.binding.title.setText(item.getTitle());
|
||||
|
||||
if (TextUtils.isEmpty(item.getSummary())) {
|
||||
holder.binding.summary.setVisibility(View.GONE);
|
||||
} else {
|
||||
holder.binding.summary.setVisibility(View.VISIBLE);
|
||||
holder.binding.summary.setText(item.getSummary());
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(item.getBreadcrumbs())) {
|
||||
holder.binding.breadcrumbs.setVisibility(View.GONE);
|
||||
} else {
|
||||
holder.binding.breadcrumbs.setVisibility(View.VISIBLE);
|
||||
holder.binding.breadcrumbs.setText(item.getBreadcrumbs());
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onItemClickListener != null) {
|
||||
onItemClickListener.accept(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setContent(final List<PreferenceSearchItem> items) {
|
||||
dataset = new ArrayList<>(items);
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return dataset.size();
|
||||
}
|
||||
|
||||
void setOnItemClickListener(final Consumer<PreferenceSearchItem> onItemClickListener) {
|
||||
this.onItemClickListener = onItemClickListener;
|
||||
}
|
||||
|
||||
static class PreferenceViewHolder extends RecyclerView.ViewHolder {
|
||||
final SettingsPreferencesearchListItemResultBinding binding;
|
||||
|
||||
PreferenceViewHolder(final SettingsPreferencesearchListItemResultBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.preference.PreferenceCategory;
|
||||
import androidx.preference.PreferenceScreen;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PreferenceSearchConfiguration {
|
||||
private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction();
|
||||
|
||||
private final List<String> parserIgnoreElements = Arrays.asList(
|
||||
PreferenceCategory.class.getSimpleName());
|
||||
private final List<String> parserContainerElements = Arrays.asList(
|
||||
PreferenceCategory.class.getSimpleName(),
|
||||
PreferenceScreen.class.getSimpleName());
|
||||
|
||||
|
||||
public void setSearcher(final PreferenceSearchFunction searcher) {
|
||||
this.searcher = Objects.requireNonNull(searcher);
|
||||
}
|
||||
|
||||
public PreferenceSearchFunction getSearcher() {
|
||||
return searcher;
|
||||
}
|
||||
|
||||
public List<String> getParserIgnoreElements() {
|
||||
return parserIgnoreElements;
|
||||
}
|
||||
|
||||
public List<String> getParserContainerElements() {
|
||||
return parserContainerElements;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PreferenceSearchFunction {
|
||||
Stream<PreferenceSearchItem> search(
|
||||
Stream<PreferenceSearchItem> allAvailable,
|
||||
String keyword);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Displays the search results.
|
||||
*/
|
||||
public class PreferenceSearchFragment extends Fragment {
|
||||
public static final String NAME = PreferenceSearchFragment.class.getSimpleName();
|
||||
|
||||
private PreferenceSearcher searcher;
|
||||
|
||||
private SettingsPreferencesearchFragmentBinding binding;
|
||||
private PreferenceSearchAdapter adapter;
|
||||
|
||||
public void setSearcher(final PreferenceSearcher searcher) {
|
||||
this.searcher = searcher;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState
|
||||
) {
|
||||
binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false);
|
||||
|
||||
binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
|
||||
adapter = new PreferenceSearchAdapter();
|
||||
adapter.setOnItemClickListener(this::onItemClicked);
|
||||
binding.searchResults.setAdapter(adapter);
|
||||
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
public void updateSearchResults(final String keyword) {
|
||||
if (adapter == null || searcher == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<PreferenceSearchItem> results =
|
||||
!TextUtils.isEmpty(keyword)
|
||||
? searcher.searchFor(keyword)
|
||||
: new ArrayList<>();
|
||||
|
||||
adapter.setContent(new ArrayList<>(results));
|
||||
|
||||
setEmptyViewShown(results.isEmpty());
|
||||
}
|
||||
|
||||
private void setEmptyViewShown(final boolean shown) {
|
||||
binding.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE);
|
||||
binding.searchResults.setVisibility(shown ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
public void onItemClicked(final PreferenceSearchItem item) {
|
||||
if (!(getActivity() instanceof PreferenceSearchResultListener)) {
|
||||
throw new ClassCastException(
|
||||
getActivity().toString() + " must implement SearchPreferenceResultListener");
|
||||
}
|
||||
|
||||
((PreferenceSearchResultListener) getActivity()).onSearchResultClicked(item);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.XmlRes;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a preference-item inside the search.
|
||||
*/
|
||||
public class PreferenceSearchItem {
|
||||
/**
|
||||
* Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
@NonNull
|
||||
private final String key;
|
||||
/**
|
||||
* Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String title;
|
||||
/**
|
||||
* Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String summary;
|
||||
/**
|
||||
* Possible entries of the setting, e.g. 480p,720p,...
|
||||
*/
|
||||
@NonNull
|
||||
private final String entries;
|
||||
/**
|
||||
* Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
||||
*/
|
||||
@NonNull
|
||||
private final String breadcrumbs;
|
||||
/**
|
||||
* The xml-resource where this item was found/built from.
|
||||
*/
|
||||
@XmlRes
|
||||
private final int searchIndexItemResId;
|
||||
|
||||
public PreferenceSearchItem(
|
||||
@NonNull final String key,
|
||||
@NonNull final String title,
|
||||
@NonNull final String summary,
|
||||
@NonNull final String entries,
|
||||
@NonNull final String breadcrumbs,
|
||||
@XmlRes final int searchIndexItemResId
|
||||
) {
|
||||
this.key = Objects.requireNonNull(key);
|
||||
this.title = Objects.requireNonNull(title);
|
||||
this.summary = Objects.requireNonNull(summary);
|
||||
this.entries = Objects.requireNonNull(entries);
|
||||
this.breadcrumbs = Objects.requireNonNull(breadcrumbs);
|
||||
this.searchIndexItemResId = searchIndexItemResId;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public String getEntries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public String getBreadcrumbs() {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
public int getSearchIndexItemResId() {
|
||||
return searchIndexItemResId;
|
||||
}
|
||||
|
||||
boolean hasData() {
|
||||
return !key.isEmpty() && !title.isEmpty();
|
||||
}
|
||||
|
||||
public List<String> getAllRelevantSearchFields() {
|
||||
return Arrays.asList(
|
||||
getTitle(),
|
||||
getSummary(),
|
||||
getEntries(),
|
||||
getBreadcrumbs());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PreferenceItem: " + title + " " + summary + " " + key;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.RippleDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
import androidx.preference.PreferenceGroup;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
|
||||
public final class PreferenceSearchResultHighlighter {
|
||||
private static final String TAG = "PrefSearchResHighlter";
|
||||
|
||||
private PreferenceSearchResultHighlighter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the specified preference.
|
||||
* <br/>
|
||||
* Note: This function is Thread independent (can be called from outside of the main thread).
|
||||
*
|
||||
* @param item The item to highlight
|
||||
* @param prefsFragment The fragment where the items is located on
|
||||
*/
|
||||
public static void highlight(
|
||||
final PreferenceSearchItem item,
|
||||
final PreferenceFragmentCompat prefsFragment
|
||||
) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment));
|
||||
}
|
||||
|
||||
private static void doHighlight(
|
||||
final PreferenceSearchItem item,
|
||||
final PreferenceFragmentCompat prefsFragment
|
||||
) {
|
||||
final Preference prefResult = prefsFragment.findPreference(item.getKey());
|
||||
|
||||
if (prefResult == null) {
|
||||
Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
final RecyclerView recyclerView = prefsFragment.getListView();
|
||||
final RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
|
||||
if (adapter instanceof PreferenceGroup.PreferencePositionCallback) {
|
||||
final int position = ((PreferenceGroup.PreferencePositionCallback) adapter)
|
||||
.getPreferenceAdapterPosition(prefResult);
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
recyclerView.scrollToPosition(position);
|
||||
recyclerView.postDelayed(() -> {
|
||||
final RecyclerView.ViewHolder holder =
|
||||
recyclerView.findViewHolderForAdapterPosition(position);
|
||||
if (holder != null) {
|
||||
final Drawable background = holder.itemView.getBackground();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& background instanceof RippleDrawable) {
|
||||
showRippleAnimation((RippleDrawable) background);
|
||||
return;
|
||||
}
|
||||
}
|
||||
highlightFallback(prefsFragment, prefResult);
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
highlightFallback(prefsFragment, prefResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative highlighting (shows an → arrow in front of the setting)if ripple does not work.
|
||||
*
|
||||
* @param prefsFragment
|
||||
* @param prefResult
|
||||
*/
|
||||
private static void highlightFallback(
|
||||
final PreferenceFragmentCompat prefsFragment,
|
||||
final Preference prefResult
|
||||
) {
|
||||
// Get primary color from text for highlight icon
|
||||
final TypedValue typedValue = new TypedValue();
|
||||
final Resources.Theme theme = prefsFragment.getActivity().getTheme();
|
||||
theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true);
|
||||
final TypedArray arr = prefsFragment.getActivity()
|
||||
.obtainStyledAttributes(
|
||||
typedValue.data,
|
||||
new int[]{android.R.attr.textColorPrimary});
|
||||
final int color = arr.getColor(0, 0xffE53935);
|
||||
arr.recycle();
|
||||
|
||||
// Show highlight icon
|
||||
final Drawable oldIcon = prefResult.getIcon();
|
||||
final boolean oldSpaceReserved = prefResult.isIconSpaceReserved();
|
||||
final Drawable highlightIcon =
|
||||
AppCompatResources.getDrawable(
|
||||
prefsFragment.requireContext(),
|
||||
R.drawable.ic_play_arrow);
|
||||
highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
|
||||
prefResult.setIcon(highlightIcon);
|
||||
|
||||
prefsFragment.scrollToPreference(prefResult);
|
||||
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
prefResult.setIcon(oldIcon);
|
||||
prefResult.setIconSpaceReserved(oldSpaceReserved);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private static void showRippleAnimation(final RippleDrawable rippleDrawable) {
|
||||
rippleDrawable.setState(
|
||||
new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled});
|
||||
new Handler(Looper.getMainLooper())
|
||||
.postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface PreferenceSearchResultListener {
|
||||
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class PreferenceSearcher {
|
||||
private final List<PreferenceSearchItem> allEntries = new ArrayList<>();
|
||||
|
||||
private final PreferenceSearchConfiguration configuration;
|
||||
|
||||
public PreferenceSearcher(final PreferenceSearchConfiguration configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public void add(final List<PreferenceSearchItem> items) {
|
||||
allEntries.addAll(items);
|
||||
}
|
||||
|
||||
List<PreferenceSearchItem> searchFor(final String keyword) {
|
||||
if (TextUtils.isEmpty(keyword)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return configuration.getSearcher()
|
||||
.search(allEntries.stream(), keyword)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
allEntries.clear();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Contains classes for searching inside the preferences.
|
||||
* <br/>
|
||||
* This code is based on
|
||||
* <a href="https://github.com/ByteHamster/SearchPreference">ByteHamster/SearchPreference</a>
|
||||
* (MIT license) but was heavily modified/refactored for our use.
|
||||
*
|
||||
* @author litetex
|
||||
*/
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
|
@ -27,8 +27,8 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.settings.SelectChannelFragment;
|
||||
|
@ -44,8 +44,6 @@ import java.util.List;
|
|||
import static org.schabi.newpipe.settings.tabs.Tab.typeFrom;
|
||||
|
||||
public class ChooseTabsFragment extends Fragment {
|
||||
private static final int MENU_ITEM_RESTORE_ID = 123456;
|
||||
|
||||
private TabsManager tabsManager;
|
||||
|
||||
private final List<Tab> tabList = new ArrayList<>();
|
||||
|
@ -110,21 +108,14 @@ public class ChooseTabsFragment extends Fragment {
|
|||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE,
|
||||
R.string.restore_defaults);
|
||||
final MenuItem restoreItem = menu.add(R.string.restore_defaults);
|
||||
restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(),
|
||||
R.drawable.ic_settings_backup_restore));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
if (item.getItemId() == MENU_ITEM_RESTORE_ID) {
|
||||
restoreItem.setOnMenuItemClickListener(ev -> {
|
||||
restoreDefaults();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
});
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -182,7 +173,7 @@ public class ChooseTabsFragment extends Fragment {
|
|||
final Tab.Type type = typeFrom(tabId);
|
||||
|
||||
if (type == null) {
|
||||
ErrorActivity.reportErrorInSnackbar(this,
|
||||
ErrorUtil.showSnackbar(this,
|
||||
new ErrorInfo(new IllegalStateException("Tab id not found: " + tabId),
|
||||
UserAction.SOMETHING_ELSE, "Choosing tabs on settings"));
|
||||
return;
|
||||
|
|
|
@ -12,8 +12,8 @@ import com.grack.nanojson.JsonSink;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem.LocalItemType;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
|
@ -36,6 +36,10 @@ import java.util.Objects;
|
|||
public abstract class Tab {
|
||||
private static final String JSON_TAB_ID_KEY = "tab_id";
|
||||
|
||||
private static final String NO_NAME = "<no-name>";
|
||||
private static final String NO_ID = "<no-id>";
|
||||
private static final String NO_URL = "<no-url>";
|
||||
|
||||
Tab() {
|
||||
}
|
||||
|
||||
|
@ -185,7 +189,9 @@ public abstract class Tab {
|
|||
|
||||
@Override
|
||||
public String getTabName(final Context context) {
|
||||
return "NewPipe"; //context.getString(R.string.blank_page_summary);
|
||||
// TODO: find a better name for the blank tab (maybe "blank_tab") or replace it with
|
||||
// context.getString(R.string.app_name);
|
||||
return "NewPipe"; // context.getString(R.string.blank_page_summary);
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
|
@ -309,7 +315,7 @@ public abstract class Tab {
|
|||
private String kioskId;
|
||||
|
||||
private KioskTab() {
|
||||
this(-1, "<no-id>");
|
||||
this(-1, NO_ID);
|
||||
}
|
||||
|
||||
public KioskTab(final int kioskServiceId, final String kioskId) {
|
||||
|
@ -357,7 +363,7 @@ public abstract class Tab {
|
|||
@Override
|
||||
protected void readDataFromJson(final JsonObject jsonObject) {
|
||||
kioskServiceId = jsonObject.getInt(JSON_KIOSK_SERVICE_ID_KEY, -1);
|
||||
kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, "<no-id>");
|
||||
kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, NO_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -395,7 +401,7 @@ public abstract class Tab {
|
|||
private String channelName;
|
||||
|
||||
private ChannelTab() {
|
||||
this(-1, "<no-url>", "<no-name>");
|
||||
this(-1, NO_URL, NO_NAME);
|
||||
}
|
||||
|
||||
public ChannelTab(final int channelServiceId, final String channelUrl,
|
||||
|
@ -440,8 +446,8 @@ public abstract class Tab {
|
|||
@Override
|
||||
protected void readDataFromJson(final JsonObject jsonObject) {
|
||||
channelServiceId = jsonObject.getInt(JSON_CHANNEL_SERVICE_ID_KEY, -1);
|
||||
channelUrl = jsonObject.getString(JSON_CHANNEL_URL_KEY, "<no-url>");
|
||||
channelName = jsonObject.getString(JSON_CHANNEL_NAME_KEY, "<no-name>");
|
||||
channelUrl = jsonObject.getString(JSON_CHANNEL_URL_KEY, NO_URL);
|
||||
channelName = jsonObject.getString(JSON_CHANNEL_NAME_KEY, NO_NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -506,7 +512,7 @@ public abstract class Tab {
|
|||
final StreamingService service = NewPipe.getService(kioskServiceId);
|
||||
kioskId = service.getKioskList().getDefaultKioskId();
|
||||
} catch (final ExtractionException e) {
|
||||
ErrorActivity.reportErrorInSnackbar(context, new ErrorInfo(e,
|
||||
ErrorUtil.showSnackbar(context, new ErrorInfo(e,
|
||||
UserAction.REQUESTED_KIOSK, "Loading default kiosk for selected service"));
|
||||
}
|
||||
return kioskId;
|
||||
|
@ -527,7 +533,7 @@ public abstract class Tab {
|
|||
private LocalItemType playlistType;
|
||||
|
||||
private PlaylistTab() {
|
||||
this(-1, "<no-name>");
|
||||
this(-1, NO_NAME);
|
||||
}
|
||||
|
||||
public PlaylistTab(final long playlistId, final String playlistName) {
|
||||
|
@ -535,7 +541,7 @@ public abstract class Tab {
|
|||
this.playlistId = playlistId;
|
||||
this.playlistType = LocalItemType.PLAYLIST_LOCAL_ITEM;
|
||||
this.playlistServiceId = -1;
|
||||
this.playlistUrl = "<no-url>";
|
||||
this.playlistUrl = NO_URL;
|
||||
}
|
||||
|
||||
public PlaylistTab(final int playlistServiceId, final String playlistUrl,
|
||||
|
@ -589,8 +595,8 @@ public abstract class Tab {
|
|||
@Override
|
||||
protected void readDataFromJson(final JsonObject jsonObject) {
|
||||
playlistServiceId = jsonObject.getInt(JSON_PLAYLIST_SERVICE_ID_KEY, -1);
|
||||
playlistUrl = jsonObject.getString(JSON_PLAYLIST_URL_KEY, "<no-url>");
|
||||
playlistName = jsonObject.getString(JSON_PLAYLIST_NAME_KEY, "<no-name>");
|
||||
playlistUrl = jsonObject.getString(JSON_PLAYLIST_URL_KEY, NO_URL);
|
||||
playlistName = jsonObject.getString(JSON_PLAYLIST_NAME_KEY, NO_NAME);
|
||||
playlistId = jsonObject.getInt(JSON_PLAYLIST_ID_KEY, -1);
|
||||
playlistType = LocalItemType.valueOf(
|
||||
jsonObject.getString(JSON_PLAYLIST_TYPE_KEY,
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
package org.schabi.newpipe.streams.io;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
/**
|
||||
* Helper for when no file-manager/activity was found.
|
||||
*/
|
||||
public final class NoFileManagerSafeGuard {
|
||||
private NoFileManagerSafeGuard() {
|
||||
// No impl
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert dialog when no file-manager is found.
|
||||
* @param context Context
|
||||
*/
|
||||
private static void showActivityNotFoundAlert(final Context context) {
|
||||
if (context == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unable to open no file manager alert dialog: Context is null");
|
||||
}
|
||||
|
||||
final String message;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Android 10+ only allows SAF
|
||||
message = context.getString(R.string.no_appropriate_file_manager_message_android_10);
|
||||
} else {
|
||||
message = context.getString(
|
||||
R.string.no_appropriate_file_manager_message,
|
||||
context.getString(R.string.downloads_storage_use_saf_title));
|
||||
}
|
||||
|
||||
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.no_app_to_open_intent)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the file manager safely.
|
||||
*
|
||||
* If no file manager is found (which is normally only the case when the user uninstalled
|
||||
* the default file manager or the OS lacks one) an alert dialog shows up, asking the user
|
||||
* to fix the situation.
|
||||
*
|
||||
* @param activityResultLauncher see {@link ActivityResultLauncher#launch(Object)}
|
||||
* @param input see {@link ActivityResultLauncher#launch(Object)}
|
||||
* @param tag Tag used for logging
|
||||
* @param context Context
|
||||
* @param <I> see {@link ActivityResultLauncher#launch(Object)}
|
||||
*/
|
||||
public static <I> void launchSafe(
|
||||
final ActivityResultLauncher<I> activityResultLauncher,
|
||||
final I input,
|
||||
final String tag,
|
||||
final Context context
|
||||
) {
|
||||
try {
|
||||
activityResultLauncher.launch(input);
|
||||
} catch (final ActivityNotFoundException aex) {
|
||||
Log.w(tag, "Unable to launch file/directory picker", aex);
|
||||
NoFileManagerSafeGuard.showActivityNotFoundAlert(context);
|
||||
}
|
||||
}
|
||||
}
|
43
app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java
Normal file
43
app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java
Normal file
|
@ -0,0 +1,43 @@
|
|||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
/**
|
||||
* Utility class for the Android keyboard.
|
||||
* <p>
|
||||
* See also <a href="https://stackoverflow.com/q/1109022">https://stackoverflow.com/q/1109022</a>
|
||||
* </p>
|
||||
*/
|
||||
public final class KeyboardUtil {
|
||||
private KeyboardUtil() {
|
||||
}
|
||||
|
||||
public static void showKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editText.requestFocus()) {
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
|
||||
}
|
||||
}
|
||||
|
||||
public static void hideKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
imm.hideSoftInputFromWindow(editText.getWindowToken(),
|
||||
InputMethodManager.RESULT_UNCHANGED_SHOWN);
|
||||
|
||||
editText.clearFocus();
|
||||
}
|
||||
}
|
|
@ -157,9 +157,8 @@ public final class NavigationHelper {
|
|||
return;
|
||||
}
|
||||
|
||||
if (PlayerHolder.getInstance().getType() != PlayerType.POPUP) {
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.POPUP.ordinal());
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
|
@ -168,10 +167,9 @@ public final class NavigationHelper {
|
|||
public static void playOnBackgroundPlayer(final Context context,
|
||||
final PlayQueue queue,
|
||||
final boolean resumePlayback) {
|
||||
if (PlayerHolder.getInstance().getType() != MainPlayer.PlayerType.AUDIO) {
|
||||
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
}
|
||||
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.AUDIO.ordinal());
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
package org.schabi.newpipe.util;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Utility class for putting the uploader url into the database - when required.
|
||||
*/
|
||||
public final class SaveUploaderUrlHelper {
|
||||
private SaveUploaderUrlHelper() {
|
||||
}
|
||||
|
||||
// Public functions which call the function that does
|
||||
// the actual work with the correct parameters
|
||||
public static void saveUploaderUrlIfNeeded(@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
saveUploaderUrlIfNeeded(fragment.requireContext(),
|
||||
infoItem.getServiceId(),
|
||||
infoItem.getUrl(),
|
||||
infoItem.getUploaderUrl(),
|
||||
callback);
|
||||
}
|
||||
public static void saveUploaderUrlIfNeeded(@NonNull final Context context,
|
||||
@NonNull final PlayQueueItem queueItem,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
saveUploaderUrlIfNeeded(context,
|
||||
queueItem.getServiceId(),
|
||||
queueItem.getUrl(),
|
||||
queueItem.getUploaderUrl(),
|
||||
callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and saves the uploaderUrl if it is empty (meaning that it does
|
||||
* not exist in the video item). The callback is called with either the
|
||||
* fetched uploaderUrl, or the already saved uploaderUrl, but it is always
|
||||
* called with a valid uploaderUrl that can be used to show channel details.
|
||||
*
|
||||
* @param context Context
|
||||
* @param serviceId The serviceId of the item
|
||||
* @param url The item url
|
||||
* @param uploaderUrl The uploaderUrl of the item, if null or empty, it
|
||||
* will be fetched using the item url.
|
||||
* @param callback The callback that returns the fetched or existing
|
||||
* uploaderUrl
|
||||
*/
|
||||
private static void saveUploaderUrlIfNeeded(@NonNull final Context context,
|
||||
final int serviceId,
|
||||
@NonNull final String url,
|
||||
// Only used if not null or empty
|
||||
@Nullable final String uploaderUrl,
|
||||
@NonNull final SaveUploaderUrlCallback callback) {
|
||||
if (isNullOrEmpty(uploaderUrl)) {
|
||||
Toast.makeText(context, R.string.loading_channel_details,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
NewPipeDatabase.getInstance(context).streamDAO()
|
||||
.setUploaderUrl(serviceId, url, result.getUploaderUrl())
|
||||
.subscribeOn(Schedulers.io()).subscribe();
|
||||
callback.onCallback(result.getUploaderUrl());
|
||||
}, throwable -> ErrorUtil.createNotification(context,
|
||||
new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
|
||||
"Could not load channel details")
|
||||
));
|
||||
} else {
|
||||
callback.onCallback(uploaderUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public interface SaveUploaderUrlCallback {
|
||||
void onCallback(@NonNull String uploaderUrl);
|
||||
}
|
||||
}
|
|
@ -2,12 +2,11 @@ package org.schabi.newpipe.util;
|
|||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
@ -21,40 +20,19 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
public enum StreamDialogEntry {
|
||||
//////////////////////////////////////
|
||||
// enum values with DEFAULT actions //
|
||||
//////////////////////////////////////
|
||||
|
||||
show_channel_details(R.string.show_channel_details, (fragment, item) -> {
|
||||
if (isNullOrEmpty(item.getUploaderUrl())) {
|
||||
final int serviceId = item.getServiceId();
|
||||
final String url = item.getUrl();
|
||||
Toast.makeText(fragment.getContext(), R.string.loading_channel_details,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
NewPipeDatabase.getInstance(fragment.requireContext()).streamDAO()
|
||||
.setUploaderUrl(serviceId, url, result.getUploaderUrl())
|
||||
.subscribeOn(Schedulers.io()).subscribe();
|
||||
openChannelFragment(fragment, item, result.getUploaderUrl());
|
||||
}, throwable -> Toast.makeText(
|
||||
// TODO: Open the Error Activity
|
||||
fragment.getContext(),
|
||||
R.string.error_show_channel_details,
|
||||
Toast.LENGTH_SHORT
|
||||
).show());
|
||||
} else {
|
||||
openChannelFragment(fragment, item, item.getUploaderUrl());
|
||||
}
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(fragment, item,
|
||||
uploaderUrl -> openChannelFragment(fragment, item, uploaderUrl));
|
||||
}),
|
||||
|
||||
/**
|
||||
|
@ -63,20 +41,24 @@ public enum StreamDialogEntry {
|
|||
* Info: Add this entry within showStreamDialog.
|
||||
*/
|
||||
enqueue(R.string.enqueue_stream, (fragment, item) -> {
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), new SinglePlayQueue(item));
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), fullItem));
|
||||
}),
|
||||
|
||||
enqueue_next(R.string.enqueue_next_stream, (fragment, item) -> {
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), new SinglePlayQueue(item));
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), fullItem));
|
||||
}),
|
||||
|
||||
start_here_on_background(R.string.start_here_on_background, (fragment, item) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(fragment.getContext(),
|
||||
new SinglePlayQueue(item), true)),
|
||||
start_here_on_background(R.string.start_here_on_background, (fragment, item) -> {
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.playOnBackgroundPlayer(fragment.getContext(), fullItem, true));
|
||||
}),
|
||||
|
||||
start_here_on_popup(R.string.start_here_on_popup, (fragment, item) ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(),
|
||||
new SinglePlayQueue(item), true)),
|
||||
start_here_on_popup(R.string.start_here_on_popup, (fragment, item) -> {
|
||||
fetchItemInfoIfSparse(fragment, item, fullItem ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(), fullItem, true));
|
||||
}),
|
||||
|
||||
set_as_playlist_thumbnail(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
|
||||
}), // has to be set manually
|
||||
|
@ -218,4 +200,39 @@ public enum StreamDialogEntry {
|
|||
fragment.requireActivity().getSupportFragmentManager(),
|
||||
item.getServiceId(), uploaderUrl, item.getUploaderName());
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// helper functions //
|
||||
/////////////////////////////////////////////
|
||||
|
||||
private static void fetchItemInfoIfSparse(final Fragment fragment,
|
||||
final StreamInfoItem item,
|
||||
final Consumer<SinglePlayQueue> callback) {
|
||||
if (!(item.getStreamType() == StreamType.LIVE_STREAM
|
||||
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM)
|
||||
&& item.getDuration() < 0) {
|
||||
// Sparse item: fetched by fast fetch
|
||||
ExtractorHelper.getStreamInfo(
|
||||
item.getServiceId(),
|
||||
item.getUrl(),
|
||||
false
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
final HistoryRecordManager recordManager =
|
||||
new HistoryRecordManager(fragment.getContext());
|
||||
recordManager.saveStreamState(result, 0)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError(throwable -> Log.e("StreamDialogEntry",
|
||||
throwable.toString()))
|
||||
.subscribe();
|
||||
|
||||
callback.accept(new SinglePlayQueue(result));
|
||||
}, throwable -> Log.e("StreamDialogEntry", throwable.toString()));
|
||||
} else {
|
||||
callback.accept(new SinglePlayQueue(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,7 +33,9 @@ import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler
|
|||
public final class TextLinkifier {
|
||||
public static final String TAG = TextLinkifier.class.getSimpleName();
|
||||
|
||||
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[A-Za-z0-9_]+)");
|
||||
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
|
||||
private static final Pattern HASHTAGS_PATTERN =
|
||||
Pattern.compile("(#[\\p{L}0-9_]+)");
|
||||
|
||||
private TextLinkifier() {
|
||||
}
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
|
||||
class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) {
|
||||
|
||||
private var backgroundPaint = Paint()
|
||||
|
||||
private var widthPx = 0
|
||||
private var heightPx = 0
|
||||
|
||||
// Background
|
||||
|
||||
private var shapePath = Path()
|
||||
private var arcSize: Float = 80f
|
||||
private var isLeft = true
|
||||
|
||||
init {
|
||||
requireNotNull(context) { "Context is null." }
|
||||
|
||||
backgroundPaint.apply {
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
color = 0x30000000
|
||||
}
|
||||
|
||||
val dm = context.resources.displayMetrics
|
||||
widthPx = dm.widthPixels
|
||||
heightPx = dm.heightPixels
|
||||
|
||||
updatePathShape()
|
||||
}
|
||||
|
||||
fun updateArcSize(baseView: View) {
|
||||
val newArcSize = baseView.height / 11.4f
|
||||
if (arcSize != newArcSize) {
|
||||
arcSize = newArcSize
|
||||
updatePathShape()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePosition(newIsLeft: Boolean) {
|
||||
if (isLeft != newIsLeft) {
|
||||
isLeft = newIsLeft
|
||||
updatePathShape()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePathShape() {
|
||||
val halfWidth = widthPx * 0.5f
|
||||
|
||||
shapePath.reset()
|
||||
|
||||
val w = if (isLeft) 0f else widthPx.toFloat()
|
||||
val f = if (isLeft) 1 else -1
|
||||
|
||||
shapePath.moveTo(w, 0f)
|
||||
shapePath.lineTo(f * (halfWidth - arcSize) + w, 0f)
|
||||
shapePath.quadTo(
|
||||
f * (halfWidth + arcSize) + w,
|
||||
heightPx.toFloat() / 2,
|
||||
f * (halfWidth - arcSize) + w,
|
||||
heightPx.toFloat()
|
||||
)
|
||||
shapePath.lineTo(w, heightPx.toFloat())
|
||||
|
||||
shapePath.close()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
widthPx = w
|
||||
heightPx = h
|
||||
updatePathShape()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
canvas?.clipPath(shapePath)
|
||||
canvas?.drawPath(shapePath, backgroundPaint)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.END
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.player.event.DisplayPortion
|
||||
import org.schabi.newpipe.player.event.DoubleTapListener
|
||||
|
||||
class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) :
|
||||
ConstraintLayout(context, attrs), DoubleTapListener {
|
||||
|
||||
private var secondsView: SecondsView
|
||||
private var circleClipTapView: CircleClipTapView
|
||||
private var rootConstraintLayout: ConstraintLayout
|
||||
|
||||
private var wasForwarding: Boolean = false
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.player_fast_seek_overlay, this, true)
|
||||
|
||||
secondsView = findViewById(R.id.seconds_view)
|
||||
circleClipTapView = findViewById(R.id.circle_clip_tap_view)
|
||||
rootConstraintLayout = findViewById(R.id.root_constraint_layout)
|
||||
|
||||
addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||
circleClipTapView.updateArcSize(view)
|
||||
}
|
||||
}
|
||||
|
||||
private var performListener: PerformListener? = null
|
||||
|
||||
fun performListener(listener: PerformListener) = apply {
|
||||
performListener = listener
|
||||
}
|
||||
|
||||
private var seekSecondsSupplier: () -> Int = { 0 }
|
||||
|
||||
fun seekSecondsSupplier(supplier: () -> Int) = apply {
|
||||
seekSecondsSupplier = supplier
|
||||
}
|
||||
|
||||
// Indicates whether this (double) tap is the first of a series
|
||||
// Decides whether to call performListener.onAnimationStart or not
|
||||
private var initTap: Boolean = false
|
||||
|
||||
override fun onDoubleTapStarted(portion: DisplayPortion) {
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "onDoubleTapStarted called with portion = [$portion]")
|
||||
|
||||
initTap = false
|
||||
|
||||
secondsView.stopAnimation()
|
||||
}
|
||||
|
||||
override fun onDoubleTapProgressDown(portion: DisplayPortion) {
|
||||
val shouldForward: Boolean =
|
||||
performListener?.getFastSeekDirection(portion)?.directionAsBoolean ?: return
|
||||
|
||||
if (DEBUG)
|
||||
Log.d(
|
||||
TAG,
|
||||
"onDoubleTapProgressDown called with " +
|
||||
"shouldForward = [$shouldForward], " +
|
||||
"wasForwarding = [$wasForwarding], " +
|
||||
"initTap = [$initTap], "
|
||||
)
|
||||
|
||||
/*
|
||||
* Check if a initial tap occurred or if direction was switched
|
||||
*/
|
||||
if (!initTap || wasForwarding != shouldForward) {
|
||||
// Reset seconds and update position
|
||||
secondsView.seconds = 0
|
||||
changeConstraints(shouldForward)
|
||||
circleClipTapView.updatePosition(!shouldForward)
|
||||
secondsView.setForwarding(shouldForward)
|
||||
|
||||
wasForwarding = shouldForward
|
||||
|
||||
if (!initTap) {
|
||||
initTap = true
|
||||
}
|
||||
}
|
||||
|
||||
performListener?.onDoubleTap()
|
||||
|
||||
secondsView.seconds += seekSecondsSupplier.invoke()
|
||||
performListener?.seek(forward = shouldForward)
|
||||
}
|
||||
|
||||
override fun onDoubleTapFinished() {
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]")
|
||||
|
||||
if (initTap) performListener?.onDoubleTapEnd()
|
||||
initTap = false
|
||||
|
||||
secondsView.stopAnimation()
|
||||
}
|
||||
|
||||
private fun changeConstraints(forward: Boolean) {
|
||||
val constraintSet = ConstraintSet()
|
||||
with(constraintSet) {
|
||||
clone(rootConstraintLayout)
|
||||
clear(secondsView.id, if (forward) START else END)
|
||||
connect(
|
||||
secondsView.id, if (forward) END else START,
|
||||
PARENT_ID, if (forward) END else START
|
||||
)
|
||||
secondsView.startAnimation()
|
||||
applyTo(rootConstraintLayout)
|
||||
}
|
||||
}
|
||||
|
||||
interface PerformListener {
|
||||
fun onDoubleTap()
|
||||
fun onDoubleTapEnd()
|
||||
/**
|
||||
* Determines if the playback should forward/rewind or do nothing.
|
||||
*/
|
||||
@NonNull
|
||||
fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection
|
||||
fun seek(forward: Boolean)
|
||||
|
||||
enum class FastSeekDirection(val directionAsBoolean: Boolean?) {
|
||||
NONE(null),
|
||||
FORWARD(true),
|
||||
BACKWARD(false);
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlayerFastSeekOverlay"
|
||||
private val DEBUG = MainActivity.DEBUG
|
||||
}
|
||||
}
|
181
app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
Normal file
181
app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
Normal file
|
@ -0,0 +1,181 @@
|
|||
package org.schabi.newpipe.views.player
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
|
||||
class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
|
||||
|
||||
companion object {
|
||||
const val ICON_ANIMATION_DURATION = 750L
|
||||
}
|
||||
|
||||
var cycleDuration: Long = ICON_ANIMATION_DURATION
|
||||
set(value) {
|
||||
firstAnimator.duration = value / 5
|
||||
secondAnimator.duration = value / 5
|
||||
thirdAnimator.duration = value / 5
|
||||
fourthAnimator.duration = value / 5
|
||||
fifthAnimator.duration = value / 5
|
||||
field = value
|
||||
}
|
||||
|
||||
var seconds: Int = 0
|
||||
set(value) {
|
||||
binding.tvSeconds.text = context.resources.getQuantityString(
|
||||
R.plurals.seconds, value, value
|
||||
)
|
||||
field = value
|
||||
}
|
||||
|
||||
// Done as a field so that we don't have to compute on each tab if animations are enabled
|
||||
private val animationsEnabled = DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)
|
||||
|
||||
val binding = PlayerFastSeekSecondsViewBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
fun setForwarding(isForward: Boolean) {
|
||||
binding.triangleContainer.rotation = if (isForward) 0f else 180f
|
||||
}
|
||||
|
||||
fun startAnimation() {
|
||||
stopAnimation()
|
||||
|
||||
if (animationsEnabled) {
|
||||
firstAnimator.start()
|
||||
} else {
|
||||
// If no animations are enable show the arrow(s) without animation
|
||||
showWithoutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAnimation() {
|
||||
firstAnimator.cancel()
|
||||
secondAnimator.cancel()
|
||||
thirdAnimator.cancel()
|
||||
fourthAnimator.cancel()
|
||||
fifthAnimator.cancel()
|
||||
|
||||
reset()
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
}
|
||||
|
||||
private fun showWithoutAnimation() {
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 1f
|
||||
}
|
||||
|
||||
private val firstAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon1.alpha = it
|
||||
},
|
||||
{
|
||||
secondAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val secondAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon2.alpha = it
|
||||
},
|
||||
{
|
||||
thirdAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val thirdAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 1f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 0f
|
||||
},
|
||||
{
|
||||
binding.icon1.alpha = 1f - binding.icon3.alpha
|
||||
binding.icon3.alpha = it
|
||||
},
|
||||
{
|
||||
fourthAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val fourthAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 1f
|
||||
binding.icon3.alpha = 1f
|
||||
},
|
||||
{
|
||||
binding.icon2.alpha = 1f - it
|
||||
},
|
||||
{
|
||||
fifthAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private val fifthAnimator: ValueAnimator = CustomValueAnimator(
|
||||
{
|
||||
binding.icon1.alpha = 0f
|
||||
binding.icon2.alpha = 0f
|
||||
binding.icon3.alpha = 1f
|
||||
},
|
||||
{
|
||||
binding.icon3.alpha = 1f - it
|
||||
},
|
||||
{
|
||||
firstAnimator.start()
|
||||
}
|
||||
)
|
||||
|
||||
private inner class CustomValueAnimator(
|
||||
start: () -> Unit,
|
||||
update: (value: Float) -> Unit,
|
||||
end: () -> Unit
|
||||
) : ValueAnimator() {
|
||||
|
||||
init {
|
||||
duration = cycleDuration / 5
|
||||
setFloatValues(0f, 1f)
|
||||
|
||||
addUpdateListener { update(it.animatedValue as Float) }
|
||||
addListener(object : AnimatorListener {
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
start()
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
end()
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) = Unit
|
||||
|
||||
override fun onAnimationRepeat(animation: Animator?) = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -108,10 +108,12 @@ public class FileStreamSAF extends SharpStream {
|
|||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSetLength() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSeek() {
|
||||
return true;
|
||||
}
|
||||
|
@ -131,10 +133,12 @@ public class FileStreamSAF extends SharpStream {
|
|||
out.write(buffer, offset, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLength(long length) throws IOException {
|
||||
channel.truncate(length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seek(long offset) throws IOException {
|
||||
channel.position(offset);
|
||||
}
|
||||
|
|
|
@ -43,8 +43,8 @@ import org.schabi.newpipe.App;
|
|||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.LocalPlayerActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
@ -607,9 +607,9 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
|
|||
service = ErrorInfo.SERVICE_NONE;
|
||||
}
|
||||
|
||||
ErrorActivity.reportError(mContext,
|
||||
ErrorUtil.createNotification(mContext,
|
||||
new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action,
|
||||
service, request.toString(), reason, null));
|
||||
service, request.toString(), reason));
|
||||
}
|
||||
|
||||
public void clearFinishedDownloads(boolean delete) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue